mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
4f9c4f813e
commit
fe25ed9e16
30 changed files with 1704 additions and 284 deletions
176
designdocs/AGENT_MODE_SYSTEM_PROMPT.md
Normal file
176
designdocs/AGENT_MODE_SYSTEM_PROMPT.md
Normal 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.
|
||||
28
designdocs/todo/AGENT_MODE_TODOS.md
Normal file
28
designdocs/todo/AGENT_MODE_TODOS.md
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
68
src/agentMode/backends/claude-code/ClaudeCodeBackend.ts
Normal file
68
src/agentMode/backends/claude-code/ClaudeCodeBackend.ts
Normal 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);
|
||||
}
|
||||
136
src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx
Normal file
136
src/agentMode/backends/claude-code/ClaudeCodeInstallModal.tsx
Normal 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'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();
|
||||
}
|
||||
}
|
||||
136
src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx
Normal file
136
src/agentMode/backends/claude-code/ClaudeCodeSettingsPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
89
src/agentMode/backends/claude-code/descriptor.ts
Normal file
89
src/agentMode/backends/claude-code/descriptor.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
1
src/agentMode/backends/claude-code/index.ts
Normal file
1
src/agentMode/backends/claude-code/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ClaudeCodeBackendDescriptor } from "./descriptor";
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
165
src/agentMode/session/AgentModelPreloader.ts
Normal file
165
src/agentMode/session/AgentModelPreloader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
63
src/utils/detectBinary.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue