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.
This commit is contained in:
Zero Liu 2026-04-26 01:47:03 -07:00
parent 4f9c4f813e
commit fe25ed9e16
30 changed files with 1704 additions and 284 deletions

View file

@ -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.<id>` 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: <id>` 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.<id>.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.<id>.prompt` but requires shipping an opencode plugin file; rejected for complexity.

View file

@ -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

View file

@ -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<ListSessionsResponse> {
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<ResumeSessionResponse> {
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<LoadSessionResponse> {
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();

View file

@ -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<AcpSpawnDescriptor> {
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<string>();
const merged: string[] = [];
for (const p of [...candidates, ...inheritedParts]) {
if (!seen.has(p)) {
seen.add(p);
merged.push(p);
}
}
return merged.join(sep);
}

View file

@ -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<ContentProps> = ({ onClose }) => {
const initial = getSettings().agentMode?.backends?.["claude-code"]?.binaryPath ?? "";
const [path, setPath] = React.useState(initial);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const autoDetect = React.useCallback(async (): Promise<void> => {
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<void> => {
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 (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>
Claude Code uses the official <code>@agentclientprotocol/claude-agent-acp</code> adapter,
which wraps the local <code>claude</code> CLI. It inherits your existing{" "}
<code>claude auth login</code> credentials no API key needed if you&apos;re already signed
in.
</p>
<div className="tw-flex tw-flex-col tw-gap-1">
<span className="tw-text-xs tw-text-muted">Install command</span>
<div className="tw-flex tw-items-center tw-gap-2">
<code className="tw-flex-1 tw-break-all tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{CLAUDE_CODE_INSTALL_COMMAND}
</code>
<Button variant="ghost" size="sm" onClick={copy}>
Copy
</Button>
</div>
</div>
<div className="tw-flex tw-flex-col tw-gap-2">
<span className="tw-text-xs tw-text-muted">Binary path</span>
<div className="tw-flex tw-items-center tw-gap-2">
<Input
type="text"
placeholder="/absolute/path/to/claude-agent-acp"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={autoDetect} disabled={busy}>
Auto-detect
</Button>
</div>
{error && <div className="tw-text-xs tw-text-error">{error}</div>}
</div>
<div className="tw-flex tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="default" onClick={save} disabled={busy}>
Save
</Button>
</div>
</div>
);
};
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(<ClaudeCodeInstallContent onClose={() => this.close()} />);
}
onClose(): void {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
}
}

View file

@ -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<Props> = ({ app }) => {
const settings = useSettingsValue();
const stored = settings.agentMode?.backends?.["claude-code"]?.binaryPath ?? "";
const [pathInput, setPathInput] = React.useState(stored);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
setPathInput(stored);
}, [stored]);
const apply = React.useCallback(async (): Promise<void> => {
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<void> => {
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 ? (
<>
<div>
Ready <code>{CLAUDE_CODE_BINARY_NAME}</code> (custom path)
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{stored}</div>
</>
) : (
<span className="tw-text-warning">
Setup required Claude Code binary path not configured.
</span>
);
return (
<>
<SettingItem type="custom" title="Claude Code binary" description={description}>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
{!stored && (
<Button variant="default" onClick={openInstallModal}>
Configure
</Button>
)}
{stored && (
<Button variant="destructive" onClick={clear}>
Clear path
</Button>
)}
</div>
</SettingItem>
<SettingItem
type="custom"
title="Custom claude-agent-acp path"
description="Point Agent Mode at the claude-agent-acp binary. Claude Code inherits auth from your local `claude auth login` credentials."
>
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2 sm:tw-w-[360px]">
<div className="tw-flex tw-items-center tw-gap-2">
<Input
type="text"
placeholder="/absolute/path/to/claude-agent-acp"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={autoDetect} disabled={busy}>
Auto-detect
</Button>
</div>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={apply}>
Apply
</Button>
</div>
{error && <div className="tw-text-xs tw-text-error">{error}</div>}
</div>
</SettingItem>
</>
);
};

View file

@ -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<ClaudeCodeBackendSettings>): 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<void> {
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<void> {
updateClaudeCodeFields({ probeSessionId: sessionId });
},
};

View file

@ -0,0 +1 @@
export { ClaudeCodeBackendDescriptor } from "./descriptor";

View file

@ -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<Store> | ((current: Store) => Partial<Store>)) => {
const partial = typeof settings === "function" ? settings(store) : settings;
store = { ...store, ...partial };
},
};
});

View file

@ -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<OpencodeBackendSettings>): 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 {

View file

@ -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<Props> = ({ plugin, app }) => {
const [customPathInput, setCustomPathInput] = React.useState("");
const [customPathError, setCustomPathError] = React.useState<string | null>(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<Props> = ({ plugin, app }) => {
setCustomPathInput("");
};
const autoDetect = async (): Promise<void> => {
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 (
<>
<SettingItem
@ -141,12 +169,17 @@ export const OpencodeSettingsPanel: React.FC<Props> = ({ 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."
>
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2 sm:tw-w-[360px]">
<Input
type="text"
placeholder="/absolute/path/to/opencode"
value={customPathInput}
onChange={(e) => setCustomPathInput(e.target.value)}
/>
<div className="tw-flex tw-items-center tw-gap-2">
<Input
type="text"
placeholder="/absolute/path/to/opencode"
value={customPathInput}
onChange={(e) => setCustomPathInput(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={autoDetect} disabled={detecting}>
Auto-detect
</Button>
</div>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={applyCustomPath}>
Apply

View file

@ -106,19 +106,18 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
// can't resolve (an OpenCode-native model with no Copilot entry), persist
// the raw id under the same field.
const selectedModelKey = agentModelIdToCopilotKey(modelId) ?? modelId;
const current = getSettings();
setSettings({
setSettings((cur) => ({
agentMode: {
...current.agentMode,
...cur.agentMode,
backends: {
...current.agentMode.backends,
...cur.agentMode.backends,
opencode: {
...(current.agentMode.backends?.opencode ?? {}),
...(cur.agentMode.backends?.opencode ?? {}),
selectedModelKey,
},
},
},
});
}));
},
copilotModelKeyToAgentModelId(model: CustomModel): string | undefined {
@ -134,6 +133,26 @@ export const OpencodeBackendDescriptor: BackendDescriptor = {
}
return undefined;
},
getProbeSessionId(settings: CopilotSettings): string | undefined {
const id = settings.agentMode?.backends?.opencode?.probeSessionId;
return id && id.length > 0 ? id : undefined;
},
async persistProbeSessionId(sessionId: string, _plugin: CopilotPlugin): Promise<void> {
setSettings((cur) => ({
agentMode: {
...cur.agentMode,
backends: {
...cur.agentMode.backends,
opencode: {
...(cur.agentMode.backends?.opencode ?? {}),
probeSessionId: sessionId,
},
},
},
}));
},
};
/**

View file

@ -1,4 +1,5 @@
import type { CopilotSettings } from "@/settings/model";
import { ClaudeCodeBackendDescriptor } from "./claude-code/descriptor";
import { OpencodeBackendDescriptor } from "./opencode/descriptor";
import type { BackendDescriptor, BackendId } from "@/agentMode/session/types";
@ -14,6 +15,7 @@ import type { BackendDescriptor, BackendId } from "@/agentMode/session/types";
*/
export const backendRegistry: Record<BackendId, BackendDescriptor> = {
opencode: OpencodeBackendDescriptor,
"claude-code": ClaudeCodeBackendDescriptor,
};
/** Resolve the active backend descriptor from settings. Falls back to `opencode`. */

View file

@ -1,8 +1,9 @@
import type { App } from "obsidian";
import type CopilotPlugin from "@/main";
import { getSettings } from "@/settings/model";
import { logError } from "@/logger";
import { getActiveBackendDescriptor } from "./backends/registry";
import { getSettings } from "@/settings/model";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import { AgentModelPreloader } from "./session/AgentModelPreloader";
import { AgentSessionManager } from "./session/AgentSessionManager";
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
@ -10,31 +11,56 @@ import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
// `@/agentMode` (this file), never deep paths into acp/, session/, backends/,
// or ui/.
export { AgentModeChat } from "./ui/AgentModeChat";
export { useActiveBackendDescriptor, useBackendInstallState } from "./ui/useBackendDescriptor";
export {
useActiveBackendDescriptor,
useBackendInstallState,
useSessionBackendDescriptor,
} from "./ui/useBackendDescriptor";
export { useAgentModelPicker } from "./ui/useAgentModelPicker";
export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker";
export type { AgentSessionManager } from "./session/AgentSessionManager";
export type { BackendDescriptor, BackendId, InstallState } from "./session/types";
export { getActiveBackendDescriptor } from "./backends/registry";
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
/**
* Single seam between the plugin host (`main.ts`) and Agent Mode. Resolves
* the active backend descriptor and the default permission prompter, wires
* them into a fresh `AgentSessionManager`, and kicks off the descriptor's
* load-time reconcile (e.g. clear stale managed install).
* Single seam between the plugin host (`main.ts`) and Agent Mode. Wires the
* default permission prompter into a fresh `AgentSessionManager` and kicks
* off every registered backend descriptor's load-time reconcile (e.g. clear
* stale managed install). The manager itself is backend-agnostic backends
* are spawned lazily on first session creation.
*
* `main.ts` calls this once on plugin load. To swap backends or prompters,
* shut down the existing manager and call this again.
* `main.ts` calls this once on plugin load. To swap prompters, shut down
* the existing manager and call this again.
*/
export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager {
const descriptor = getActiveBackendDescriptor(getSettings());
const manager = new AgentSessionManager(app, plugin, {
descriptor,
permissionPrompter: createDefaultPermissionPrompter(app),
resolveDescriptor: (id) => backendRegistry[id],
});
// Non-blocking — plugin load should not wait on disk reconcile.
descriptor
.onPluginLoad?.(plugin)
.catch((e) => logError("[AgentMode] backend onPluginLoad failed", e));
for (const descriptor of listBackendDescriptors()) {
descriptor
.onPluginLoad?.(plugin)
.catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e));
}
// Spin up the preloader and kick off a probe per ready backend. We do this
// after the descriptor `onPluginLoad` calls (which may reconcile install
// state) so `getInstallState` returns the most current answer when the
// preloader checks readiness — but we don't await either step. The preload
// itself is fully async/best-effort: a slow/failing probe never blocks
// the UI (the picker section just stays empty until the cache is filled).
const preloader = new AgentModelPreloader(app, plugin, (id) => backendRegistry[id]);
manager.attachModelPreloader(preloader);
// Skip preloading entirely when Agent Mode is disabled. Probing spawns a
// subprocess per backend and the user has opted out of the feature.
const settings = getSettings();
if (!settings.agentMode?.enabled) return manager;
for (const descriptor of listBackendDescriptors()) {
if (descriptor.getInstallState(settings).kind !== "ready") continue;
preloader
.preload(descriptor.id)
.catch((e) => logError(`[AgentMode] preload ${descriptor.id} failed`, e));
}
return manager;
}

View file

@ -0,0 +1,165 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { getSettings } from "@/settings/model";
import type { SessionModelState } from "@agentclientprotocol/sdk";
import { App, FileSystemAdapter, Platform } from "obsidian";
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
import { MethodUnsupportedError } from "@/agentMode/acp/types";
import type { BackendDescriptor, BackendId } from "./types";
/**
* Plugin-lifetime cache of per-backend `availableModels`. ACP exposes models
* only as a side-effect of session creation/resume/load, so without this
* preload the picker would show no entries for non-active backends.
*
* Probes once per backend at startup: prefer resume of a persisted probe
* sessionId, fall back to load, then to new (and persist the new id so the
* next reload can reuse it keeps the agent-side session store at one stale
* entry per machine instead of growing with each reload).
*/
export class AgentModelPreloader {
private readonly cache = new Map<BackendId, SessionModelState>();
private readonly inflight = new Map<BackendId, Promise<void>>();
private readonly listeners = new Set<() => void>();
private disposed = false;
constructor(
private readonly app: App,
private readonly plugin: CopilotPlugin,
private readonly resolveDescriptor: (id: BackendId) => BackendDescriptor | undefined
) {}
getCachedModels(backendId: BackendId): SessionModelState | null {
return this.cache.get(backendId) ?? null;
}
/** Best-effort probe; failures are logged and swallowed. Dedupes per backend. */
preload(backendId: BackendId): Promise<void> {
if (this.disposed) return Promise.resolve();
const existing = this.inflight.get(backendId);
if (existing) return existing;
const promise = this.runProbe(backendId).finally(() => {
this.inflight.delete(backendId);
});
this.inflight.set(backendId, promise);
return promise;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
shutdown(): void {
this.disposed = true;
this.cache.clear();
this.inflight.clear();
this.listeners.clear();
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (e) {
logWarn("[AgentMode] preload listener threw", e);
}
}
}
private async runProbe(backendId: BackendId): Promise<void> {
if (Platform.isMobile) return;
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) return;
const cwd = adapter.getBasePath();
const descriptor = this.resolveDescriptor(backendId);
if (!descriptor) {
logWarn(`[AgentMode] preload skipped: unknown backend ${backendId}`);
return;
}
if (descriptor.getInstallState(getSettings()).kind !== "ready") return;
const proc = new AcpBackendProcess(
this.app,
descriptor.createBackend(this.plugin),
this.plugin.manifest.version
);
try {
await proc.start();
const storedId = descriptor.getProbeSessionId?.(getSettings());
// No registerSessionHandler: probe is models-only, so any session/update
// frames (history replay from session/load) are intentionally dropped.
const models = await this.fetchModels(proc, descriptor, backendId, storedId, cwd);
if (this.disposed) return;
if (models) {
this.cache.set(backendId, models);
const ids = models.availableModels.map((m) => m.modelId).join(", ");
logInfo(
`[AgentMode] preload ${backendId}: cached ${models.availableModels.length} models (current=${models.currentModelId}, available=[${ids}])`
);
this.notify();
} else {
logInfo(`[AgentMode] preload ${backendId}: agent did not report any models`);
}
} catch (err) {
logError(`[AgentMode] preload ${backendId} failed`, err);
} finally {
try {
await proc.shutdown();
} catch (e) {
logWarn(`[AgentMode] preload ${backendId}: shutdown failed`, e);
}
}
}
private async fetchModels(
proc: AcpBackendProcess,
descriptor: BackendDescriptor,
backendId: BackendId,
storedId: string | undefined,
cwd: string
): Promise<SessionModelState | null> {
type Strategy = {
label: string;
run: () => Promise<{ models?: SessionModelState | null }>;
};
const strategies: Strategy[] = [];
if (storedId && proc.isResumeSessionSupported()) {
strategies.push({
label: `resumed probe session ${storedId}`,
run: () => proc.resumeSession({ sessionId: storedId, cwd, mcpServers: [] }),
});
}
if (storedId && proc.isLoadSessionSupported()) {
strategies.push({
label: `loaded probe session ${storedId}`,
run: () => proc.loadSession({ sessionId: storedId, cwd, mcpServers: [] }),
});
}
for (const { label, run } of strategies) {
try {
const resp = await run();
logInfo(`[AgentMode] preload ${backendId}: ${label}`);
return resp.models ?? null;
} catch (err) {
if (!(err instanceof MethodUnsupportedError)) {
logWarn(`[AgentMode] preload ${backendId}: ${label} failed (will fall back)`, err);
}
}
}
const resp = await proc.newSession({ cwd, mcpServers: [] });
logInfo(`[AgentMode] preload ${backendId}: created probe session ${resp.sessionId}`);
if (descriptor.persistProbeSessionId) {
try {
await descriptor.persistProbeSessionId(resp.sessionId, this.plugin);
} catch (e) {
logWarn(`[AgentMode] preload ${backendId}: persistProbeSessionId failed`, e);
}
}
return resp.models ?? null;
}
}

View file

@ -64,7 +64,7 @@ function makeMockBackend(): MockBackend {
describe("AgentSession.sendPrompt", () => {
it("appends user + placeholder synchronously and resolves on stopReason", async () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const { userMessageId, turn } = session.sendPrompt("Hi there");
const messages = session.store.getDisplayMessages();
@ -88,7 +88,7 @@ describe("AgentSession.sendPrompt", () => {
it("rejects if a turn is already in flight", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
session.sendPrompt("first");
expect(() => session.sendPrompt("second")).toThrow(/in flight/);
});
@ -99,7 +99,7 @@ describe("AgentSession.sendPrompt", () => {
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const { turn } = session.sendPrompt("hi");
mock.emit({
@ -130,7 +130,7 @@ describe("AgentSession.sendPrompt", () => {
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const { turn } = session.sendPrompt("hi");
mock.emit({
@ -174,7 +174,7 @@ describe("AgentSession.sendPrompt", () => {
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const { turn } = session.sendPrompt("hi");
await session.cancel();
expect(mock.cancel).toHaveBeenCalledWith({ sessionId: "acp-1" });
@ -196,7 +196,7 @@ describe("AgentSession.create", () => {
],
},
});
const session = await AgentSession.create(mock.asBackend, "/vault", "internal-1");
const session = await AgentSession.create(mock.asBackend, "/vault", "internal-1", "opencode");
expect(session.getModelState()?.currentModelId).toBe("anthropic/sonnet");
expect(session.getModelState()?.availableModels).toHaveLength(2);
});
@ -204,7 +204,7 @@ describe("AgentSession.create", () => {
it("getModelState returns null when the agent doesn't report models", async () => {
const mock = makeMockBackend();
mock.newSession.mockResolvedValueOnce({ sessionId: "acp-1", models: null });
const session = await AgentSession.create(mock.asBackend, "/vault", "internal-1");
const session = await AgentSession.create(mock.asBackend, "/vault", "internal-1", "opencode");
expect(session.getModelState()).toBeNull();
});
@ -220,7 +220,7 @@ describe("AgentSession.create", () => {
],
},
});
await AgentSession.create(mock.asBackend, "/vault", "internal-1", "openai/gpt-5");
await AgentSession.create(mock.asBackend, "/vault", "internal-1", "opencode", "openai/gpt-5");
expect(mock.setSessionModel).toHaveBeenCalledWith({
sessionId: "acp-1",
modelId: "openai/gpt-5",
@ -236,7 +236,13 @@ describe("AgentSession.create", () => {
availableModels: [{ modelId: "anthropic/sonnet", name: "Claude Sonnet" }],
},
});
await AgentSession.create(mock.asBackend, "/vault", "internal-1", "anthropic/sonnet");
await AgentSession.create(
mock.asBackend,
"/vault",
"internal-1",
"opencode",
"anthropic/sonnet"
);
expect(mock.setSessionModel).not.toHaveBeenCalled();
});
@ -249,7 +255,7 @@ describe("AgentSession.create", () => {
availableModels: [{ modelId: "anthropic/sonnet", name: "Claude Sonnet" }],
},
});
await AgentSession.create(mock.asBackend, "/vault", "internal-1", "ghost/model");
await AgentSession.create(mock.asBackend, "/vault", "internal-1", "opencode", "ghost/model");
expect(mock.setSessionModel).not.toHaveBeenCalled();
});
@ -270,6 +276,7 @@ describe("AgentSession.create", () => {
mock.asBackend,
"/vault",
"internal-1",
"opencode",
"openai/gpt-5"
);
// Session is still usable; current model stays at the agent's default.
@ -280,7 +287,7 @@ describe("AgentSession.create", () => {
describe("AgentSession.setModel", () => {
it("calls backend.setSessionModel and updates currentModelId on success", async () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", {
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode", {
currentModelId: "a/b",
availableModels: [
{ modelId: "a/b", name: "A B" },
@ -295,7 +302,7 @@ describe("AgentSession.setModel", () => {
it("rethrows MethodUnsupportedError without mutating local state", async () => {
const mock = makeMockBackend();
mock.setSessionModel.mockRejectedValueOnce(new MethodUnsupportedError("session/set_model"));
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", {
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode", {
currentModelId: "a/b",
availableModels: [{ modelId: "a/b", name: "A B" }],
});
@ -305,7 +312,7 @@ describe("AgentSession.setModel", () => {
it("notifies onModelChanged listeners after successful switch", async () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", {
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode", {
currentModelId: "a/b",
availableModels: [
{ modelId: "a/b", name: "A B" },
@ -326,7 +333,7 @@ describe("AgentSession.setModel", () => {
describe("AgentSession.setLabel", () => {
it("stores trimmed label and notifies onLabelChanged", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const onLabelChanged = jest.fn();
session.subscribe({
onMessagesChanged: jest.fn(),
@ -352,7 +359,7 @@ describe("AgentSession.setLabel", () => {
describe("AgentSession session_info_update", () => {
it("adopts the title pushed by the agent and notifies listeners", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
const onLabelChanged = jest.fn();
session.subscribe({
onMessagesChanged: jest.fn(),
@ -371,7 +378,7 @@ describe("AgentSession session_info_update", () => {
it("ignores agent-pushed titles after the user has renamed the session", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
session.setLabel("My label");
mock.emit({
@ -384,7 +391,7 @@ describe("AgentSession session_info_update", () => {
it("does not require an active turn placeholder", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
// No sendPrompt() — so placeholderId is null. Should still work.
mock.emit({
sessionId: "acp-1",
@ -395,7 +402,7 @@ describe("AgentSession session_info_update", () => {
it("a null/empty agent title clears the label and re-opens it for future agent updates", () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
mock.emit({
sessionId: "acp-1",
update: { sessionUpdate: "session_info_update", title: "First" },
@ -433,7 +440,14 @@ describe("AgentSession title poll after turn", () => {
{ sessionId: "acp-other", cwd: "/vault", title: "Different session", updatedAt: null },
],
});
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", null, "/vault");
const session = new AgentSession(
mock.asBackend,
"acp-1",
"internal-1",
"opencode",
null,
"/vault"
);
const { turn } = session.sendPrompt("hi");
await turn;
await flushMicrotasks();
@ -454,7 +468,14 @@ describe("AgentSession title poll after turn", () => {
},
],
});
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", null, "/vault");
const session = new AgentSession(
mock.asBackend,
"acp-1",
"internal-1",
"opencode",
null,
"/vault"
);
await session.sendPrompt("hi").turn;
await flushMicrotasks();
expect(session.getLabel()).toBeNull();
@ -462,7 +483,14 @@ describe("AgentSession title poll after turn", () => {
it("does not poll when the user has already renamed the session", async () => {
const mock = makeMockBackend();
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", null, "/vault");
const session = new AgentSession(
mock.asBackend,
"acp-1",
"internal-1",
"opencode",
null,
"/vault"
);
session.setLabel("My label");
await session.sendPrompt("hi").turn;
await flushMicrotasks();
@ -473,7 +501,14 @@ describe("AgentSession title poll after turn", () => {
it("does not poll on cancelled turns", async () => {
const mock = makeMockBackend();
mock.prompt.mockResolvedValueOnce({ stopReason: "cancelled" });
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", null, "/vault");
const session = new AgentSession(
mock.asBackend,
"acp-1",
"internal-1",
"opencode",
null,
"/vault"
);
await session.sendPrompt("hi").turn;
await flushMicrotasks();
expect(mock.listSessions).not.toHaveBeenCalled();
@ -482,7 +517,14 @@ describe("AgentSession title poll after turn", () => {
it("silently no-ops when the agent doesn't support session/list", async () => {
const mock = makeMockBackend();
mock.listSessions.mockRejectedValueOnce(new MethodUnsupportedError("session/list"));
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", null, "/vault");
const session = new AgentSession(
mock.asBackend,
"acp-1",
"internal-1",
"opencode",
null,
"/vault"
);
await session.sendPrompt("hi").turn;
await flushMicrotasks();
expect(session.getLabel()).toBeNull();
@ -493,7 +535,7 @@ describe("AgentSession title poll after turn", () => {
mock.listSessions.mockResolvedValueOnce({
sessions: [{ sessionId: "acp-1", cwd: "/vault", title: "Found me", updatedAt: null }],
});
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1");
const session = new AgentSession(mock.asBackend, "acp-1", "internal-1", "opencode");
await session.sendPrompt("hi").turn;
await flushMicrotasks();
expect(mock.listSessions).toHaveBeenCalledWith({});
@ -523,8 +565,8 @@ describe("AgentSession multi-session routing", () => {
isListSessionsSupported: jest.fn(() => true),
} as unknown as AcpBackendProcess;
const sessionA = new AgentSession(backend, "acp-A", "internal-A");
const sessionB = new AgentSession(backend, "acp-B", "internal-B");
const sessionA = new AgentSession(backend, "acp-A", "internal-A", "opencode");
const sessionB = new AgentSession(backend, "acp-B", "internal-B", "opencode");
let resolveA: ((v: { stopReason: string }) => void) | null = null;
let resolveB: ((v: { stopReason: string }) => void) | null = null;
@ -577,7 +619,7 @@ describe("AgentSession multi-session routing", () => {
isListSessionsSupported: jest.fn(() => true),
} as unknown as AcpBackendProcess;
const session = new AgentSession(backend, "acp-1", "internal-1");
const session = new AgentSession(backend, "acp-1", "internal-1", "opencode");
expect(handlers.has("acp-1")).toBe(true);
session.dispose();
expect(handlers.has("acp-1")).toBe(false);

View file

@ -22,7 +22,7 @@ import {
ToolCallUpdate,
} from "@agentclientprotocol/sdk";
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
import { MethodUnsupportedError } from "@/agentMode/acp/types";
import { MethodUnsupportedError, type BackendId } from "@/agentMode/acp/types";
/**
* Prefix opencode uses for placeholder titles before its title-summarizer
@ -64,6 +64,8 @@ export class AgentSession {
private readonly backend: AcpBackendProcess,
readonly acpSessionId: SessionId,
readonly internalId: string,
/** The backend this session was spawned on. */
readonly backendId: BackendId,
initialModelState: SessionModelState | null = null,
private readonly cwd: string | null = null
) {
@ -78,6 +80,7 @@ export class AgentSession {
backend: AcpBackendProcess,
cwd: string,
internalId: string,
backendId: BackendId,
preferredModelId?: string
): Promise<AgentSession> {
const resp = await backend.newSession({ cwd, mcpServers: [] });
@ -89,7 +92,14 @@ export class AgentSession {
} else {
logInfo(`[AgentMode] session ${resp.sessionId} created — agent did not report model state`);
}
const session = new AgentSession(backend, resp.sessionId, internalId, resp.models ?? null, cwd);
const session = new AgentSession(
backend,
resp.sessionId,
internalId,
backendId,
resp.models ?? null,
cwd
);
// Apply sticky preference if it's available and differs from the agent's
// current model. Failures here are non-fatal — the session is usable with
@ -150,6 +160,15 @@ export class AgentSession {
return this.status;
}
/**
* Whether this session has at least one user-visible message. The model
* picker uses this to decide whether non-active backend entries should be
* hidden (mid-conversation) or shown (empty new tab).
*/
hasUserVisibleMessages(): boolean {
return this.store.getDisplayMessages().length > 0;
}
/**
* User-supplied label for this session (shown in the tab strip). `null`
* means "no label" the UI falls back to a positional default like

View file

@ -14,6 +14,13 @@ jest.mock("@/logger", () => ({
logError: jest.fn(),
}));
jest.mock("@/settings/model", () => ({
getSettings: jest.fn(() => ({
agentMode: { activeBackend: "opencode", backends: {} },
})),
setSettings: jest.fn(),
}));
let mockBackendIsRunning = true;
const mockBackendShutdown = jest.fn(async () => undefined);
const mockBackendStart = jest.fn(async () => undefined);
@ -37,10 +44,11 @@ const mockSessionDispose = jest.fn(async () => undefined);
const mockSessionCancel = jest.fn(async () => undefined);
let nextAcpSessionId = 1;
const sessionCreateSpy = jest.spyOn(AgentSession, "create").mockImplementation(
async (_backend, _cwd, internalId) =>
async (_backend, _cwd, internalId, backendId) =>
({
internalId,
acpSessionId: `acp-${nextAcpSessionId++}`,
backendId,
getStatus: () => "idle",
cancel: mockSessionCancel,
dispose: mockSessionDispose,
@ -48,6 +56,7 @@ const sessionCreateSpy = jest.spyOn(AgentSession, "create").mockImplementation(
getLabel: () => null,
setLabel: jest.fn(),
subscribe: () => () => {},
hasUserVisibleMessages: () => false,
}) as unknown as AgentSession
);
@ -76,12 +85,13 @@ function buildDescriptor(): BackendDescriptor {
}
function buildManager(): AgentSessionManager {
const descriptor = buildDescriptor();
return new AgentSessionManager(
buildApp(),
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
{
descriptor: buildDescriptor(),
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
}
);
}
@ -146,10 +156,11 @@ describe("AgentSessionManager.createSession", () => {
throw new Error("boom");
})
.mockImplementationOnce(
async (_b, _c, internalId) =>
async (_b, _c, internalId, backendId) =>
({
internalId,
acpSessionId: "acp-ok",
backendId,
getStatus: () => "idle",
cancel: mockSessionCancel,
dispose: mockSessionDispose,
@ -157,6 +168,7 @@ describe("AgentSessionManager.createSession", () => {
getLabel: () => null,
setLabel: jest.fn(),
subscribe: () => () => {},
hasUserVisibleMessages: () => false,
}) as unknown as AgentSession
);

View file

@ -1,38 +1,44 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
import { getSettings } from "@/settings/model";
import { getSettings, setSettings } from "@/settings/model";
import { err2String } from "@/utils";
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
import { App, FileSystemAdapter, Platform } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
import { AgentSession } from "./AgentSession";
import type { BackendDescriptor } from "./types";
import type { AgentModelPreloader } from "./AgentModelPreloader";
import type { BackendDescriptor, BackendId } from "./types";
export type PermissionPrompter = (
req: RequestPermissionRequest
) => Promise<RequestPermissionResponse>;
// Injected by the barrel so `session/` doesn't have to import
// `backends/registry` directly (would breach the layer boundary).
export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined;
export interface AgentSessionManagerOptions {
descriptor: BackendDescriptor;
permissionPrompter: PermissionPrompter;
resolveDescriptor: DescriptorResolver;
}
/**
* Plugin-scoped coordinator for Agent Mode. Owns a pool of `AgentSession`s
* multiplexed on a single shared `AcpBackendProcess`. Lazily spawns the
* backend on first `createSession()` and tears down on plugin unload via
* `shutdown()`.
* Plugin-scoped coordinator for Agent Mode. Owns one `AcpBackendProcess` per
* registered backend (lazy-spawned on first `createSession(backendId)`) and a
* pool of `AgentSession`s, each tagged with the backend it was created on.
* Tears every backend down on plugin unload via `shutdown()`.
*
* Backend pluggability is handled via `BackendDescriptor`: the manager calls
* `descriptor.createBackend(plugin)` and never imports a specific backend
* class. The permission prompter is injected so this file stays out of the UI
* layer.
* Backend pluggability is handled via `BackendDescriptor`: the manager
* resolves descriptors from `backendRegistry` and calls
* `descriptor.createBackend(plugin)` to construct each `AcpBackend` it
* never imports a specific backend class. The permission prompter is
* injected so this file stays out of the UI layer.
*/
export class AgentSessionManager {
private backend: AcpBackendProcess | null = null;
private starting: Promise<AcpBackendProcess> | null = null;
private backends = new Map<BackendId, AcpBackendProcess>();
private starting = new Map<BackendId, Promise<AcpBackendProcess>>();
private sessions = new Map<string, AgentSession>();
private chatUIStates = new Map<string, AgentChatUIState>();
private activeSessionId: string | null = null;
@ -44,6 +50,7 @@ export class AgentSessionManager {
private disposed = false;
private isStarting = false;
private lastError: string | null = null;
private preloader: AgentModelPreloader | null = null;
constructor(
private readonly app: App,
@ -79,12 +86,12 @@ export class AgentSessionManager {
}
/**
* Spawn a fresh `AgentSession`. Lazily starts the shared backend on the
* first call. The new session becomes the active one. Concurrent calls each
* spawn their own session; the shared backend boot is serialized
* internally via `ensureBackend()`.
* Spawn a fresh `AgentSession`. Lazily starts the requested backend on its
* first call. The new session becomes the active one. `backendId` defaults
* to `settings.agentMode.activeBackend` (the model-picker keeps that in
* sync with the user's most recently selected default model).
*/
async createSession(): Promise<AgentSession> {
async createSession(backendId?: BackendId): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
@ -95,14 +102,23 @@ export class AgentSessionManager {
}
const vaultBasePath = adapter.getBasePath();
const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
const descriptor = this.resolveDescriptor(resolvedId);
this.pendingCreates++;
this.isStarting = true;
this.notify();
try {
const backend = await this.ensureBackend();
const preferredModelId = this.opts.descriptor.getPreferredModelId?.(getSettings());
const session = await AgentSession.create(backend, vaultBasePath, uuidv4(), preferredModelId);
const backend = await this.ensureBackend(resolvedId, descriptor);
const preferredModelId = descriptor.getPreferredModelId?.(getSettings());
const session = await AgentSession.create(
backend,
vaultBasePath,
uuidv4(),
resolvedId,
preferredModelId
);
// Shutdown may have raced with us. If so, dispose the freshly-created
// session instead of leaking it.
if (this.disposed) {
@ -117,7 +133,7 @@ export class AgentSessionManager {
// first's error before the user (or a retry handler) has seen it.
this.lastError = null;
logInfo(
`[AgentMode] session created (internal=${session.internalId} acp=${session.acpSessionId}); pool size=${this.sessions.size}`
`[AgentMode] session created (internal=${session.internalId} acp=${session.acpSessionId} backend=${resolvedId}); pool size=${this.sessions.size}`
);
return session;
} catch (err) {
@ -130,6 +146,41 @@ export class AgentSessionManager {
}
}
private resolveDescriptor(backendId: BackendId): BackendDescriptor {
const descriptor = this.opts.resolveDescriptor(backendId);
if (!descriptor) {
throw new Error(`Unknown backend "${backendId}". Did you forget to register it?`);
}
return descriptor;
}
setDefaultBackend(backendId: BackendId): void {
if (getSettings().agentMode?.activeBackend === backendId) return;
setSettings((cur) => ({
agentMode: { ...cur.agentMode, activeBackend: backendId },
}));
this.notify();
}
/** Persist a sticky model preference for `backendId` without touching any session. */
async persistModelSelectionFor(backendId: BackendId, modelId: string): Promise<void> {
const descriptor = this.resolveDescriptor(backendId);
if (!descriptor.persistModelSelection) return;
await descriptor.persistModelSelection(modelId, this.plugin);
}
getBackendProcess(backendId: BackendId): AcpBackendProcess | null {
return this.backends.get(backendId) ?? null;
}
attachModelPreloader(preloader: AgentModelPreloader): void {
this.preloader = preloader;
}
getModelPreloader(): AgentModelPreloader | null {
return this.preloader;
}
/**
* Cancel any in-flight turn, dispose the session, and remove it from the
* pool. If the closed session was active, picks the right neighbor (or the
@ -241,31 +292,37 @@ export class AgentSessionManager {
* in `availableModels`). Mapping from a Copilot `CustomModel` to that id is
* the descriptor's responsibility.
*
* The persistence step is decoupled from the live switch if persistence
* throws, the runtime change still sticks for the current session.
* Persistence is routed through the *active session's* backend descriptor
* with multi-backend, the global `activeBackend` may not match the session
* the user is interacting with. If persistence throws, the runtime change
* still sticks for the current session.
*/
async setActiveSessionModel(modelId: string): Promise<void> {
const session = this.getActiveSession();
if (!session) throw new Error("No active session");
await session.setModel(modelId);
try {
await this.opts.descriptor.persistModelSelection?.(modelId, this.plugin);
const descriptor = this.resolveDescriptor(session.backendId);
if (descriptor.getPreferredModelId?.(getSettings()) === modelId) return;
await descriptor.persistModelSelection?.(modelId, this.plugin);
} catch (e) {
logWarn("[AgentMode] persistModelSelection failed", e);
}
}
/**
* Tear down every session and the shared backend subprocess. Safe to call
* when nothing was started; safe to call multiple times.
* Tear down every session and every spawned backend subprocess. Safe to
* call when nothing was started; safe to call multiple times.
*/
async shutdown(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
logInfo(`[AgentMode] shutdown (pool size=${this.sessions.size})`);
logInfo(
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
);
const all = Array.from(this.sessions.values());
for (const session of all) {
const allSessions = Array.from(this.sessions.values());
for (const session of allSessions) {
try {
await session.cancel();
} catch (e) {
@ -281,56 +338,71 @@ export class AgentSessionManager {
this.chatUIStates.clear();
this.activeSessionId = null;
try {
await this.backend?.shutdown();
} catch (e) {
logError("[AgentMode] backend shutdown failed", e);
const allBackends = Array.from(this.backends.values());
for (const proc of allBackends) {
try {
await proc.shutdown();
} catch (e) {
logError("[AgentMode] backend shutdown failed", e);
}
}
this.backend = null;
this.starting = null;
this.backends.clear();
this.starting.clear();
this.listeners.clear();
this.preloader?.shutdown();
this.preloader = null;
}
private async ensureBackend(): Promise<AcpBackendProcess> {
if (this.backend && this.backend.isRunning()) return this.backend;
if (this.starting) return this.starting;
private async ensureBackend(
backendId: BackendId,
descriptor: BackendDescriptor
): Promise<AcpBackendProcess> {
const existing = this.backends.get(backendId);
if (existing && existing.isRunning()) return existing;
const inflight = this.starting.get(backendId);
if (inflight) return inflight;
const proc = new AcpBackendProcess(
this.app,
this.opts.descriptor.createBackend(this.plugin),
descriptor.createBackend(this.plugin),
this.plugin.manifest.version
);
this.starting = (async () => {
const startPromise = (async () => {
await proc.start();
proc.setPermissionPrompter(this.opts.permissionPrompter);
proc.onExit(() => {
// Backend died unexpectedly. All sessions on this backend are now
// unusable (their acp session ids are dead). Dispose them and clear
// the pool — preserving message history across crashes is M5.
if (this.backend === proc) this.backend = null;
const dead = Array.from(this.sessions.values());
// Backend died unexpectedly. Sessions belonging to *this* backend
// are now unusable (their acp session ids are dead) — but other
// backends keep running. Preserving message history across crashes
// is M5.
if (this.backends.get(backendId) === proc) this.backends.delete(backendId);
const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
if (dead.length === 0) return;
this.sessions.clear();
this.chatUIStates.clear();
this.activeSessionId = null;
for (const s of dead) {
this.sessions.delete(s.internalId);
this.chatUIStates.delete(s.internalId);
s.cancel().catch(() => {});
s.dispose().catch(() => {});
}
if (this.activeSessionId && !this.sessions.has(this.activeSessionId)) {
const remaining = Array.from(this.sessions.keys());
this.activeSessionId = remaining[0] ?? null;
}
// Surface the crash so the empty-state pill shows it and the
// router's auto-spawn effect (which bails on lastError) doesn't
// immediately respawn behind the user's back. The next explicit
// create call clears it.
this.lastError = "Agent Mode backend exited unexpectedly.";
for (const s of dead) {
s.cancel().catch(() => {});
s.dispose().catch(() => {});
}
this.lastError = `${descriptor.displayName} backend exited unexpectedly.`;
this.notify();
});
this.backend = proc;
this.backends.set(backendId, proc);
return proc;
})();
this.starting.set(backendId, startPromise);
try {
return await this.starting;
return await startPromise;
} finally {
this.starting = null;
this.starting.delete(backendId);
}
}
}

View file

@ -87,6 +87,21 @@ export interface BackendDescriptor {
* doesn't drag in OpenCode's full OpenRouter catalog).
*/
agentModelIdToCopilotProvider?(modelId: string): string | undefined;
/**
* Optional: previously-stored ACP sessionId of the backend's dedicated
* "probe session", used by `AgentModelPreloader` to enumerate live models
* across plugin reloads without accumulating one fresh agent-side session
* record per startup. Returns `undefined` when no probe has run yet.
*/
getProbeSessionId?(settings: CopilotSettings): string | undefined;
/**
* Optional: persist the probe sessionId returned by a successful
* `session/new` probe so the next plugin load can reuse it via
* `session/resume` or `session/load`. Only called by `AgentModelPreloader`.
*/
persistProbeSessionId?(sessionId: string, plugin: CopilotPlugin): Promise<void>;
}
/**

View file

@ -1,7 +1,7 @@
import { AgentChatControls } from "@/agentMode/ui/AgentChatControls";
import AgentChatMessages from "@/agentMode/ui/AgentChatMessages";
import { AgentModeStatus } from "@/agentMode/ui/AgentModeStatus";
import { useActiveBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
import { useAgentModelPicker } from "@/agentMode/ui/useAgentModelPicker";
import ChatInput from "@/components/chat-components/ChatInput";
import { EVENT_NAMES } from "@/constants";
@ -161,7 +161,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
[backend]
);
const descriptor = useActiveBackendDescriptor();
const descriptor = useSessionBackendDescriptor(manager);
const handleInstall = useCallback(() => {
descriptor.openInstallUI(plugin);
}, [descriptor, plugin]);

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import {
useActiveBackendDescriptor,
useBackendInstallState,
useSessionBackendDescriptor,
} from "@/agentMode/ui/useBackendDescriptor";
import { AgentSessionStatus } from "@/agentMode/session/AgentSession";
import { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
@ -22,10 +22,12 @@ interface Props {
* on mount. Surfaces install gaps, boot state, and per-turn status.
*
* Backend-agnostic: all backend-specific copy (display name, version) comes
* from the active `BackendDescriptor`.
* from the *active session's* `BackendDescriptor` — that's the backend
* actually running the conversation, which can differ from the user's
* default backend after a cross-backend tab switch.
*/
export const AgentModeStatus: React.FC<Props> = ({ manager, onInstallClick }) => {
const descriptor = useActiveBackendDescriptor();
const descriptor = useSessionBackendDescriptor(manager);
const installState = useBackendInstallState(descriptor);
// Tick on any manager notify (active session set/cleared, isStarting flip,

View file

@ -2,5 +2,9 @@ export { AgentModeChat } from "./AgentModeChat";
export { default as AgentChat } from "./AgentChat";
export { AgentChatControls } from "./AgentChatControls";
export { AgentModeStatus } from "./AgentModeStatus";
export { useActiveBackendDescriptor, useBackendInstallState } from "./useBackendDescriptor";
export {
useActiveBackendDescriptor,
useBackendInstallState,
useSessionBackendDescriptor,
} from "./useBackendDescriptor";
export { createDefaultPermissionPrompter } from "./permissionPrompter";

View file

@ -4,10 +4,11 @@ import type { CustomModel } from "@/aiParams";
import { logError } from "@/logger";
import type { ModelSelectorEntry } from "@/components/ui/ModelSelector";
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { useActiveBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
import { backendRegistry, listBackendDescriptors } from "@/agentMode/backends/registry";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
import { MethodUnsupportedError } from "@/agentMode/acp/types";
import { MethodUnsupportedError, type BackendId } from "@/agentMode/acp/types";
import type { BackendDescriptor } from "@/agentMode/session/types";
export interface AgentModelPickerOverride {
models: ModelSelectorEntry[];
@ -16,118 +17,141 @@ export interface AgentModelPickerOverride {
disabled?: boolean;
}
/** A pseudo-provider value used for agent-only synthesized entries. */
const AGENT_PROVIDER = "agent";
/**
* Build the `modelPickerOverride` shape Agent Mode hands to `ChatInput` so the
* shared `ModelSelector` lights up with the agent-aware list.
*
* The list combines:
* 1. Copilot-configured models (`partition.compatible`) always shown,
* enabled if the agent reported them, otherwise disabled with a
* "Restart agent to load" hint. Incompatible ones are omitted.
* 2. Agent-reported models the user *hasn't* curated shown as
* synthesized entries so OpenCode's built-in defaults stay visible.
* "Curated" means the user has at least one Copilot model with that
* provider; once a provider is curated, the agent's full catalog for
* the same provider is hidden (prevents e.g. OpenRouter's 200-model
* catalog from drowning out a single configured OpenRouter model).
*
* Returns `null` when the agent doesn't report a model state (older
* backends), so callers can omit the override and fall back to the default
* `ChatInput` picker behavior.
* Build the `modelPickerOverride` for `ChatInput` one grouped section per
* registered backend. Backends that integrate with Copilot contribute
* curated entries; others contribute their live `availableModels` (active
* session or preloader cache). Once the active session has any user-visible
* messages, non-active backend sections are hidden so picks can't muddle
* mid-conversation history; cross-backend picks on an empty tab swap the
* tab for a fresh session on the target backend.
*/
export function useAgentModelPicker(
backend: AgentChatBackend | null,
manager: AgentSessionManager | null
): AgentModelPickerOverride | null {
const descriptor = useActiveBackendDescriptor();
const settings = useSettingsValue();
const descriptors = useMemo(() => listBackendDescriptors(), []);
// Subscribe to backend updates so model-change notifications cause re-render.
// Re-render on active-session model-state changes, manager lifecycle
// changes, and preloader cache updates — all three drive picker contents.
const [, forceRender] = useState(0);
const preloader = manager?.getModelPreloader() ?? null;
useEffect(() => {
if (!backend) return;
return backend.subscribe(() => forceRender((n) => n + 1));
}, [backend]);
const tick = () => forceRender((n) => n + 1);
const unsubs = [backend?.subscribe(tick), manager?.subscribe(tick), preloader?.subscribe(tick)];
return () => unsubs.forEach((u) => u?.());
}, [backend, manager, preloader]);
const modelState = backend?.getModelState() ?? null;
const activeModelState = backend?.getModelState() ?? null;
const isModelSwitchSupported = backend?.isModelSwitchSupported() ?? null;
const activeSession = manager?.getActiveSession() ?? null;
const activeBackendId = activeSession?.backendId ?? null;
const activeSessionHasHistory = activeSession?.hasUserVisibleMessages() ?? false;
const override = useMemo<AgentModelPickerOverride | null>(() => {
if (!backend || !modelState) return null;
const filterFn = descriptor.filterCopilotModels;
const translateFn = descriptor.copilotModelKeyToAgentModelId;
const reverseProviderFn = descriptor.agentModelIdToCopilotProvider;
const partition = filterFn
? filterFn(settings.activeModels ?? [])
: { compatible: settings.activeModels ?? [], incompatible: [] };
const agentAvailableIds = new Set(modelState.availableModels.map((m) => m.modelId));
// Track which Copilot providers the user has explicitly curated and the
// agent ids those Copilot entries translate to (used to suppress duplicate
// synthesized entries).
const curatedProviders = new Set<string>();
const curatedAgentIds = new Set<string>();
const copilotByAgentId = new Map<string, CustomModel>();
for (const m of partition.compatible) {
curatedProviders.add(m.provider);
const agentId = translateFn?.(m);
if (agentId) {
curatedAgentIds.add(agentId);
copilotByAgentId.set(agentId, m);
}
}
return useMemo<AgentModelPickerOverride | null>(() => {
if (!manager) return null;
const entries: ModelSelectorEntry[] = [];
for (const m of partition.compatible) {
const agentId = translateFn?.(m);
const isAvailable = agentId !== undefined && agentAvailableIds.has(agentId);
entries.push(
isAvailable ? { ...m, enabled: true } : { ...m, _disabledReason: "Restart agent to load" }
);
for (const descriptor of descriptors) {
const isActiveBackend = descriptor.id === activeBackendId;
if (!isActiveBackend && activeSessionHasHistory) continue;
const liveAvailable = isActiveBackend
? (activeModelState?.availableModels ?? null)
: (preloader?.getCachedModels(descriptor.id)?.availableModels ?? null);
const isRunning = manager.getBackendProcess(descriptor.id)?.isRunning() ?? false;
appendBackendSection(entries, descriptor, settings.activeModels ?? [], {
liveAvailable,
isRunning,
isActiveBackend,
});
}
// Surface agent-reported models for providers the user hasn't curated in
// Copilot. Skips models whose provider has at least one Copilot entry —
// that's our signal the user is hand-picking models for that provider.
for (const m of modelState.availableModels) {
if (curatedAgentIds.has(m.modelId)) continue;
const copilotProvider = reverseProviderFn?.(m.modelId);
if (copilotProvider && curatedProviders.has(copilotProvider)) continue;
entries.push(synthesizeAgentEntry(m.modelId, m.name));
}
const valueKey = computeValueKey(modelState.currentModelId, copilotByAgentId);
// Edge case: the agent's `currentModelId` lands inside a curated provider
// but isn't one of the user's configured entries (stale
// `selectedModelKey`, or the agent fell back to a catalog default we
// filtered out). Surface it as a synthesized entry so the dropdown value
// isn't dangling.
if (modelState.currentModelId && !entries.some((e) => getModelKeyFromModel(e) === valueKey)) {
const currentInfo = modelState.availableModels.find(
(m) => m.modelId === modelState.currentModelId
// Surface the session's current model if a curated/synthesized entry for
// it isn't already present (stale selectedModelKey, or filtered out).
const activeDescriptor = activeBackendId ? backendRegistry[activeBackendId] : undefined;
const valueKey =
activeBackendId && activeDescriptor && activeModelState
? computeValueKeyForActive(
activeModelState.currentModelId,
activeDescriptor,
settings.activeModels ?? []
)
: "";
if (activeBackendId && activeDescriptor && activeModelState?.currentModelId) {
const currentInfo = activeModelState.availableModels.find(
(m) => m.modelId === activeModelState.currentModelId
);
if (currentInfo) {
entries.unshift(synthesizeAgentEntry(currentInfo.modelId, currentInfo.name));
if (currentInfo && !entries.some((e) => getModelKeyFromModel(e) === valueKey)) {
entries.unshift(
synthesizeAgentEntry(currentInfo.modelId, currentInfo.name, activeDescriptor)
);
}
}
return {
models: entries,
value: valueKey,
disabled: isModelSwitchSupported === false,
// Cross-backend picks are valid even when the active session can't
// switch models at runtime; intra-backend support is checked per-call.
disabled: false,
onChange: (modelKey: string) => {
// Resolve the chosen entry to an agent-native id. If it's a
// Copilot-side key, ask the descriptor to translate; otherwise it's
// already a synthesized "agent|<id>" key — strip the prefix.
const agentId = resolveAgentIdFromKey(modelKey, settings.activeModels ?? [], translateFn);
const entry = entries.find((e) => getModelKeyFromModel(e) === modelKey);
if (!entry) return;
const targetBackendId = entry._backendId;
if (!targetBackendId) {
logError("[AgentMode] picker entry missing _backendId", entry);
return;
}
const targetDescriptor = backendRegistry[targetBackendId];
if (!targetDescriptor) {
logError("[AgentMode] picker entry references unknown backend", targetBackendId);
return;
}
const agentId = resolveAgentId(entry, targetDescriptor, settings.activeModels ?? []);
if (!agentId) {
new Notice("Could not resolve a model id for this selection.");
return;
}
if (!manager) return;
if (!activeSession || activeSession.backendId !== targetBackendId) {
// Cross-backend pick: persist model first so the new session's
// getPreferredModelId sees it, then spawn, then close the old empty
// tab. Default-backend flip waits until the new session resolves so
// a failed start doesn't leave the user pointing at a broken backend.
const emptyId = activeSession?.internalId;
void (async () => {
try {
await manager.persistModelSelectionFor(targetBackendId, agentId);
await manager.createSession(targetBackendId);
manager.setDefaultBackend(targetBackendId);
if (emptyId) {
await manager
.closeSession(emptyId)
.catch((e) => logError("[AgentMode] closeSession of empty tab failed", e));
}
} catch (err) {
logError("[AgentMode] cross-backend pick failed", err);
new Notice(
`Failed to start ${targetDescriptor.displayName}. See console for details.`
);
}
})();
return;
}
// Same backend as the active session — flip the default eagerly,
// then route the model switch through the running session.
manager.setDefaultBackend(targetBackendId);
if (isModelSwitchSupported === false) {
new Notice("This agent doesn't support runtime model switching.");
return;
}
manager.setActiveSessionModel(agentId).catch((err) => {
if (err instanceof MethodUnsupportedError) {
new Notice("This agent doesn't support runtime model switching.");
@ -138,44 +162,124 @@ export function useAgentModelPicker(
});
},
};
}, [backend, descriptor, settings.activeModels, modelState, isModelSwitchSupported, manager]);
return override;
}, [
manager,
descriptors,
settings.activeModels,
activeBackendId,
activeModelState,
isModelSwitchSupported,
activeSession,
activeSessionHasHistory,
preloader,
]);
}
/** A pseudo-provider value used for agent-only synthesized entries. */
const AGENT_PROVIDER = "agent";
function appendBackendSection(
entries: ModelSelectorEntry[],
descriptor: BackendDescriptor,
activeModels: CustomModel[],
ctx: {
liveAvailable: ReadonlyArray<{ modelId: string; name: string }> | null;
isRunning: boolean;
isActiveBackend: boolean;
}
): void {
const filterFn = descriptor.filterCopilotModels;
const translateFn = descriptor.copilotModelKeyToAgentModelId;
const reverseProviderFn = descriptor.agentModelIdToCopilotProvider;
const partition = filterFn
? filterFn(activeModels)
: { compatible: [] as CustomModel[], incompatible: [] as CustomModel[] };
function synthesizeAgentEntry(modelId: string, humanName: string): ModelSelectorEntry {
const liveIds = new Set((ctx.liveAvailable ?? []).map((m) => m.modelId));
const curatedProviders = new Set<string>();
const curatedAgentIds = new Set<string>();
for (const m of partition.compatible) {
curatedProviders.add(m.provider);
const agentId = translateFn?.(m);
if (agentId) curatedAgentIds.add(agentId);
}
for (const m of partition.compatible) {
const agentId = translateFn?.(m);
const isAvailable = ctx.isRunning && agentId !== undefined && liveIds.has(agentId);
const disabledReason = ctx.isActiveBackend
? ctx.isRunning
? isAvailable
? undefined
: "Restart agent to load"
: `Start ${descriptor.displayName} session to use`
: undefined;
entries.push({
...m,
enabled: true,
_group: descriptor.displayName,
_backendId: descriptor.id,
_disabledReason: disabledReason,
});
}
// Live entries — surface uncurated models. Skip providers the user has
// already curated; those should not be drowned out by the agent catalog.
if (!ctx.liveAvailable) return;
for (const m of ctx.liveAvailable) {
if (curatedAgentIds.has(m.modelId)) continue;
const copilotProvider = reverseProviderFn?.(m.modelId);
if (copilotProvider && curatedProviders.has(copilotProvider)) continue;
entries.push(synthesizeAgentEntry(m.modelId, m.name, descriptor));
}
}
// Backend-scoped so two backends reporting the same `sonnet` don't collide
// on React key / dropdown value `<name>|<provider>`. Raw id is preserved in
// `_agentModelId` for outbound resolution.
function scopedSynthName(backendId: BackendId, modelId: string): string {
return `${backendId}:${modelId}`;
}
function synthesizeAgentEntry(
modelId: string,
humanName: string,
descriptor: BackendDescriptor
): ModelSelectorEntry {
return {
name: modelId,
name: scopedSynthName(descriptor.id, modelId),
provider: AGENT_PROVIDER,
enabled: true,
isBuiltIn: false,
displayName: humanName || modelId,
_group: descriptor.displayName,
_backendId: descriptor.id,
_agentModelId: modelId,
};
}
function computeValueKey(
function computeValueKeyForActive(
currentModelId: string,
copilotByAgentId: Map<string, CustomModel>
descriptor: BackendDescriptor,
activeModels: CustomModel[]
): string {
const copilot = copilotByAgentId.get(currentModelId);
if (copilot) return getModelKeyFromModel(copilot);
return `${currentModelId}|${AGENT_PROVIDER}`;
const translateFn = descriptor.copilotModelKeyToAgentModelId;
if (translateFn) {
const filterFn = descriptor.filterCopilotModels;
const partition = filterFn
? filterFn(activeModels)
: { compatible: activeModels, incompatible: [] };
const match = partition.compatible.find((m) => translateFn(m) === currentModelId);
if (match) return getModelKeyFromModel(match);
}
return `${scopedSynthName(descriptor.id, currentModelId)}|${AGENT_PROVIDER}`;
}
function resolveAgentIdFromKey(
modelKey: string,
activeModels: CustomModel[],
translateFn: ((model: CustomModel) => string | undefined) | undefined
function resolveAgentId(
entry: ModelSelectorEntry,
descriptor: BackendDescriptor,
activeModels: CustomModel[]
): string | undefined {
const sep = modelKey.lastIndexOf("|");
if (sep < 0) return undefined;
const provider = modelKey.slice(sep + 1);
const name = modelKey.slice(0, sep);
if (provider === AGENT_PROVIDER) return name;
const match = activeModels.find((m) => m.name === name && m.provider === provider);
if (entry._agentModelId) return entry._agentModelId;
if (!descriptor.copilotModelKeyToAgentModelId) return undefined;
const match = activeModels.find((m) => m.name === entry.name && m.provider === entry.provider);
if (!match) return undefined;
return translateFn ? translateFn(match) : undefined;
return descriptor.copilotModelKeyToAgentModelId(match);
}

View file

@ -1,12 +1,41 @@
import { getActiveBackendDescriptor } from "@/agentMode/backends/registry";
import { backendRegistry, getActiveBackendDescriptor } from "@/agentMode/backends/registry";
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
import type { BackendDescriptor, InstallState } from "@/agentMode/session/types";
import { useSettingsValue } from "@/settings/model";
import React from "react";
/** Resolve the active backend descriptor from settings. */
/** Resolve the active (default) backend descriptor from settings. */
export function useActiveBackendDescriptor(): BackendDescriptor {
return getActiveBackendDescriptor(useSettingsValue());
}
/**
* Resolve the descriptor for the currently active *session*'s backend.
* Falls back to the default backend descriptor when there is no active
* session (e.g. the no-session fallback view, or before auto-spawn lands).
*
* Status pills, install CTAs, and other session-scoped UI should prefer
* this over `useActiveBackendDescriptor` so the displayed display name /
* version / install handler matches the running session which can be on
* a non-default backend after a cross-backend model pick + new tab.
*/
export function useSessionBackendDescriptor(
manager: AgentSessionManager | null | undefined
): BackendDescriptor {
const settings = useSettingsValue();
const [, forceRender] = React.useState(0);
React.useEffect(() => {
if (!manager) return;
return manager.subscribe(() => forceRender((n) => n + 1));
}, [manager]);
const sessionBackendId = manager?.getActiveSession()?.backendId;
if (sessionBackendId) {
const desc = backendRegistry[sessionBackendId];
if (desc) return desc;
}
return getActiveBackendDescriptor(settings);
}
/** Compute the descriptor's current install state. Recomputes each render. */
export function useBackendInstallState(descriptor: BackendDescriptor): InstallState {
return descriptor.getInstallState(useSettingsValue());

View file

@ -4,6 +4,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModelDisplay } from "@/components/ui/model-display";
@ -20,8 +21,27 @@ import { cn } from "@/lib/utils";
* non-API-key reason for a disabled option, set `_disabledReason` on the
* synthetic entry; the selector will render the option disabled with the
* reason as a right-side label.
*
* `_group` opts entries into section headers in the dropdown when
* consecutive entries have differing `_group` values, a non-clickable label
* is rendered before the next group. Used by Agent Mode to subtitle
* per-backend sections (e.g. `opencode`, `Claude Code`). Backwards
* compatible entries without `_group` render flat as today.
*
* Agent Mode tags every entry with `_backendId` so the selector can route
* the selected key back to the right backend, and so the React key /
* dropdown value can stay unique even when two backends report the same
* agent-native model id (e.g. both surface a `sonnet` alias). Synthesized
* agent entries (no Copilot `CustomModel` backing) also carry the raw
* agent model id in `_agentModelId` so callers can resolve the original id
* after we scope `name` for uniqueness.
*/
export type ModelSelectorEntry = CustomModel & { _disabledReason?: string };
export type ModelSelectorEntry = CustomModel & {
_disabledReason?: string;
_group?: string;
_backendId?: string;
_agentModelId?: string;
};
interface ModelSelectorProps {
disabled?: boolean;
@ -58,6 +78,9 @@ export function ModelSelector({
(model) => (model.enabled ?? true) && getModelKeyFromModel(model) === value
);
const visible = showModels.filter((model) => model.enabled !== false);
let lastGroup: string | undefined;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -81,16 +104,25 @@ export function ModelSelector({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="tw-max-h-64 tw-overflow-y-auto">
{showModels
.filter((model) => model.enabled !== false)
.map((model) => {
const disabledReason = model._disabledReason;
const hasApiKey = skipApiKeyCheck ? true : checkModelApiKey(model, settings).hasApiKey;
const itemDisabled = Boolean(disabledReason) || !hasApiKey;
const rightLabel = disabledReason ?? (!hasApiKey ? "Needs API key" : null);
return (
{visible.map((model) => {
const disabledReason = model._disabledReason;
const hasApiKey = skipApiKeyCheck ? true : checkModelApiKey(model, settings).hasApiKey;
const itemDisabled = Boolean(disabledReason) || !hasApiKey;
const rightLabel = disabledReason ?? (!hasApiKey ? "Needs API key" : null);
const showHeader = model._group !== undefined && model._group !== lastGroup;
const headerKey = `__group__${model._group}__${getModelKeyFromModel(model)}`;
lastGroup = model._group;
return (
<React.Fragment key={getModelKeyFromModel(model)}>
{showHeader && (
<DropdownMenuLabel
key={headerKey}
className="tw-text-xs tw-uppercase tw-tracking-wide tw-text-faint"
>
{model._group}
</DropdownMenuLabel>
)}
<DropdownMenuItem
key={getModelKeyFromModel(model)}
disabled={itemDisabled}
title={disabledReason ?? undefined}
onSelect={async (event) => {
@ -121,8 +153,9 @@ export function ModelSelector({
<span className="tw-ml-auto tw-text-smallest tw-text-faint">{rightLabel}</span>
)}
</DropdownMenuItem>
);
})}
</React.Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -209,10 +209,31 @@ export interface CopilotSettings {
/** Per-backend config slice, keyed by BackendId. Each backend owns its slice. */
backends: {
opencode?: OpencodeBackendSettings;
"claude-code"?: ClaudeCodeBackendSettings;
};
};
}
/** Settings slice owned by the Claude Code backend. */
export interface ClaudeCodeBackendSettings {
/** Path to the user-provided `claude-agent-acp` binary. */
binaryPath?: string;
/**
* Sticky model preference. Stored as the raw agent model id reported by
* `claude-agent-acp` (e.g. `claude-sonnet-4-5-20250929`) Claude Code does
* not pull from Copilot's `activeModels`, so no Copilot-key translation.
*/
selectedModelKey?: string;
/**
* ACP sessionId of the dedicated "probe session" used by AgentModelPreloader
* to enumerate live models without disturbing user chats. Persisted across
* plugin reloads so subsequent loads can `session/resume` (or `session/load`)
* the same record instead of accumulating one new session per startup. Never
* surfaced in the Copilot tab strip or chat history.
*/
probeSessionId?: string;
}
/** Settings slice owned by the OpenCode backend. */
export interface OpencodeBackendSettings {
binaryVersion?: string;
@ -232,6 +253,14 @@ export interface OpencodeBackendSettings {
* Empty/unset = no preference, OpenCode picks its own default.
*/
selectedModelKey?: string;
/**
* ACP sessionId of the dedicated "probe session" used by AgentModelPreloader
* to enumerate live models without disturbing user chats. Persisted across
* plugin reloads so subsequent loads can `session/resume` (or `session/load`)
* the same record instead of accumulating one new session per startup. Never
* surfaced in the Copilot tab strip or chat history.
*/
probeSessionId?: string;
}
export const settingsStore = createStore();
@ -256,12 +285,21 @@ function resolveEmbeddingModelKey(settings: CopilotSettings): string {
}
/**
* Sets the settings in the atom.
* Sets the settings in the atom. Accepts either a partial object or an
* updater function `(prev) => partial`. Prefer the updater form for any
* read-modify-write it routes through jotai's atom-setter callback so the
* read and write are atomic at the store level (no stale-snapshot races
* between concurrent writers, even across `await` boundaries in the caller).
*/
export function setSettings(settings: Partial<CopilotSettings>) {
const newSettings = mergeAllActiveModelsWithCoreModels({ ...getSettings(), ...settings });
newSettings.embeddingModelKey = resolveEmbeddingModelKey(newSettings);
settingsStore.set(settingsAtom, newSettings);
export function setSettings(
settings: Partial<CopilotSettings> | ((current: CopilotSettings) => Partial<CopilotSettings>)
) {
settingsStore.set(settingsAtom, (prev) => {
const partial = typeof settings === "function" ? settings(prev) : settings;
const merged = mergeAllActiveModelsWithCoreModels({ ...prev, ...partial });
merged.embeddingModelKey = resolveEmbeddingModelKey(merged);
return merged;
});
}
/**
@ -304,8 +342,7 @@ export function sanitizeQaExclusions(rawValue: unknown): string {
* Sets a single setting in the atom.
*/
export function updateSetting<K extends keyof CopilotSettings>(key: K, value: CopilotSettings[K]) {
const settings = getSettings();
setSettings({ ...settings, [key]: value });
setSettings((cur) => ({ ...cur, [key]: value }));
}
/**
@ -665,10 +702,10 @@ function sanitizeAgentMode(raw: unknown): CopilotSettings["agentMode"] {
? r.activeBackend
: DEFAULT_SETTINGS.agentMode.activeBackend;
const existingOpencode =
r.backends && typeof r.backends === "object"
? ((r.backends as Record<string, unknown>).opencode as Record<string, unknown> | undefined)
: undefined;
const backendsRaw =
r.backends && typeof r.backends === "object" ? (r.backends as Record<string, unknown>) : {};
const existingOpencode = backendsRaw.opencode as Record<string, unknown> | undefined;
const existingClaudeCode = backendsRaw["claude-code"] as Record<string, unknown> | undefined;
// Lift legacy top-level fields when `backends.opencode` doesn't already hold them.
const legacyOpencode =
@ -685,16 +722,34 @@ function sanitizeAgentMode(raw: unknown): CopilotSettings["agentMode"] {
: legacyOpencode
? sanitizeOpencodeBackendSettings(legacyOpencode)
: undefined;
const claudeCodeSlice = existingClaudeCode
? sanitizeClaudeCodeBackendSettings(existingClaudeCode)
: undefined;
const backends: CopilotSettings["agentMode"]["backends"] = {};
if (opencodeSlice) backends.opencode = opencodeSlice;
if (claudeCodeSlice) backends["claude-code"] = claudeCodeSlice;
return {
enabled,
byok,
mcpServers,
activeBackend,
backends: opencodeSlice ? { opencode: opencodeSlice } : {},
backends,
};
}
function sanitizeClaudeCodeBackendSettings(raw: unknown): ClaudeCodeBackendSettings {
if (!raw || typeof raw !== "object") return {};
const r = raw as Record<string, unknown>;
const binaryPath = typeof r.binaryPath === "string" && r.binaryPath ? r.binaryPath : undefined;
const selectedModelKey =
typeof r.selectedModelKey === "string" && r.selectedModelKey ? r.selectedModelKey : undefined;
const probeSessionId =
typeof r.probeSessionId === "string" && r.probeSessionId ? r.probeSessionId : undefined;
return { binaryPath, selectedModelKey, probeSessionId };
}
function sanitizeOpencodeBackendSettings(raw: unknown): OpencodeBackendSettings {
if (!raw || typeof raw !== "object") return {};
const r = raw as Record<string, unknown>;
@ -709,7 +764,9 @@ function sanitizeOpencodeBackendSettings(raw: unknown): OpencodeBackendSettings
}
const selectedModelKey =
typeof r.selectedModelKey === "string" && r.selectedModelKey ? r.selectedModelKey : undefined;
return { binaryPath, binaryVersion, binarySource, selectedModelKey };
const probeSessionId =
typeof r.probeSessionId === "string" && r.probeSessionId ? r.probeSessionId : undefined;
return { binaryPath, binaryVersion, binarySource, selectedModelKey, probeSessionId };
}
function mergeAllActiveModelsWithCoreModels(settings: CopilotSettings): CopilotSettings {

View file

@ -1,19 +1,25 @@
import { useActiveBackendDescriptor } from "@/agentMode";
import { listBackendDescriptors } from "@/agentMode";
import { SettingItem } from "@/components/ui/setting-item";
import { usePlugin } from "@/contexts/PluginContext";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { Platform } from "obsidian";
import React from "react";
/**
* Settings tab section for Agent Mode. Stacks every registered backend's
* `SettingsPanel` so users can configure all of them at once independent
* of which one is currently the default. The "default backend" picker
* tracks `settings.agentMode.activeBackend`, which determines which backend
* the `+` button and auto-spawn-on-mount land on (the model picker also
* keeps this field in sync as the user picks models).
*/
export const AgentModeSettings: React.FC = () => {
const settings = useSettingsValue();
const plugin = usePlugin();
const descriptor = useActiveBackendDescriptor();
const descriptors = React.useMemo(() => listBackendDescriptors(), []);
if (Platform.isMobile) return null;
const Panel = descriptor.SettingsPanel;
return (
<section>
<div className="tw-mb-3 tw-text-xl tw-font-bold">Agent Mode (alpha)</div>
@ -21,14 +27,37 @@ export const AgentModeSettings: React.FC = () => {
<SettingItem
type="switch"
title="Enable Agent Mode"
description={`BYOK agent harness backed by a local ${descriptor.displayName} subprocess. Desktop only.`}
description="BYOK agent harness backed by a local ACP subprocess. Desktop only."
checked={settings.agentMode.enabled}
onCheckedChange={(checked) =>
updateSetting("agentMode", { ...settings.agentMode, enabled: checked })
}
/>
{settings.agentMode.enabled && Panel && <Panel plugin={plugin} app={plugin.app} />}
{settings.agentMode.enabled && (
<SettingItem
type="select"
title="Default backend"
description="Used when you click `+` to start a new session and for auto-spawn on mount. Selecting a model from the model picker also updates this."
value={settings.agentMode.activeBackend}
onChange={(value) =>
updateSetting("agentMode", { ...settings.agentMode, activeBackend: value })
}
options={descriptors.map((d) => ({ label: d.displayName, value: d.id }))}
/>
)}
{settings.agentMode.enabled &&
descriptors.map((descriptor) => {
const Panel = descriptor.SettingsPanel;
if (!Panel) return null;
return (
<div key={descriptor.id} className="tw-space-y-2">
<div className="tw-text-base tw-font-semibold">{descriptor.displayName}</div>
<Panel plugin={plugin} app={plugin.app} />
</div>
);
})}
</div>
</section>
);

63
src/utils/detectBinary.ts Normal file
View file

@ -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<string | null> {
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<string | null> {
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;
}