feat(agent-mode): introduce Agent Mode feature

Squashed from 8 commits on zero/acp-test:
- feat(agent-mode): introduce Agent Mode feature (squashed)
- Fix eslint
- hide add context button
- chore: ban parent-relative imports and rewrite existing ones to @/ aliases
- wip(agent-mode): skills management + slash command revamp
- fix(agent-mode): initialize SkillManager before preload probes
- fix(build): align svgr jsxRuntime with tsconfig classic jsx
- fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock
This commit is contained in:
Zero Liu 2026-05-19 14:55:21 -07:00 committed by Logan Yang
parent bd8829f538
commit c2ac27edef
No known key found for this signature in database
269 changed files with 41803 additions and 1466 deletions

5
.gitignore vendored
View file

@ -18,6 +18,7 @@ styles.css
# obsidian
data.json
data/
# Exclude macOS Finder (System Explorer) View States
.DS_Store
@ -31,3 +32,7 @@ data.json
# Development session tracking
TODO.md
# ACP full-frame debug log (vault sidecar, may contain prompt/tool data)
copilot/acp-frames.ndjson
copilot/acp-frames.old.ndjson

View file

@ -36,12 +36,6 @@ The Obsidian desktop app includes a CLI for plugin development. Use the full pat
/Applications/Obsidian.app/Contents/MacOS/obsidian <command>
```
**Plugin reload** (after `npm run build`):
```bash
/Applications/Obsidian.app/Contents/MacOS/obsidian plugin:reload id=copilot
```
**Console debugging** (requires attaching debugger first):
```bash
@ -65,28 +59,24 @@ Run `obsidian help` for the full command list.
### Core Systems
1. **LLM Provider System** (`src/LLMProviders/`)
- Provider implementations for OpenAI, Anthropic, Google, Azure, local models
- `LLMProviderManager` handles provider lifecycle and switching
- Stream-based responses with error handling and rate limiting
- Custom model configuration support
2. **Chain Factory Pattern** (`src/chainFactory.ts`)
- Different chain types for various AI operations (chat, copilot, adhoc prompts)
- LangChain integration for complex workflows
- Memory management for conversation context
- Tool integration (search, file operations, time queries)
3. **Vector Store & Search** (`src/search/`)
- `VectorStoreManager` manages embeddings and semantic search
- `ChunkedStorage` for efficient large document handling
- Event-driven index updates via `IndexManager`
- Multiple embedding providers support
4. **UI Component System** (`src/components/`)
- React functional components with Radix UI primitives
- Tailwind CSS with class variance authority (CVA)
- Modal system for user interactions
@ -94,7 +84,6 @@ Run `obsidian help` for the full command list.
- Settings UI with versioned components
5. **Message Management Architecture** (`src/core/`, `src/state/`)
- **MessageRepository** (`src/core/MessageRepository.ts`): Single source of truth for all messages
- Stores each message once with both `displayText` and `processedText`
- Provides computed views for UI display and LLM processing
@ -116,7 +105,6 @@ Run `obsidian help` for the full command list.
- Reprocesses context when messages are edited
6. **Settings Management**
- Jotai for atomic settings state management
- React contexts for feature-specific state
@ -147,14 +135,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
### Core Classes and Flow
1. **MessageRepository** (`src/core/MessageRepository.ts`)
- Single source of truth for all messages
- Stores `StoredMessage` objects with both `displayText` and `processedText`
- Provides computed views via `getDisplayMessages()` and `getLLMMessages()`
- No complex dual-array synchronization or ID matching
2. **ChatManager** (`src/core/ChatManager.ts`)
- Central business logic coordinator
- Orchestrates MessageRepository, ContextManager, and LLM operations
- Handles all message CRUD operations with proper error handling
@ -166,14 +152,12 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- Creates new empty repository for each project (no message caching)
3. **ChatUIState** (`src/state/ChatUIState.ts`)
- Clean UI-only state manager
- Delegates all business logic to ChatManager
- Provides React integration with subscription mechanism
- Replaces legacy SharedState with minimal, focused approach
4. **ContextManager** (`src/core/ContextManager.ts`)
- Handles context processing (notes, URLs, selected text)
- Reprocesses context when messages are edited
- Ensures fresh context for LLM processing
@ -233,6 +217,8 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- After editing CSS, always run `npm run build` to regenerate `styles.css`
- **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site.
- **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes) — do not zero out component chrome with inline `border: 0`, `background: "transparent"`, `padding: 0`, etc.
## Testing Guidelines

View file

@ -1,3 +1,3 @@
# CLAUDE.md
@AGENTS.md
@AGENTS.md

View file

@ -0,0 +1,62 @@
// Lightweight mock for @agentclientprotocol/sdk so unit tests can import
// modules that reference its runtime values (RequestError, ClientSideConnection,
// PROTOCOL_VERSION, ndJsonStream) without pulling the real ESM package
// through ts-jest. Tests that need behavior beyond this mock should stub
// their own.
class RequestError extends Error {
constructor(code, message, data) {
super(message);
this.code = code;
this.data = data;
this.name = "RequestError";
}
static parseError(data, msg) {
return new RequestError(-32700, msg ?? "Parse error", data);
}
static invalidRequest(data, msg) {
return new RequestError(-32600, msg ?? "Invalid Request", data);
}
static methodNotFound(method) {
return new RequestError(-32601, `Method not found: ${method}`);
}
static invalidParams(data, msg) {
return new RequestError(-32602, msg ?? "Invalid params", data);
}
static internalError(data, msg) {
return new RequestError(-32603, msg ?? "Internal error", data);
}
static authRequired(data, msg) {
return new RequestError(-32000, msg ?? "Authentication required", data);
}
static resourceNotFound(uri) {
return new RequestError(-32001, `Resource not found${uri ? `: ${uri}` : ""}`);
}
toResult() {
return { error: this.toErrorResponse() };
}
toErrorResponse() {
return { code: this.code, message: this.message, data: this.data };
}
}
class ClientSideConnection {
constructor(toClient, _stream) {
this._client = toClient(this);
}
initialize = jest.fn(async () => ({ protocolVersion: 1 }));
newSession = jest.fn(async () => ({ sessionId: "test-session" }));
prompt = jest.fn(async () => ({ stopReason: "end_turn" }));
cancel = jest.fn(async () => undefined);
}
const ndJsonStream = jest.fn(() => ({}));
const PROTOCOL_VERSION = 1;
module.exports = {
RequestError,
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
};

View file

@ -0,0 +1,44 @@
// Lightweight mock for @anthropic-ai/claude-agent-sdk so unit tests can
// import modules that reference its runtime values (`query`,
// `createSdkMcpServer`, `tool`) without pulling the real ESM package
// through ts-jest. Tests that exercise SDK behavior should stub these.
function query() {
// Minimal Query stub: empty async generator + control methods. Tests
// that need real behavior should mock `query` themselves.
const iter = (async function* () {})();
return Object.assign(iter, {
interrupt: async () => {},
setModel: async () => {},
setPermissionMode: async () => {},
setMaxThinkingTokens: async () => {},
applyFlagSettings: async () => {},
initializationResult: async () => ({}),
supportedCommands: async () => [],
supportedModels: async () => [],
supportedAgents: async () => [],
mcpServerStatus: async () => [],
getContextUsage: async () => ({}),
readFile: async () => null,
reloadPlugins: async () => ({}),
accountInfo: async () => ({}),
});
}
function createSdkMcpServer(options) {
return { type: "sdk", instance: { name: options?.name ?? "mock", tools: options?.tools ?? [] } };
}
function tool(name, description, inputSchema, handler) {
return { name, description, inputSchema, handler };
}
class AbortError extends Error {}
module.exports = {
query,
createSdkMcpServer,
tool,
AbortError,
};

View file

@ -13,8 +13,6 @@ let requestUrlImpl = jest.fn().mockResolvedValue({
});
module.exports = {
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
normalizePath: jest.fn().mockImplementation((p) => p),
moment: jest.requireActual("moment"),
requestUrl: (...args) => requestUrlImpl(...args),
__setRequestUrlImpl: (impl) => {
@ -45,7 +43,22 @@ module.exports = {
}),
Platform: {
isDesktop: true,
isDesktopApp: true,
isMobile: false,
},
FileSystemAdapter: class FileSystemAdapter {
constructor(basePath = "/vault") {
this._basePath = basePath;
this.read = jest.fn();
this.write = jest.fn();
this.exists = jest.fn().mockResolvedValue(true);
this.mkdir = jest.fn().mockResolvedValue(undefined);
}
getBasePath() {
return this._basePath;
}
},
normalizePath: (p) => String(p).replace(/\\\\/g, "/").replace(/\/+/g, "/"),
parseYaml: jest.fn().mockImplementation((content) => {
return parseYamlString(content);
}),

6
__mocks__/svg.js Normal file
View file

@ -0,0 +1,6 @@
import React from "react";
const SvgStub = (props) => React.createElement("svg", props);
module.exports = SvgStub;
module.exports.default = SvgStub;

View file

@ -0,0 +1,709 @@
# ACP Protocol & `claude-agent-acp` Wrapper: Concrete Limitations Encountered During SDK Migration
**From:** Obsidian Copilot plugin team
**Re:** Migration from `@agentclientprotocol/claude-agent-acp``@anthropic-ai/claude-agent-sdk`
**Status:** Feedback / RFC. We still ship `@agentclientprotocol/sdk` for our OpenCode and Codex backends; this is not a teardown.
---
## TL;DR
We ship Copilot for Obsidian, an Obsidian plugin that runs an in-vault AI agent inside an Electron renderer. We've used `@agentclientprotocol/claude-agent-acp` (v0.31.4) for our Claude backend since launch. As Claude Code / Claude Agent SDK matured, we hit a series of capability ceilings that ACP-as-a-protocol and the wrapper-as-an-implementation cannot pass through, and we've now migrated our Claude backend to call `@anthropic-ai/claude-agent-sdk` directly. The five gaps that forced the migration:
1. **`AskUserQuestion` is hard-blocked** at the wrapper (`acp-agent.ts:1813`). Claude can never ask the user a multi-choice clarifying question through ACP.
2. **Only `PostToolUse` hooks are exposed** (`acp-agent.ts:1864-1885`). No `PreToolUse`, no `UserPromptSubmit`, no `Stop`, no `SessionStart/End`, no `SubagentStart/Stop`. A whole class of host policy/audit/UX features is unreachable.
3. **No in-process MCP server transport.** ACP forwards only `stdio | http | sse` (`acp-agent.ts:1749-1773`). For an Obsidian plugin, every vault file op needs to flow through `vault.adapter` (so sync, frontmatter, plugin events fire). Under ACP we'd have to spawn a localhost MCP subprocess that round-trips back into the renderer per call — defeats the point.
4. **Many SDK system & content events are silently dropped** (`acp-agent.ts:837-852, 1132-1136, 2738-2747`). Compaction, task progress, memory recall, rate-limit, redacted thinking, `input_json_delta`, citations — all gone. Hosts cannot show "context compacted" UI, live tool-input typing, structured citations, or backoff state.
5. **Streaming model collapses too early.** Tool inputs only arrive as completed JSON blocks (`input_json_delta` discarded); thinking surfaces only as plain text (`acp-agent.ts:2576-2585`); subagent runs surface only as flat `tool_call`s tagged with `parent_tool_use_id`.
The rest of this doc walks each gap with code citations on both sides — so each section can be lifted into a tracked issue. A prioritized list lives in §5. We also call out what ACP got right in §4; the protocol's session-update streaming and `requestPermission` shapes are genuinely good, just under-expressive at the edges.
All ACP-side citations refer to `@agentclientprotocol/claude-agent-acp` v0.31.4 (`~/Developer/claude-agent-acp/src/`). All host-side citations refer to obsidian-copilot branch `zero/acp-test`.
---
## 2. How we use the protocol
Our host:
- Is an Obsidian plugin running inside an Electron renderer (no Node CLI shell, no easy access to spawn pipes from the renderer).
- Drives **multiple agent backends** behind a single UI: Claude (now via SDK), OpenCode (ACP), Codex (ACP). The multi-backend story is exactly why ACP is valuable to us — and exactly why we're writing this rather than walking away.
- Operates on **vault files**, not OS files. Every file mutation must go through `vault.adapter` / `vault.modify` to keep Obsidian's sync, frontmatter parsing, plugin event bus, and encryption-at-rest providers consistent. Direct FS writes are observable but lossy.
- Streams to a custom React UI that wants to render: live thinking deltas, live tool-input deltas, plan proposals, multi-choice elicitation modals, per-turn cost, compaction notices, and rate-limit backoff.
- Targets desktop and mobile Obsidian. Mobile has no `claude` CLI binary available; we work around this only on desktop today.
---
## 3. Limitations by category
Each subsection follows the same shape:
> **What ACP / claude-agent-acp does today** — with `path:line` citation.
> **What we needed** — concrete UX example.
> **Workaround attempted under ACP** — or "no workaround possible."
> **What the SDK exposes natively** — with citation.
> **Suggested protocol change** — one line the ACP team can act on.
### 3.1 No `AskUserQuestion` — agent cannot ask multi-choice clarifying questions
**ACP today.** The wrapper hard-codes `AskUserQuestion` into a disallowed-tools list:
```ts
// claude-agent-acp/src/acp-agent.ts:1812-1813
// Disable this for now, not a great way to expose this over ACP at the moment
// (in progress work so we can revisit)
const disallowedTools = ["AskUserQuestion"];
```
That list is then merged into the SDK options unconditionally (`acp-agent.ts:1862`). There is no `_meta` opt-in, no client capability flag, no way for a host that _does_ know how to render multi-choice prompts to receive them.
**What we needed.** When a user types "rename the daily note", Claude should be able to ask "Which one — today's, yesterday's, or both?" with three buttons, get back the user's selection, and continue the turn without having to abandon and re-prompt.
**Workaround under ACP.** None. The tool is gone from Claude's tool list before the prompt even starts. There is no equivalent ACP notification (`session/elicitation`, `session/ask_question`, etc.).
**SDK native.** The SDK ships `AskUserQuestion` as a built-in tool whose dispatch flows through `canUseTool`. Our `permissionBridge.ts` intercepts:
```ts
// obsidian-copilot/src/agentMode/sdk/permissionBridge.ts
if (toolName === "AskUserQuestion") {
const answers = await openAskUserQuestionModal(input.questions);
return { behavior: "allow", updatedInput: { questions: input.questions, answers } };
}
```
The host opens the modal, the user clicks, the answers thread back through `updatedInput`, and the agent continues the same turn. No protocol-level gymnastics required.
**Suggested protocol change.** Add a `session/elicitation` notification with structured options:
```jsonc
{
"method": "session/elicitation",
"params": {
"sessionId": "…",
"questions": [
{
"question": "Which note?",
"header": "note-pick",
"options": [
{ "label": "Today", "description": "2026-05-03.md" },
{ "label": "Yesterday", "description": "2026-05-02.md" },
],
"multiSelect": false,
},
],
},
}
```
Client returns answers in the response. Hosts that don't implement it can advertise so via capability and the wrapper falls back to the current "disallowed" behavior.
---
### 3.2 Hooks: only `PostToolUse` is exposed
**ACP today.** The wrapper hard-wires exactly one hook surface and uses it internally:
```ts
// claude-agent-acp/src/acp-agent.ts:1864-1885
hooks: {
...userProvidedOptions?.hooks,
PostToolUse: [
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
hooks: [
createPostToolUseHook(this.logger, {
onEnterPlanMode: async () => {
await this.client.sessionUpdate({
sessionId,
update: { sessionUpdate: "current_mode_update", currentModeId: "plan" },
});
await this.updateConfigOption(sessionId, "mode", "plan");
},
}),
],
},
],
},
```
User-provided `hooks.PreToolUse`, `hooks.UserPromptSubmit`, `hooks.Stop`, `hooks.SessionStart`, `hooks.SessionEnd`, `hooks.SubagentStart`, `hooks.SubagentStop`, `hooks.PermissionRequest` etc. are silently passed into `userProvidedOptions?.hooks` only if the _caller_ is a Node consumer that already has SDK types — which an ACP client by construction is not. Across the wire, ACP has no hook concept at all.
The SDK-side hook lifecycle events that _do_ fire (`hook_started`, `hook_progress`, `hook_response`) are then explicitly dropped:
```ts
// claude-agent-acp/src/acp-agent.ts:837-839
case "hook_started":
case "hook_progress":
case "hook_response":
```
(Falls through to the no-op default; cf. lines 837-852.)
**What we needed.**
- **`PreToolUse`** to gate destructive vault writes against a per-vault audit policy _before_ the tool runs (so a denial prevents partial state).
- **`UserPromptSubmit`** to inject vault-context (current note title, selection, frontmatter) into every prompt without polluting the system prompt.
- **`Stop`** to flush a turn-end event for our chat persistence layer.
- **`SubagentStart/Stop`** to render a hierarchical run tree in the UI.
**Workaround under ACP.** Partial. The wrapper's own `PostToolUse` handler emits `current_mode_update` for `EnterPlanMode` and forwards Edit-tool diffs (`tools.ts:771-798`), but this is a private side channel; we cannot intercept it. For `PreToolUse` we'd have to abuse `requestPermission` (which only fires for tools that require approval — read-only tools never hit it).
**SDK native.** Full hook surface across the SDK's `Hook` lifecycle. We don't yet use all of them in obsidian-copilot, but the migration _unblocks_ them:
```ts
// (planned) src/agentMode/sdk/ClaudeSdkBackendProcess.ts
hooks: {
PreToolUse: [{ hooks: [auditWriteHook] }],
UserPromptSubmit: [{ hooks: [vaultContextInjector] }],
Stop: [{ hooks: [persistTurnHook] }],
}
```
**Suggested protocol change.** Add a `session/hook_event` notification with `phase: "pre_tool_use" | "post_tool_use" | "user_prompt_submit" | "stop" | "session_start" | "session_end" | "subagent_start" | "subagent_stop" | "permission_request"` and an opaque `payload` blob. Clients that handle it can return a hook _response_ (block / modify / continue) via the response object. Wrappers stay free to map this onto whatever native hook system the underlying agent has.
---
### 3.3 No in-process MCP servers
**ACP today.** ACP's MCP forwarding accepts exactly three transport types:
```ts
// claude-agent-acp/src/acp-agent.ts:1749-1773
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
for (const server of params.mcpServers) {
if ("type" in server && (server.type === "http" || server.type === "sse")) {
mcpServers[server.name] = {
type: server.type,
url: server.url,
headers: server.headers ? Object.fromEntries(...) : undefined,
};
} else {
// Stdio type (with or without explicit type field)
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env ? Object.fromEntries(...) : undefined,
};
}
}
}
```
There is no fourth case for "host implements these tools in-process and the wrapper should call back via JSON-RPC."
**What we needed.** Vault file ops (`vault_read`, `vault_write`, `vault_edit`, `vault_list`, `vault_glob`, `vault_grep`) MUST flow through `app.vault.adapter` and `app.vault.modify/create`. Anything else — direct FS access from a spawned subprocess, for instance — bypasses Obsidian's:
- file-modified event bus (other plugins watching for changes break)
- frontmatter parser / metadata cache
- third-party sync drivers (Obsidian Sync, iCloud, Self-hosted LiveSync)
- mobile compatibility (mobile has no spawnable processes at all)
- vault encryption-at-rest plugins
**Workaround under ACP.** We considered shipping a localhost stdio MCP subprocess that round-trips every read/write back into the renderer over WebSocket or named pipe. This (a) cannot work on mobile, (b) requires shipping platform-specific binaries inside an Obsidian plugin, which is not a supported distribution shape, and (c) adds a per-tool-call latency floor for what should be a memory-speed operation.
**SDK native.** The SDK accepts an `sdk`-typed MCP server whose tools are JS callbacks the agent invokes in-process:
```ts
// obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts
const server = createSdkMcpServer({
name: "obsidian-vault",
tools: [
tool("vault_read", "Read a vault file", { path: z.string() }, async ({ path }) => {
return { content: [{ type: "text", text: await app.vault.adapter.read(path) }] };
}),
// … vault_write, vault_edit, vault_list, vault_glob, vault_grep
],
});
// in query options:
mcpServers: { "obsidian-vault": { type: "sdk", instance: server } }
```
The agent sees the tools as normal MCP tools; the host runs them with full vault-API access. No subprocess, no IPC, works on mobile.
**Suggested protocol change.** Add an `mcp/sdk` (or `mcp/host`) transport. The wrapper-side adapter would expose the tools as if they were stdio MCP, but route every `tools/call` back to the ACP client as a `session/host_tool_call` notification, awaiting a response. Implementation cost on both sides is moderate; the payoff is hosts can finally implement their own first-class tools without process management.
---
### 3.4 Silent message drops
**ACP today.** Two large switch statements in `acp-agent.ts` and `tools.ts` enumerate SDK events the wrapper does not translate. Falling through means the host literally never sees them.
System messages (`acp-agent.ts:837-852`):
```ts
case "hook_started":
case "hook_progress":
case "hook_response":
case "files_persisted":
case "task_started":
case "task_notification":
case "task_progress":
case "task_updated":
case "elicitation_complete":
case "plugin_install":
case "memory_recall":
case "notification":
case "api_retry":
case "mirror_error":
```
Tool-progress / auth / billing (`acp-agent.ts:1132-1136`):
```ts
case "tool_progress":
case "tool_use_summary":
case "auth_status":
case "prompt_suggestion":
case "rate_limit_event":
```
Content block types (`acp-agent.ts:2738-2747`):
```ts
case "document":
case "search_result":
case "redacted_thinking":
case "input_json_delta":
case "citations_delta":
case "signature_delta":
case "container_upload":
case "compaction":
case "compaction_delta":
case "advisor_tool_result":
break;
```
**What we needed (host-by-host UX impact).**
| Dropped event | UX we wanted but cannot ship |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `compaction`, `compaction_delta` | Show "Context window compacted (X→Y tokens)" notice in chat history so users understand why earlier messages stop influencing the model |
| `task_started`, `task_progress`, `task_updated`, `task_notification` | Render running subagents with live progress; show their tools/output without polling |
| `memory_recall` | Surface "Agent recalled this from session 2026-04-12" as inline citation |
| `redacted_thinking` | Show a redaction marker (current behavior: thinking just silently disappears) |
| `input_json_delta` | Live "Bash command being typed" in tool-call UI (see §3.6) |
| `citations_delta` | Structured citations from `WebSearch` / `WebFetch` results — currently lost and re-derived from text |
| `signature_delta` | Cryptographic signing of thinking blocks (used for cache-eligibility) — required for cache semantics in extended-thinking turns |
| `rate_limit_event`, `api_retry` | Show "Rate-limited, retrying in 12s" UI; today the user just sees an unexplained stall |
| `auth_status` | React to mid-turn auth-token refresh failures |
| `tool_progress` | Long-running tool progress bars (Bash, WebFetch with large responses) |
**Workaround under ACP.** None. The events do not cross the wire.
**SDK native.** All the above events are first-class on `SDKMessage` / `stream_event`. The host can subscribe selectively.
**Suggested protocol change.** Two-part fix:
1. **Add explicit variants** for the high-value ones (`session/compaction`, `session/task_progress`, `session/rate_limit`, `session/citation`).
2. **Add a passthrough envelope** `session/raw_event` with `{ source: "claude_code" | "opencode" | …, eventType: string, payload: unknown }` for everything else, behind a client capability flag. Hosts that opt in get to render whatever they want; hosts that don't get current behavior.
---
### 3.5 Information flattening on tool calls
**ACP today.** The wrapper recognizes 8 named tools (Agent/Task, Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch — `tools.ts:121-411`) and collapses everything else into `kind: "other"` with the input JSON-stringified.
```ts
// claude-agent-acp/src/tools.ts:121-145 (excerpt)
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput = false,
cwd?: string
): ToolInfo {
const name = toolUse.name;
switch (name) {
case "Agent":
case "Task": {
/* kind: "think" */
}
case "Bash": {
/* kind: "execute" */
}
case "Read": {
/* kind: "read" */
}
case "Write":
case "Edit": {
/* kind: "edit" */
}
case "Glob":
case "Grep": {
/* kind: "search" */
}
case "WebFetch":
case "WebSearch": {
/* kind: "fetch" */
}
// … TodoWrite (special-cased to plan update), ExitPlanMode (switch_mode)
default:
return {
title: name,
kind: "other",
content: [
{
type: "content",
content: { type: "text", text: JSON.stringify(toolUse.input, null, 2) },
},
],
};
}
}
```
`Edit` and `Write` results are translated to **empty** updates, with the actual diff smuggled through the `PostToolUse` hook side channel (`tools.ts:413-552`):
```ts
case "Edit":
case "Write":
// diffs are sent via PostToolUse hook only
return {};
```
Image content in tool results is downconverted to a text stub (`tools.ts:632`):
```ts
return [{ type: "content", content: { type: "text", text: `Fetched: ${block.url ?? ""}` } }];
```
**What we needed.** Any user-defined MCP tool — say a `notion_search` tool from a Notion MCP server — should render with a meaningful icon and title, not "Other: { query: 'foo' }". And any tool whose result is an image (a chart-rendering MCP tool, an OCR tool) should keep the image.
**Workaround under ACP.** None for non-builtin tools. For the image case, only inline base64 images survive the round-trip; URL- and file-based images become text.
**SDK native.** Tool results carry full `content` arrays through unchanged. MCP tools advertise their own metadata (description, inputSchema, annotations) at registration; hosts that consume the SDK directly can read the registry.
**Suggested protocol change.** Add a `tool_call.metadata` field populated from MCP tool descriptors at registration time:
```jsonc
{
"sessionUpdate": "tool_call",
"toolCallId": "…",
"title": "notion_search",
"kind": "search",
"metadata": {
"mcpServer": "notion",
"description": "Search Notion pages",
"icon": "notion",
},
"rawInput": { "query": "foo" },
}
```
Plus: stop dropping image content blocks unconditionally — pass them through with their original `source` shape (URL / file / base64) and let the host decide.
---
### 3.6 Streaming model: no partial tool inputs, no thinking deltas
**ACP today.** Two specific deltas are silently dropped on the content-block level (`acp-agent.ts:2738-2747`, repeated for emphasis):
- `input_json_delta` — the SDK streams tool-call arguments incrementally. ACP discards.
- `signature_delta` — used to sign thinking blocks for cache eligibility. ACP discards.
Thinking blocks themselves _do_ survive but only as plain text (`acp-agent.ts:2576-2585`):
```ts
// excerpt — every thinking block becomes a flat agent_thought_chunk
case "thinking":
case "thinking_delta":
update = { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: block.text ?? block.delta } };
```
Structured budget, signature, encrypted-thinking metadata — all lost.
**What we needed.**
- Live tool-input UI: as Claude types out a Bash command, show it filling in token-by-token. Not just "spinner → completed JSON dump."
- Cacheable thinking turns: keep the signature so the next turn can re-use the cache.
- Thinking metadata UI: render budget consumed / remaining as a progress bar.
**Workaround under ACP.** The wrapper _does_ call `toAcpNotifications` recursively on a streaming `content_block_start` (`acp-agent.ts:2784-2790`), which means the _first_ time a `tool_use` block opens we get a `tool_call` notification — but its `input` field is the empty object the SDK started with, since `input_json_delta` updates between `start` and `stop` are not propagated. We can't reconstruct progressive input client-side.
**SDK native.** Full `stream_event` access including `input_json_delta`, `signature_delta`, `text_delta`, `thinking_delta`. Our translator now reassembles per content-block index:
```ts
// obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts
case "input_json_delta": {
const block = state.toolUseBlocks.get(event.index);
if (!block) break;
block.partial += event.delta.partial_json;
try {
block.parsed = parsePartialJson(block.partial);
} catch { /* still incomplete */ }
emit({ sessionUpdate: "tool_call_update", toolCallId: block.id, rawInput: block.parsed });
}
```
The UI shows the Bash command typing itself in.
**Suggested protocol change.** Add two notification variants:
- `tool_call_input_chunk { toolCallId, partialInputJson }` — fired as each `input_json_delta` arrives. Hosts can either ignore (current behavior) or use it to drive live UI.
- `agent_thought_chunk` already exists; extend with optional `signature?: string`, `budgetTokensUsed?: number`, `budgetTokensRemaining?: number` fields.
---
### 3.7 No fine-grained permission control
**ACP today.** The wrapper exposes exactly three session config options (`acp-agent.ts:2256-2339`): `mode`, `model`, `effort`. The `mode` enum (`acp-agent.ts:1319-1324`) covers `auto | default | acceptEdits | bypassPermissions | dontAsk | plan`. There is no separate axis for `permissionMode` — the two are conflated.
`requestPermission` outcomes are limited to:
- `allow_once`
- `allow_always` with a label string ("all Bash", "Bash(npm test:\*)")
- `reject_once`
**What we needed.**
- The SDK's `canUseTool` callback can return `updatedPermissions: PermissionUpdate[]` along with the decision — a structured array of allow/deny rules to install for the rest of the session. Far richer than a label string.
- Independent `permissionMode` (gates whether tools require approval) and "agent mode" (e.g. `plan` is conceptually different from `acceptEdits`).
**Workaround under ACP.** We parse the `allow_always` label heuristically to derive "always allow Bash with prefix `npm test`" rules. Brittle and lossy — the label is human text, not a structured rule.
**SDK native.**
```ts
// SDK type
type PermissionResult =
| { behavior: "allow"; updatedInput?: unknown; updatedPermissions?: PermissionUpdate[] }
| { behavior: "deny"; reason: string };
type PermissionUpdate =
| { type: "addRules"; rules: PermissionRule[]; behavior: "allow" | "deny" | "ask"; destination: "session" | "project" | "user" }
| { type: "removeRules"; rules: PermissionRule[]; … }
| { type: "setMode"; mode: PermissionMode; … };
```
**Suggested protocol change.** Extend the `session/request_permission` response schema with an optional `updatedPermissions: PermissionUpdate[]` array using a structured rule shape rather than a free-form label.
---
### 3.8 Session control plane is RPC-only, not live
**ACP today.** Switching model or mode mid-session is a separate RPC roundtrip:
- `session/set_session_model`
- `session/set_session_config_option` (for `mode` / `effort`)
Plus capability probing — the wrapper detects whether the agent supports them at all (the obsidian-copilot side handles the probe in `src/agentMode/acp/AcpBackendProcess.ts:96-101`).
**What we needed.** A user is mid-turn on Sonnet, sees Claude is going to need a long thinking pass, and wants to switch to Opus _without_ aborting and re-prompting (which would discard the partial output and lose the cache).
**Workaround under ACP.** Abort the turn (`session/cancel`), wait for the cancel to settle, send `session/set_session_model`, re-prompt with the original user input. User sees their progress disappear and a fresh latency cost.
**SDK native.** The SDK's `query()` exposes async `setModel(modelId)` and `setPermissionMode(mode)` methods callable mid-stream. Internally these flow through the same JSON-RPC framing, but they're applied to the _live_ generator without interrupting it. Our adapter forces streaming-input mode (`ClaudeSdkBackendProcess.ts:218`) to enable this.
```ts
// obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts:330-360
async setModel(model: string) {
await this.activeQuery?.setModel(model);
}
async setPermissionMode(mode: PermissionMode) {
await this.activeQuery?.setPermissionMode(mode);
}
```
**Suggested protocol change.** Define the existing `session/set_session_model` and `session/set_session_config_option` RPCs as **valid mid-turn** (not just between turns), and require wrappers to apply them to the in-flight generator without interrupting. Document the semantics: the new value applies starting from the _next_ assistant message or tool call within the current turn.
---
### 3.9 No subagent control surface
**ACP today.** The Agent / Task tool is mapped onto `kind: "think"` (`tools.ts:129-144`):
```ts
case "Agent":
case "Task": {
const input = toolUse.input as AgentInput | BashInput | undefined;
return { title: input?.description ?? "Task", kind: "think", … };
}
```
Subagent runs surface as nested `tool_call` updates tagged with `_meta.claudeCode.parentToolUseId` (`acp-agent.ts:2755-2762`). There is no first-class "subagent started / progressed / finished / errored" event family.
**What we needed.**
- Render running subagents with their model, allowed tools, and cumulative token use.
- Allow the user to interrupt a specific subagent without aborting the whole turn.
- Show a hierarchical run tree (parent agent → subagent → sub-subagent) instead of a flat list of `tool_call`s.
**Workaround under ACP.** Reconstruct the tree client-side from `parent_tool_use_id`. Possible but fragile, and doesn't unlock interruption or per-subagent status.
**SDK native.** The SDK exposes `agents: { name: AgentDefinition }` config, an `Agent` tool, and `task_*` events (`task_started`, `task_progress`, `task_notification`, `task_updated`) — all of which ACP drops (§3.4).
**Suggested protocol change.** Add a `session/subagent_*` notification family:
- `session/subagent_started { taskId, parentTaskId?, name, model, tools }`
- `session/subagent_progress { taskId, message? }`
- `session/subagent_finished { taskId, status: "ok" | "error" | "cancelled", usage? }`
Plus a `session/cancel_subagent { taskId }` RPC.
---
### 3.10 System prompt locked to preset
**ACP today.** `acp-agent.ts:1775-1791` constructs the system prompt; the default and the "custom" path both keep `type: "preset"`:
```ts
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
const customPrompt = params._meta.systemPrompt;
if (typeof customPrompt === "string") {
systemPrompt = customPrompt; // raw string supported via _meta
} else {
systemPrompt = {
type: "preset",
preset: "claude_code",
append: customPrompt.append,
// …
} as Options["systemPrompt"];
}
}
```
So a host _can_ fully override by passing a raw string via `_meta.claudeCode.systemPrompt`, but the only documented protocol-level way to customize is append-style on top of the locked `claude_code` preset.
**What we needed.** Insert vault-specific scaffolding ("paths are vault-relative; never use absolute FS paths; the user's current note is X; selection is Y") _without_ the Claude Code preset clobbering it. The Claude Code preset assumes a CLI environment, references `.claude/` files, and instructs about behaviors that don't apply inside Obsidian.
**Workaround under ACP.** Use `_meta.claudeCode.systemPrompt` as a string. Works, but is an undocumented escape hatch and reaches outside the protocol contract — not portable to non-Claude ACP backends.
**SDK native.** `systemPrompt: string` is fully supported as a first-class field on `Options`.
**Suggested protocol change.** Promote `systemPrompt` to a documented protocol-level field on `session/new` with two shapes:
```ts
type SystemPrompt =
| { type: "preset"; preset: string; append?: string; excludeDynamicSections?: string[] }
| { type: "custom"; text: string };
```
Wrappers translate to whatever the underlying agent supports; agents that only have presets can refuse `type: "custom"` with a clear error.
---
### 3.11 Slash command / skill allowlist filtering
**ACP today.** The wrapper hard-codes a list of slash commands that are stripped before being advertised to the client (`acp-agent.ts:2379-2410`):
```ts
const UNSUPPORTED_COMMANDS = [
"cost",
"keybindings-help",
"login",
"logout",
"output-style:new",
"release-notes",
"todos",
];
```
Plus MCP commands get renamed: `/mcp:server:cmd``/server:cmd (MCP)`.
**What we needed.** Some of these we'd actually like to surface — `/cost` could populate our settings panel, `/release-notes` could show a changelog. We have a different login flow, so `/login` and `/logout` can stay filtered, but that should be a host _choice_, not a wrapper _fact_.
**Workaround under ACP.** None. The wrapper strips them before they reach us.
**SDK native.** `query.supportedCommands()` returns the full list with metadata; the host filters as it sees fit.
**Suggested protocol change.** Pass through all commands by default. Let the host opt out via capability:
```jsonc
{
"clientCapabilities": {
"slashCommands": { "exclude": ["login", "logout"] },
},
}
```
---
### 3.12 Auth and CLI binary discovery (wrapper, not protocol)
These aren't ACP-protocol issues per se, but anyone consuming `claude-agent-acp` from a non-Node-CLI environment hits them:
- **Binary discovery.** The wrapper calls `claudeCliPath()` (`acp-agent.ts:1857`) which assumes a normal `PATH` environment. Inside Obsidian / Electron renderer, `PATH` doesn't include user shell paths; we had to ship a `claudeBinaryResolver` (in `obsidian-copilot/src/agentMode/sdk/`) that probes Volta, asdf, NVM, Homebrew, npm-global, and Windows-specific locations.
- **Electron renderer crashes.** Both the SDK's process-transport-close and the MCP SDK's stdio-close-wait call `.unref()` on a `setTimeout` handle. In the Electron renderer this is unsafe and crashes during teardown. We patch this at bundle time with `scripts/patchRendererUnsafeUnref.js`. The wrapper inherits this directly from the SDK — same patch would be needed for any host that bundles `claude-agent-acp` into a renderer.
- **Mobile.** No spawnable binaries. The wrapper has no escape hatch for "no CLI available, fall back to remote API."
**Suggested protocol change / wrapper change.**
1. Document the binary-discovery contract (wrapper reads `CLAUDE_CODE_EXECUTABLE`; if unset, calls `claudeCliPath()`; recommended host behavior is to set the env var explicitly).
2. Ship a renderer-safe build of the wrapper (or guard the unsafe `.unref()` sites internally).
3. (Long-term) Add a `mode: "remote" | "local"` option that routes through the Anthropic API directly when no local binary is available — useful for mobile and serverless hosts.
---
## 4. What ACP got right
It's worth stating clearly: the protocol's bones are good, and the gaps above are fixable without breaking the abstraction.
- **Session-update streaming** (`SessionNotification` + `sessionUpdate` discriminator) is the right shape for tail-of-turn events. Adding new variants is non-breaking.
- **`requestPermission`** has the right semantics — synchronous client decision blocking the agent. The under-expressiveness (§3.7) is an extension point, not a redesign.
- **Multi-backend uniformity** is the actual reason we wrote this letter rather than walking away. Our OpenCode and Codex backends still go through ACP, and the win of "one UI, one chat-history layer, one permission modal across three different agents" is large enough that we tolerated each gap individually for a long time. We're not asking for ACP to absorb every Claude-specific feature — we're asking for the protocol to grow generic versions of the categories the SDK has shown to be valuable.
---
## 5. Prioritized request list
| Priority | Gap | Section |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| **P0** | `AskUserQuestion` / structured elicitation | §3.1 |
| **P0** | Hook lifecycle events (`PreToolUse`, `UserPromptSubmit`, `Stop`, `Subagent*`, `SessionStart/End`) | §3.2 |
| **P0** | In-process MCP transport (`mcp/sdk` or `mcp/host`) | §3.3 |
| **P1** | Pass through dropped events: `compaction`, `task_*`, `memory_recall`, `rate_limit_event`, `redacted_thinking`, `citations_delta`, `signature_delta` | §3.4 |
| **P1** | Streaming tool input deltas (`tool_call_input_chunk`) | §3.6 |
| **P1** | Structured `updatedPermissions` array on `requestPermission` response | §3.7 |
| **P2** | Documented custom system prompt (`type: "custom"`) | §3.10 |
| **P2** | First-class subagent notifications | §3.9 |
| **P2** | Mid-turn model / mode / config switches applied to live generator | §3.8 |
| **P3** | Slash command passthrough by default | §3.11 |
| **P3** | `tool_call.metadata` for non-builtin tools + image content preservation | §3.5 |
| **Wrapper** | Renderer-safe build, documented binary discovery, mobile/remote escape hatch | §3.12 |
---
## 6. Appendix
### 6.1 Citation index (ACP wrapper)
- `acp-agent.ts:837-852` — system message switch dropping `hook_*`, `task_*`, `files_persisted`, `elicitation_complete`, `plugin_install`, `memory_recall`, `notification`, `api_retry`, `mirror_error`
- `acp-agent.ts:1132-1136``tool_progress`, `tool_use_summary`, `auth_status`, `prompt_suggestion`, `rate_limit_event` dropped
- `acp-agent.ts:1319-1324``mode` enum: `auto`, `default`, `acceptEdits`, `bypassPermissions`, `dontAsk`, `plan`
- `acp-agent.ts:1749-1773` — MCP transport handling (stdio / http / sse only)
- `acp-agent.ts:1775-1791` — system prompt construction (locked to preset over the wire)
- `acp-agent.ts:1812-1813``disallowedTools = ["AskUserQuestion"]`
- `acp-agent.ts:1864-1885` — hook config (PostToolUse only; internal `onEnterPlanMode`)
- `acp-agent.ts:2256-2339` — exposed config options (mode, model, effort)
- `acp-agent.ts:2379-2410``UNSUPPORTED_COMMANDS` slash-command filter
- `acp-agent.ts:2576-2585` — thinking blocks → flat `agent_thought_chunk`
- `acp-agent.ts:2738-2747` — content block types dropped
- `acp-agent.ts:2755-2762` — subagent surface via `_meta.claudeCode.parentToolUseId`
- `tools.ts:121-411` — 8-tool kind allowlist
- `tools.ts:413-552` — Edit/Write tool result transforms (returns `{}`)
- `tools.ts:632` — image content downconverted to `"Fetched: <url>"` text stub
- `tools.ts:771-798` — internal PostToolUse hook for plan-mode and Edit-diff propagation
### 6.2 Citation index (host adapter that demonstrates the SDK surface)
- `obsidian-copilot/src/agentMode/sdk/ClaudeSdkBackendProcess.ts` — query lifecycle, mid-turn `setModel`/`setPermissionMode`, MCP server registration
- `obsidian-copilot/src/agentMode/sdk/sdkMessageTranslator.ts``input_json_delta` reassembly, thinking-delta handling, `EnterPlanMode` detection
- `obsidian-copilot/src/agentMode/sdk/vaultMcpServer.ts` — in-process MCP server using `vault.adapter`
- `obsidian-copilot/src/agentMode/sdk/permissionBridge.ts``canUseTool` integration, AskUserQuestion modal hookup
- `obsidian-copilot/src/agentMode/acp/AcpBackendProcess.ts` — the ACP integration we kept for OpenCode and Codex
### 6.3 Versions tested
- `@agentclientprotocol/claude-agent-acp` v0.31.4
- `@anthropic-ai/claude-agent-sdk` v0.2.123 (the version `claude-agent-acp` itself depends on)
- `@agentclientprotocol/sdk` v0.21.0
### 6.4 Cross-reference
Internal migration roadmap: [`designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md`](./todo/CLAUDE_AGENT_SDK_MIGRATION.md). That document covers our implementation plan, milestones, and Electron-renderer spike findings — this document is its outward-facing protocol-feedback companion.
---
_We're happy to chat through any of these in more detail, contribute fixes upstream, or test pre-release wrapper changes against our codebase. The fastest channel for follow-ups is the obsidian-copilot GitHub issues; tag them `acp-feedback`._

View file

@ -0,0 +1,274 @@
# Agent Processing UI — Design Doc
## Context
Agent mode streams a mix of events while a turn is in flight: tool calls, tool results, model reasoning tokens, sub-agent invocations, plan proposals, and prose deltas. The UI has to make this trail **scannable, verifiable, and unobtrusive** for knowledge workers — without devolving into a debug log.
Today the chat surface renders a flat list of tool-call cards, prose, and a (legacy) reasoning block. It works for simple turns but breaks down on three patterns we now hit regularly:
1. **Burst tool calls** — the agent fires 5 consecutive `Edit`s and floods the message with near-identical cards.
2. **Sub-agents** — Claude Code's Task tool spawns a child agent whose tool calls and reasoning currently render as flat top-level entries, indistinguishable from the parent's work.
3. **Reasoning streams** — agent-mode `thought` parts exist in the data model but aren't hooked to the existing `AgentReasoningBlock` UI, so reasoning either disappears or renders as a static `<details>` block.
This doc proposes a unified component model that handles all three, and the visual rules that make it readable.
## Design principles
1. **What was consulted matters more than how the model thought.** Tool calls are the primary signal. Reasoning is secondary, collapsed by default after streaming.
2. **One visual rhythm.** Every action — tool, sub-agent, reasoning — follows the same `icon · verb · target · outcome` pattern, so the eye learns to scan once.
3. **Progressive disclosure.** Default = one dense line. Expand for details. Re-expand for raw payloads. Never dump full tool results inline.
4. **Verifiable.** Every claim in the prose answer must be traceable to a card; every card must show _which note / URL / line range_ it touched.
5. **Compact > complete.** A 5-action turn = 5 lines, not 5 paragraphs. Prefer aggregation (one `Edited 5 notes` card) over enumeration when actions are homogeneous.
6. **Don't invent abstractions over the data model.** The backend already emits tool calls, reasoning, and parent-child links. The UI's job is to render them well, not to introduce a separate "Chain of Thought" or "Task" layer on top.
## Component taxonomy
We collapse the four Vercel SDK concepts (Reasoning, Tool, Task, Chain of Thought) into **two render primitives**:
| Primitive | Renders | Source |
| ------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| **Action card** | A tool call, an aggregate of consecutive same-tool calls, OR a sub-agent invocation | `AgentMessagePart.tool_call` (with optional `parentToolCallId`) |
| **Reasoning block** | The model's internal thinking tokens | `AgentMessagePart.thought` |
Sub-agents reuse the action-card primitive — they're just an action whose "result" is a nested trail of more action cards. **No separate `Task` or `Chain of Thought` component.**
## Action card
### Anatomy (collapsed)
```
┌─────────────────────────────────────────────────────┐
│ 🔍 Searched web · "jazz piano voicings" ✓ ⌄ │
│ 5 results · 1.2s │
└─────────────────────────────────────────────────────┘
```
- **Icon** — tool family (search, read, edit, fetch, vault, agent, time, …)
- **Verb · target** — past-tense action + the thing acted on
- **Outcome line** — small, muted: count + duration
- **Status**`⟳` running, `✓` done, `⚠` failed, `∅` empty result
- **Affordance**`⌄` to expand
### Tool-aware summaries
The collapsed line speaks the user's language per tool. Same shape, tool-specific outcome:
```
📄 Read music-theory.md · lines 1048 ✓
🗂️ Searched vault · "scales" · 12 matches ✓
🔗 Fetched pianogroove.com/voicings ✓
✏️ Edited practice-log.md · +12 / 0 lines ✓
🧠 Indexed 47 notes · jazz folder ✓
🕒 Time "what day is today" ✓
```
### Expanded view
Shows **inputs** (so the user can verify the agent searched what they'd expect) and **outputs** in cited form (clickable note paths and URLs). Full result body is _not_ dumped — there's a second-level expand for the raw payload when needed.
```
┌─────────────────────────────────────────────────────┐
│ 🔍 Searched web · "jazz piano voicings" ✓ ⌃ │
│ 5 results · 1.2s │
│ ───────────────────────────────────────────────── │
│ Query: jazz piano voicings │
│ │
│ Results: │
│ 1. Jazz Piano Voicings — pianogroove.com ↗ │
│ 2. Modern Jazz Voicings — open-studio.com ↗ │
│ 3. Rootless Voicings Guide — jazzwise.com ↗ │
│ 4. ... │
└─────────────────────────────────────────────────────┘
```
### States
```
⟳ Searching web · "jazz piano voicings"… (running, animated)
✓ Searched web · "jazz piano voicings" · 5 results (done)
⚠ Search failed · network error [retry] (error)
∅ Read note · daily/2024-03-15.md · empty (degenerate ok)
```
Empty results render explicitly — knowledge workers care about negative findings ("no, you don't have notes on this").
## Compaction (consecutive tool calls)
### Rule
At render time, fold a run of N ≥ 2 cards into a single aggregate when:
- **Same tool type** (all `Edit`, all `Read`, all `WebSearch`)
- **Strictly consecutive in the part stream** — no intervening tool of a different type, no streamed prose, no `thought` part between them
- **Same nesting level** — never compact across a sub-agent boundary
No fancier heuristics (no "detect read-edit cycles", no topic clustering). The backend may also pass an explicit `group_id`/`batch_id` in `_meta` to force-group; the UI honors it but doesn't depend on it.
### Aggregate card
The collapsed view shows a tool-aware aggregate stat, _not_ the first item:
```
✏️ Edited 5 notes · +47 / 12 lines ✓ ⌄
📄 Read 3 notes · 4.5k tokens ✓ ⌄
🔍 Searched web · 3 queries · 17 unique results ✓ ⌄
🗂️ Searched vault · 4 queries · 28 matches ✓ ⌄
```
Mixed status surfaces in the line: `✏️ Edited 5 notes · 4 ✓ · 1 ⚠`.
### Expanded aggregate
Per-item rows with their own micro-summary; each row is itself expandable to show the underlying card detail:
```
✏️ Edited 5 notes · +47 / 12 lines ✓ ⌃
─────────────────────────────────────────────────
✏️ practice-log.md · +12 / 0
✏️ jazz/voicings.md · +18 / 8
✏️ jazz/scales.md · +8 / 2
✏️ daily/2024-03-15.md · +6 / 0
✏️ ⚠ archive/old.md · failed (file locked)
```
### What we lose
Strict per-call timestamps in the collapsed view. Acceptable: the expanded view restores everything, and 95% of the value is the aggregate stat.
## Sub-agent nesting
Sub-agents use the **same action-card primitive** with a different shape: the child trail lives inside the expanded card. They are identified by `parentToolCallId` on child tool parts (already extracted in `backendMeta.ts`; needs to be propagated into `AgentMessagePart`).
### Collapsed parent card
```
🤖 research-agent · "find all jazz voicings notes" ✓ ⌄
3 tools · 1 reasoning · 4.2s · 7 matches found
```
- Sub-agent identity (`research-agent`, `code-reviewer`, `explore`) is first-class — surfaced in the title.
- Outcome line summarizes both _the work_ (tool/reasoning counts, duration) and _the result_ (whatever the sub-agent returned).
### Expanded parent card — full nested trail
```
🤖 research-agent · "find all jazz voicings notes" ✓ ⌃
3 tools · 1 reasoning · 4.2s
┃ 🗂️ Searched vault · "voicings" · 7 matches ✓
┃ 💭 Reasoning · 250 tokens ⌄
┃ 📄 Read voicings.md · 1.2k ✓
┃ 📄 Read 2024-02-piano.md · 800 ✓
┃ ─────────────────────────────────────────────
┃ Returned: 7 notes referencing voicings,
┃ primarily under jazz/ and practice/
```
A vertical guide rail (`┃`) and indentation make nesting unambiguous. Child cards are the _same components_ as top-level cards, just rendered inside a parent. Compaction applies inside the nest the same way (5 sub-agent edits → one aggregate inside the parent).
### Streaming behavior
Default for all sub-agents:
```
🤖 research-agent · "find all jazz voicings notes" ⟳
2 tools so far…
```
Collapsed parent shows running counter; expanding lets the user watch live. (Auto-expand-while-running, auto-collapse-on-done is a reasonable v2 enhancement for long primary sub-agents — defer until users ask for it.)
### Recursion
Cap visible nesting at 23 levels. Beyond the cap, deeper sub-agents render as collapsed `🤖 …` cards inside the parent — still clickable to drill in, but indentation stops growing. Without a cap, deep traces become unreadable horizontal noise.
### Sub-agent textual return values
Some sub-agents return prose. Render it as a quoted block at the bottom of the expanded parent — keeps the parent card self-contained, no scroll-elsewhere required:
```
🤖 code-reviewer · "review auth changes" ✓ ⌃
5 tools · 2 reasoning · 12s
┃ ...child cards...
┃ ─────────────────────────────────────────────
┃ > The migration is safe under concurrent
┃ > writes. The backfill default handles…
```
## Reasoning block
Renders the model's `thought` parts. Visually distinct from action cards — lower weight, no border, muted text — to signal "secondary information."
```
💭 Reasoning · 250 tokens · 1.8s ⌄
```
### Streaming
While streaming: a small pulse + live token counter, no auto-expand. (Vercel's auto-open-during-stream pattern is distracting in a chat UI; users can click to watch live if they want.)
### After streaming
Collapsed by default. Click to read. The block can show either raw thinking text or, if the backend emits structured reasoning steps, a step list — but the same component handles both.
## Composition with the chat message
```
You ▸ summarize what I've written about jazz voicings
Assistant
🗂️ Searched vault · "voicings" · 7 matches ✓
💭 Reasoning · 250 tokens ⌄
📄 Read 2 notes · 2.0k tokens ✓
─────────────────────────────────────────────────────
Across your notes, you've explored three voicing
families: rootless (Bill Evans style), quartal
(McCoy Tyner), and shell voicings…
[Sources: voicings.md, 2024-02-piano.md]
```
Action cards and reasoning blocks form a header strip above the prose answer. Citations at the bottom link prose claims back to specific notes — they tie the cards (process) to the prose (result).
## Edge cases
- **Parallel tool calls** (agent fires 3 reads in one turn): order chronologically by completion. If they're same-tool, compaction folds them naturally.
- **Mid-trail failure**: parent stays `⚠`, child cards remain visible — the partial trail is the most useful debugging artifact.
- **Empty results**: surface explicitly (`∅ Searched vault · 0 matches`). Don't collapse to "Done."
- **Permission-gated tools** (e.g., `ExitPlanMode` plan proposals): existing `PlanProposalCard` handles this — keep it as a specialized action-card variant, not a separate primitive.
- **Backend group hint**: an explicit `group_id` in `_meta` overrides the consecutive-same-tool heuristic, letting agent authors mark logically grouped operations even when interleaved.
- **Tool that produces no output** (fire-and-forget): show with `✓` and an outcome line like "no return value."
## Current state & gaps
References from `zero/acp-test`:
| Area | File | Status |
| --------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Streaming events | `src/agentMode/acp/AcpBackendProcess.ts`, `src/agentMode/session/AgentSession.ts` | ✅ tool_call / tool_call_update / agent_message_chunk / thought / plan all flowing |
| Tool card UI | `src/agentMode/ui/AgentToolCall.tsx` | ✅ renders `tool_call` / `thought` / `plan`, status icons, diff view, file annotations |
| Plan proposal | `src/agentMode/ui/PlanProposalCard.tsx` (commit `fa7a65d`) | ✅ specialized card, permission flow, preview view |
| Backend meta | `src/agentMode/session/backendMeta.ts`, `src/agentMode/backends/claude-code/meta.ts` | ✅ `parentToolCallId`, `vendorToolName`, `isPlanProposal` extracted |
| Reasoning UI | `src/components/chat-components/AgentReasoningBlock.tsx` | ⚠ exists for legacy chat, **not wired** to agent-mode `thought` parts |
| **Sub-agent nesting** | — | ❌ `AgentMessagePart.tool_call` lacks a `parentToolCallId` field; tool parts are flattened in the store; no UI hierarchy |
| **Tool compaction** | — | ❌ no aggregation/grouping logic; consecutive same-tool calls render as N separate cards |
| **Aggregate stats** | — | ❌ no tool-aware summary helpers (line deltas, token totals, unique-result counts) |
### Implementation pointers (high-level — not a step-by-step plan)
- **Data model**: add `parentToolCallId?: string` to `AgentMessagePart.tool_call` and propagate it from `backendMeta.ts` into the store. This is the foundation for both sub-agent nesting and ensuring compaction never crosses a parent/child boundary.
- **Render-time grouping**: introduce a pure function that takes the flat `AgentMessagePart[]` for an assistant turn and returns a tree of render nodes — `Card | AggregateCard | SubAgentCard | ReasoningBlock`. Compaction is a fold over consecutive same-tool peers at each level. Keep this layer purely derivational so the store stays flat.
- **Tool-aware summary registry**: a small registry mapping tool name → `(parts) => { line: string, stat: string }`. Each tool family (read, edit, search, fetch, vault-search, …) contributes one entry. Falls back to a generic summary for unknown tools.
- **Reasoning hookup**: route agent-mode `thought` parts through `AgentReasoningBlock`. The legacy block already supports streaming + collapse-on-done; the gap is wiring, not new UI.
- **Sub-agent component**: a thin variant of `AgentToolCall` that, when expanded, renders its child render tree (recursively reusing the same renderer). Indentation + a left guide rail handles the visual.
## Verification
- **Unit**: cover the grouping/aggregation function with cases for (a) heterogeneous runs (no grouping), (b) consecutive same-tool, (c) interleaved with reasoning (no grouping), (d) sub-agent boundary (no grouping across), (e) explicit `group_id`.
- **Visual / live**: drive each case end-to-end via agent mode in Obsidian — single tool, 5-edit burst, sub-agent invocation (e.g., `Task` from Claude Code backend), reasoning-only turn, mixed turn. Use the Obsidian CLI (`obsidian dev:debug` / `dev:console`) to confirm the underlying parts and screenshot the rendered cards.
- **Regression**: existing flat-card layouts still render correctly when no nesting / grouping applies (single tool calls, plan proposals).
## Open questions
- Should the reasoning block live **inline among action cards** (as in the composition mockup), or always **at the top** of the assistant turn? Inline preserves chronological truth; top is calmer visually. Recommend inline; flag for review.
- Auto-collapse threshold for sub-agents — collapse on done always, or only when child count ≥ 3? Recommend always-collapse for consistency.
- Tool icon source — Lucide set, custom set, or per-backend overrides? Decide before implementation since it touches every card.

View file

@ -0,0 +1,433 @@
# Branch review guide — `zero/acp-test`
## Context
One week of vibe-coded work that adds **Agent Mode** to the plugin: a chat
surface that drives external coding agents (OpenCode, Codex, Claude). The
branch is large (160 files, +31k / 9k LOC across 42 commits) but the
architecture is clean — **six element types** with ESLint-enforced boundaries
(`src/agentMode/CLAUDE.md`, `.eslintrc` `boundaries/elements`).
This is a **review tour by architectural concern**, not by commit
chronology. Read in this order; each group is sized for one or two
sittings.
---
## Architecture in one diagram
```
┌──────────────────────────────────────────┐
│ Host plugin │
│ (main.ts, CopilotView, ChatInput) │
└────────────────────┬─────────────────────┘
│ only via @/agentMode barrel
┌──────────────────────────────────────────┐
│ ui/ │ GROUP 5
│ Backend-agnostic React; reads │
│ BackendDescriptor │
└─────────┬────────────────────────┬───────┘
│ runtime + types │ catalog
│ ▼
│ ┌─────────────────────────┐
│ │ backends/registry.ts │ GROUP 3
│ │ (its own element type) │
│ └────────────┬────────────┘
│ │
│ ▼
│ ┌─────────────────────────┐
│ │ backends/<id>/ │ GROUP 3
│ │ opencode, codex, claude│
│ │ + backends/_shared/ │
│ └────┬───────────────┬────┘
│ │ │
│ ┌──────────▼─────┐ ┌──────▼──────┐
│ │ acp/ │ │ sdk/ │ GROUP 2
│ │ JSON-RPC over │ │ In-process │
│ │ stdio │ │ Claude SDK │
│ └──────────┬─────┘ └──────┬──────┘
│ │ siblings — │
│ │ neither imports the other
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ session/ │ GROUP 1 + 4
│ The contract sink. Owns BackendProcess, │
│ BackendDescriptor, errors, debug sink. │
│ Also: AgentSession, message store, │
│ multi-tab manager, persistence, │
│ mode/effort/MCP/model adapters. │
│ Imports nothing from agentMode. │
└──────────────────────────────────────────┘
```
Dependency direction in one sentence: **all arrows point toward `session/`**.
It's a sink — every other element imports types from it; it imports nothing
back. `ui/` reaches the backend catalog via `registry` (carved out as its own
element type) so it can stay oblivious to which specific backends exist.
The **contract** that ties this all together (Group 1) lives in
`session/types.ts`, `session/errors.ts`, and `src/agentMode/CLAUDE.md`. Read
those first or nothing else will land cleanly.
A **vertical slice** (Group 6 — plan mode & permissions) crosses every
layer above; it's worth reading after you've seen the layers in
isolation.
---
## Group 1 — The contract (start here)
**Why first:** every other file conforms to interfaces declared here. If
you read these in any other order, you'll re-derive them yourself as you
go.
**Files (~700 lines total):**
- `src/agentMode/CLAUDE.md` — layer rules, descriptor surface, debug tips
(the spec; authoritative when this guide and it disagree)
- `src/agentMode/session/types.ts`**the central vocabulary**: the
`BackendProcess` interface, `BackendDescriptor`, `InstallState`, the event
union the session emits to the UI (532 lines)
- `src/agentMode/session/errors.ts``MethodUnsupportedError` and
`JSONRPC_METHOD_NOT_FOUND` (extracted out of types.ts so backends can
throw them without a circular import)
- `src/agentMode/session/debugSink.ts` — shared NDJSON frame sink fed by
both transport layers (`acp/debugTap.ts` and `sdk/sdkDebugTap.ts`)
- `src/agentMode/index.ts` — public surface (one barrel; nothing outside
agentMode is allowed to deep-import past this)
- `src/agentMode/backends/registry.ts` — the only file you edit when
adding a new backend (32 lines). Carved out as its own boundary element
so `ui/` can read the catalog without unlocking deep imports into
individual backends — this is why the element count is **six**, not five.
- `.eslintrc` — find the `boundaries/elements` and
`boundaries/dependencies` blocks; this is what enforces the layering at
lint time
**Validate understanding:** can you describe (a) what `BackendProcess`
exposes to `session/`, (b) what `BackendDescriptor` exposes to `ui/` and
`registry`, and (c) why those two interfaces are different?
---
## Group 2 — Agent transport (two runtimes, one contract)
**Why grouped together:** ACP and the Claude Agent SDK are the same
architectural layer — both translate "an external agent's event stream"
into the session's internal event vocabulary, and both implement
`BackendProcess`. They are **siblings: neither imports the other**, and
`session/` doesn't import either — `session/` consumes the `BackendProcess`
interface only. Reading them side-by-side is the fastest way to see what
the contract actually buys you.
**The shared shape both runtimes expose to `session/`:** the
`BackendProcess` interface — `start(prompt)`, `interrupt()`, `setModel()`,
`setPermissionMode()`, `close()`, plus a stream of `SessionNotification`
events.
### 2a. ACP runtime — JSON-RPC subprocess
`src/agentMode/acp/`
- `types.ts` — ACP wire-format types
- `AcpProcessManager.ts` — subprocess spawn / lifecycle
- `AcpBackendProcess.ts``BackendProcess` impl: JSON-RPC frame loop + ACP
event translation (446 lines, the central file)
- `VaultClient.ts` — host-side handler for the agent's `fs/*` requests
(this is how the agent reads/writes vault files via the host, not the
OS filesystem)
- `debugTap.ts` — opt-in JSON-RPC frame tap; writes into
`session/debugSink.ts` (the standalone `frameSink.ts` from earlier
iterations is gone — the sink lives in `session/` now)
- `nodeShebangPath.ts` — CLI shebang resolution
### 2b. Claude SDK runtime — in-process query iterator
`src/agentMode/sdk/`
- `ClaudeSdkBackendProcess.ts``BackendProcess` impl that wraps `query()`
from `@anthropic-ai/claude-agent-sdk` (636 lines, the central file)
- `sdkMessageTranslator.ts` — translates `SDKMessage` stream → the same
internal event vocabulary the ACP path uses (this is what lets
`session/` stay unchanged across both runtimes)
- `vaultMcpServer.ts` — in-process MCP server wrapping `app.vault.adapter`
so writes flow through Obsidian (file watchers, links, sync). The
SDK's built-in Read/Write/Edit are explicitly disallowed.
- `permissionBridge.ts` — translates the SDK's `canUseTool` callback into
the existing permission flow, including new `AskUserQuestion` handling
- `effortOption.ts` — resolves reasoning-effort options for the SDK
(consumed by `backends/claude/descriptor.ts`)
- `toolMeta.ts` — SDK-side tool metadata (icons / display strings)
- `sdkDebugTap.ts` — SDK-side frame tap; feeds `session/debugSink.ts` so
ACP and SDK turns appear in the same NDJSON file
(Note: `claudeBinaryResolver.ts` used to live here but moved to
`backends/claude/` — see Group 3.)
**Companion docs:**
- `designdocs/todo/CLAUDE_AGENT_SDK_MIGRATION.md` — capability diff and
why the SDK path was added alongside ACP rather than replacing it.
Read §1 (capability table) and §2 (target architecture).
**Validate:** turn on full ACP frame logging (Settings → Advanced), run a
turn on opencode and a turn on claude. The internal events emitted to
`session/` should look the same shape; only the inputs differ.
---
## Group 3 — Backend catalog (which agents exist, how they install)
**Why a separate group:** "how do we talk to an agent" (Group 2) and
"which specific agents do we ship" are different concerns. A backend
descriptor picks a transport, owns its install/binary story, exposes a
settings panel, and registers itself.
**The pattern (per backend):**
```
backends/<id>/
descriptor.ts — the BackendDescriptor export (settings glue,
install state, createBackendProcess factory)
<Id>Backend.ts — subprocess track only: implements the
AcpBackend helper interface used by
simpleBinaryBackendProcess (a thin wrapper).
SDK-track backends skip this — the descriptor's
createBackendProcess constructs the
BackendProcess directly.
<Id>InstallModal.tsx — onboarding UI (BYOK key, binary path)
<Id>SettingsPanel.tsx — settings-page panel
index.ts — re-exports the descriptor
```
**Files:**
- `src/agentMode/backends/registry.ts` — already read in Group 1
- `src/agentMode/backends/_shared/` — common scaffolding three of the
three backends use:
- `simpleBinaryBackend.ts` — boilerplate for "user installs a CLI, we
detect it and pass the path"
- `BinaryInstallContent.tsx` — install-modal body
- `SimpleBackendSettingsPanel.tsx` — settings-panel body
- `src/agentMode/backends/opencode/` — the **most complete example**;
read this first. Includes:
- `OpencodeBinaryManager.ts` (612 lines) — plugin-managed download +
extract + verify (this is the only backend with a fully managed
install)
- `platformResolver.ts` — picks the right tarball per OS/arch
- `src/agentMode/backends/codex/` — minimal example (no managed binary)
- `src/agentMode/backends/claude/` — uses Group 2b (`sdk/`) instead of
ACP, so it has no `<Id>Backend.ts`. Includes:
- `AskUserQuestionModal.tsx` — Claude-only multi-choice question modal
(a capability ACP didn't have)
- `claudeBinaryResolver.ts(+test)` — locates the user-installed
`claude` CLI across Volta / asdf / NVM / Homebrew / npm-global on
macOS / Linux / Windows. Lives here (not in `sdk/`) because
cross-platform CLI resolution is Claude-specific.
- `src/utils/detectBinary.ts` — generic binary-on-PATH detection used by
the simple backend scaffold
**Validate:** open Settings → Agents tab. Switch active backend. Each
backend should expose its own install state, settings panel, and BYOK
fields without any other code changing.
---
## Group 4 — Session core (state machine + adapters)
**Why grouped together:** the state machine and the adapters are tightly
coupled. The adapters exist _because_ the session has to present a
uniform model/mode/effort UX over backends that disagree on what those
mean. Reading the state machine in isolation makes the adapters look
incidental, and vice versa.
### 4a. State machine
- `src/agentMode/session/AgentSession.ts`**1301 lines, the heart**.
Owns: turn state, message queue, plan/permission resolvers, interrupt,
model/mode/effort changes, agent-supplied title, the event handler
switch. Skim once for public methods, then read each event handler in
sequence.
- `src/agentMode/session/AgentSessionManager.ts` — multi-tab orchestration
(779 lines): per-tab session creation, active-session selection,
cross-session lifecycle, persistence wiring
- `src/agentMode/session/AgentMessageStore.ts` — append-only store
- `src/agentMode/session/AgentChatUIState.ts` — React subscription bridge
- `src/agentMode/session/AgentChatBackend.ts` — small interface the
session uses to talk to its current backend
- `src/agentMode/session/AgentChatPersistenceManager.ts` — debounced
markdown auto-save, project-scoped file naming
- `src/agentMode/session/index.ts` — public surface for the layer
(The contract files — `types.ts`, `errors.ts`, `debugSink.ts` — are
already covered in Group 1.)
### 4b. Cross-cutting adapters (per-backend normalizers)
- `session/modeAdapter.ts` — canonical `default | plan | yolo` modes
mapped to per-backend strings
- `session/effortAdapter.ts` — reasoning effort levels per backend,
including the `modelId#effort` encoding
- `session/mcpResolver.ts` — user-configured MCP servers (stdio/http/sse)
- `session/AgentModelPreloader.ts` — kicks model lists at plugin load
- `session/modelEnable.ts` — per-backend model curation
- `session/backendMeta.ts`, `session/backendSettingsAccess.ts` — small
helpers
- `ui/useAgentModelPicker.ts` (445 lines) + `ui/agentModelPickerHelpers.ts`
— the UI hook that consumes the adapters; lives in `ui/` but is
conceptually part of this group
- `ui/McpServersPanel.tsx` — settings panel for `mcpResolver`
**Validate:** start two tabs on different backends, send messages in
both, change model mid-turn (it applies on next send), set per-backend
default mode, configure an MCP server. Reload the plugin — sessions
restore from disk.
---
## Group 5 — Chat surface UI (backend-agnostic)
**Why grouped:** all of `ui/` (minus the plan/permission vertical in
Group 6) is React rendering against the session layer. No file in here
imports from `acp/`, `sdk/`, or specific backends — only from `session/`
and `backends/registry.ts`. This is exactly what `.eslintrc`'s
`from: { type: "ui" }` rule allows; if you want to verify a specific
import, check that rule.
### 5a. Shell
- `ui/CopilotAgentView.tsx` — Obsidian view registration
- `ui/AgentModeChat.tsx` — top-level chat surface
- `ui/AgentChat.tsx` (511 lines) — main chat container
- `ui/AgentChatControls.tsx` — header controls
- `ui/AgentTabStrip.tsx` — multi-session tabs
- `ui/AgentModeStatus.tsx` — install / connection status
- `ui/AgentChatMessages.tsx` — message list
- `ui/AgentMarkdownText.tsx` — markdown rendering helper
### 5b. Action-card trail (most recent UI rewrite)
The chronological trail of tool calls + reasoning + text:
- `ui/agentTrail.ts` — chronological merge of text/tool/reasoning parts
- `ui/AgentTrailView.tsx` — renderer
- `ui/ActionCard.tsx`, `AggregateCard.tsx`, `SubAgentCard.tsx` — card
variants
- `ui/ReasoningBlock.tsx` + `components/chat-components/AgentReasoningBlock.tsx`
— reasoning timer
- `ui/toolSummaries.ts` (+ test) + `toolIcons.ts` — per-tool one-liners
- `ui/diffRender.ts` — edit-tool diff display
- `ui/vaultPath.ts` — vault-relative path display
**Validate:** run a multi-tool turn (read + edit + bash). The trail
should show one card per tool call, reasoning blocks interleaved
chronologically, and a live timer on in-flight reasoning.
---
## Group 6 — Plan mode & permissions (vertical slice across all layers)
**Why a separate group:** this is the trickiest interaction surface in
the whole feature, and it crosses every layer. Reading it as a vertical
shows you how transport / session / UI cooperate around a single user
flow. Save it for after you understand the layers in isolation.
**The flow:** agent emits a `plan` notification → session creates a plan
proposal → UI renders the floating card → user approves → session sends
an `ExitPlanMode` permission → plan transitions into apply.
**Files (cross-layer):**
Transport side (Group 2):
- ACP: the `requestPermission` RPC handler in
`acp/AcpBackendProcess.ts` and `permissionPrompter.ts`
- SDK: `sdk/permissionBridge.ts` (translates `canUseTool` callback,
including `AskUserQuestion`)
Session side (Group 4):
- The plan / permission slices of `session/AgentSession.ts` (search for
`plan`, `EnterPlanMode`, `ExitPlanMode`)
UI side:
- `ui/permissionPrompter.ts` — routing layer; special-cases
`ExitPlanMode` switch_mode
- `ui/AcpPermissionModal.tsx` — generic permission modal
- `ui/PlanProposalCard.tsx` — inline plan card
- `ui/PlanPreviewView.tsx` — full-screen plan preview (custom Obsidian
view registered in `main.ts`)
- `ui/planEntryStyles.ts`
- `backends/claude/AskUserQuestionModal.tsx` — Claude-only
multi-choice question modal
**Known landmines fixed in this branch:** ghost plan card on late
`tool_call_update` (`e5b1dcb`), markdown class on plan preview
(`060e7d5`).
**Validate:** trigger a multi-step task in plan mode. Card renders →
preview opens → approve → session transitions into apply. No ghost card
on late events.
---
## Group 7 — Host integration & settings (the outer seam)
**Why last:** these are the files outside `agentMode/` that hook the
feature into the plugin. Reading them last is correct; the layered
mental model has to be in your head first or you'll mistake plumbing for
architecture.
**Plugin entry & view registration:**
- `src/main.ts` (193-line diff) — `createAgentSessionManager()` call,
view registration (chat + plan-preview), mobile gate, lifecycle
- `src/components/CopilotView.tsx` — agent-mode chat-surface mounting
**Chat input integration:**
- `src/components/chat-components/ChatInput.tsx` — agent-mode-aware input
(queue messages while a turn runs, ESC to stop, Shift-Tab to cycle
modes)
- `src/components/chat-components/ChatModeInput.tsx` — extracted
agent-mode toggle wrapper
- `src/components/chat-components/ChatMessages.tsx`,
`AgentReasoningBlock.tsx`,
`BottomLoadingIndicator.tsx`,
`attachChatViewLayoutObservers.ts`
**Settings:**
- `src/settings/model.ts` — new `agentMode.*` settings tree (active
backend, per-backend slices, sticky model/mode/effort, MCP servers)
- `src/settings/v2/components/AgentSettings.tsx` — dedicated Agents tab
- `src/settings/v2/components/AdvancedSettings.tsx` — frame-log toggle
- `src/settings/v2/SettingsMainV2.tsx` — tab registration
- `src/components/agent/BinaryPathSetting.tsx` — generic binary-path
picker shared by backends
**Build / runtime shims (needed because the SDK assumes Node, but
Obsidian runs in Electron's renderer):**
- `nodeModuleShim.mjs`
- `scripts/patchRendererUnsafeUnref.js`
- `esbuild.config.mjs`
- `__mocks__/@anthropic-ai/`
- `typings/global.d.ts`
---
## Reading order at a glance
1. **Group 1 — Contract** (must be first)
2. **Group 2 — Transport: ACP + SDK side-by-side**
3. **Group 3 — Backend catalog**
4. **Group 4 — Session core + adapters**
5. **Group 5 — Chat UI**
6. **Group 6 — Plan/permission vertical**
7. **Group 7 — Host seam**
After Group 1 the order is mostly negotiable — Groups 2/3/4 can flex
based on what you want to validate first. But Group 6 should always come
after 2/4/5, and Group 7 should always come last.

View file

@ -0,0 +1,433 @@
# Settings Redesign PRD — Copilot for Obsidian
**Status:** Draft for design handoff
**Audience:** Senior product designer (Claude Design) doing IA work
**Out of scope:** Visual restyling, color/typography choices, chat UI redesign, settings storage schema
---
## 1. Context & Problem
Copilot for Obsidian currently exposes **~150 user-configurable settings** across 7 tabs (Basic, Model, QA, Command, Plus, Advanced, Agent). The information architecture has accumulated through feature additions rather than user-journey design. The result:
- Beginners encounter expert knobs (lexical search RAM limit, embedding batch size, partition count, auto-compact token threshold, agent backend internals) on the same page as their first decisions (default model, send shortcut). They get confused and bounce.
- Related settings live in different surfaces. API keys hide in a modal launched from Basic. Custom system prompts hide in Advanced. Plus features render conditionally inside a sub-component. Self-host/Miyo settings only appear after server-side eligibility checks pass.
- Conditional/nested settings have no discoverability. A user who isn't sure if a setting exists can't find out without trial and error.
- There's no settings search.
- Deprecated and unused settings (`autoIncludeTextSelection`, `chatNoteContextPath`, `inlineEditCommands`, `stream`) still ship in the schema.
**This redesign happens in parallel with the ACP-Centric Revamp** (see audit at `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md`), which collapses the four chat modes (Chat / Vault QA / Copilot Plus / Projects) into one chat surface with a single Agent Mode toggle, retires `@`-commands, converts custom prompts and custom commands into "skills," and adds a dedicated Agent Mode configuration tab. **This PRD describes the post-revamp world**, not today's state. Settings being dropped in the revamp are listed in §6 so the designer doesn't design around them.
### Success criteria
1. **Beginner finishes setup in <60 seconds.** API key in, default model picked, chat works.
2. **Power user can still find every knob.** No setting goes missing; advanced surfaces are reachable in ≤2 clicks from any entry.
3. **Agent Mode is a first-class section** with a clean per-backend pattern.
4. **Settings search exists** and reduces "where is X" support questions.
5. **Locked / eligibility-gated states are explicit** — never silently hidden.
---
## 2. Users & Audiences
Four tiers, used as labels throughout this doc. Use them to drive progressive disclosure.
| Tier | Description | What they configure |
| ---------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Beginner** | Just installed Copilot. Has an API key from one provider. Doesn't know what an embedding is. Wants chat to work. | API key, default model, where chat opens, send shortcut. |
| **Intermediate** | Several weeks in. Has a saved-conversations workflow. Uses vault QA. May have configured 23 models. | Save folders, conversation tags, filename templates, indexing strategy, embedding model, exclusions/inclusions, skills folder. |
| **Power user** | Multi-provider, custom models, self-hosts. Tunes rate limits and partition counts. Configures agent backends. | Performance tuning, custom model registration, Azure/Bedrock/proxies, agent backend binaries, MCP servers, self-host/Miyo. |
| **Developer** | Debugging the plugin or contributing. | Debug logs, raw ACP frame capture, log files, internal toggles. |
Each setting in §5 has a tier label.
---
## 3. Goals & Non-Goals
### Goals
- Progressive disclosure: essentials default-visible, advanced collapsed.
- A clear **quick-setup path** for first-run users.
- Settings search/filter across the entire surface.
- Distinct grouping: **credentials**, **default behavior**, **performance tuning**, **experimental**.
- Agent Mode promoted to a top-level section (per audit).
- All conditional/eligibility-gated settings have a visible "locked" state with a reason.
- A clear mobile story for desktop-only features.
### Non-Goals
- Changing the _meaning_ of any setting. This is IA only.
- Visual restyling beyond what IA decisions require.
- Modifying the underlying settings storage schema (`CopilotSettings` interface).
- Redesigning the chat UI, command palette, or modals.
- Adding new functionality. (Skills replace custom prompts/commands as part of the parallel ACP revamp — that work is upstream of this PRD; here we just need to know skills exist.)
---
## 4. Platform & Tier Constraints
The designer must respect these. They're not negotiable.
### Obsidian native settings shell
The plugin renders inside Obsidian's settings modal. Standard convention is a left-rail tab list with a scrollable right-pane content area. Redesigns can deviate, but they must still feel native to Obsidian — no full-bleed marketing pages, no animated backgrounds, no overlays that obscure the modal chrome. Width is constrained by the Obsidian modal (roughly 700900px effective).
### Mobile vs desktop
- **Agent Mode is desktop-only.** External ACP backends (opencode / claude-code / codex) require local binaries; mobile cannot run them. The legacy in-process agent is the only path on mobile.
- **Vault index can be disabled on mobile** (default on) to save battery/storage.
- The settings UI must degrade gracefully on Obsidian mobile (smaller width, touch targets). Desktop-only sections should be visible on mobile but show a "desktop only" state — don't hide them silently or users will think they don't exist.
### Plus / Believer / Self-host tiers
- **Free** — BYOK API keys, vault QA with own embeddings, basic agent (legacy).
- **Plus** — hosted models, web search, YouTube transcripts, PDF parsing, reranker, memory.
- **Believer** (lifetime supporter) — unlocks Self-host mode + Miyo (private indexing infra).
When a feature requires a tier the user doesn't have, **show the section with a locked state** and a one-line "what this does + how to unlock." Don't hide it silently.
### Eligibility-gated settings
Some panes (Self-host activation, Miyo enable) require live server-side validation. The 3-strike grace flow allows up to 3 successful validations to grant offline-permanent access. Designer needs a UI pattern for **"verified eligible"** vs **"checking…"** vs **"not eligible — here's why"** states.
### Settings that trigger expensive work
Some settings cause heavy side effects when changed. The IA must surface this **inline at the setting**, not in a separate "danger zone."
- Changing the embedding model → full vault reindex.
- Changing partition count → index rebuild.
- Toggling semantic search on/off → index state changes.
- Disabling index sync → moves index to/from `.obsidian` folder.
---
## 5. Settings Inventory — Post-Revamp State
Settings are grouped by **functional purpose**, not by current tab. The designer decides where each group lives in the new IA. Each setting has audience tier, default, and notes.
Tier shorthand: `B` Beginner · `I` Intermediate · `P` Power · `D` Developer.
### A. Account & Credentials
| Setting | Tier | Default | Notes |
| --------------------------------------------------------------------------- | ---- | ------- | --------------------------------------------------------------------------------------------------------- |
| Plus license key | B | empty | Validated online; success unlocks Plus features and triggers welcome modal. Drives visibility of group G. |
| OpenAI API key | B | empty | Most common; should be prominent. |
| Anthropic API key | B | empty | |
| Google (Gemini) API key | B | empty | |
| OpenRouter API key | I | empty | |
| Cohere API key | I | empty | Often used for embeddings/reranker. |
| Groq, Mistral, xAI, DeepSeek, SiliconFlow, HuggingFace API keys | I | empty | Long tail; collapse. |
| OpenAI org id | I | empty | |
| OpenAI proxy base URL | P | empty | Custom OpenAI-compatible endpoint. |
| Embedding proxy base URL | P | empty | Separate from chat proxy. |
| Azure OpenAI (api key, instance, deployment, version, embedding deployment) | P | empty | Five fields, only meaningful together. |
| AWS Bedrock (api key, region) | P | empty | Two fields, only meaningful together. |
| GitHub Copilot (access token, token, expiry) | P | empty | OAuth-driven; expiry auto-refreshed. |
**Today** these live in a separate modal (`ApiKeyDialog`) launched from Basic. Open question: should they stay modal or move inline into the IA?
### B. Default Chat Behavior
| Setting | Tier | Default | Notes |
| ------------------------------- | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Default chat model | B | `google/gemini-2.5-flash\|openrouterai` | The single most important setting. Drives 90% of UX. Picker reads from enabled chat-model registry (group D). |
| Send shortcut | B | Enter | Enter vs Shift+Enter. |
| Where chat opens | B | Sidebar view | Sidebar vs main editor. |
| Auto-add active note to context | B | on | Convenience. |
| Auto-add selection to context | B | off | |
| Pass markdown images | I | on | Only meaningful with multimodal models. |
| Show suggested prompts | B | on | UI affordance toggle. |
| Show relevant notes | B | on | UI affordance toggle. |
### C. Conversation Storage
| Setting | Tier | Default | Notes |
| ------------------------ | ---- | ------------------------------- | -------------------------------------------------------------------- |
| Autosave chat | B | on | Saves after each turn. |
| Save folder | B | `copilot/copilot-conversations` | |
| Default conversation tag | B | `copilot-conversation` | |
| Filename template | I | `{$date}_{$time}__{$topic}` | Variables: `{$date}`, `{$time}`, `{$topic}`. Must include all three. |
| AI-generated chat titles | I | on | When off, falls back to first ~10 words. |
| Chat history sort | B | recent | recent / created / name / manual. |
| Project list sort | B | recent | Same options. |
### D. Models — Chat & Embedding Registries
These are **CRUD surfaces**, not single-value settings. The IA must accommodate list/table views inside settings.
**Chat models registry** (I→P)
- 36+ built-in models pre-populated. Enable/disable per model. Drag-reorder. Add custom model (provider, name, model id, capabilities).
- Per-model capabilities: vision, reasoning.
- Per-model overrides (where supported): temperature, max tokens, reasoning effort, verbosity.
**Embedding models registry** (I→P)
- 12 built-in. Same enable/disable pattern.
- Selected embedding model is the one used for QA. **Changing it triggers full reindex** — surface inline.
**Conversation context** (cross-cutting)
- Context turns to include in chat history (I; default 15, range 150). 1 turn = user message + AI reply.
- Auto-compact threshold in tokens (P; default 128000, range 640001000000). When context exceeds this, older turns are summarized.
- Default reasoning effort (P; for o1-class models). Values: minimal / low / medium / high.
- Default verbosity (P; for GPT-5 class). Values: low / medium / high.
- Default temperature (P; default 0.1).
- Default max tokens (P; default 6000).
### E. Vault Search & Indexing
| Setting | Tier | Default | Notes |
| ----------------------------- | ---- | -------------- | ----------------------------------------------------------- |
| Semantic search enabled | B | off | When off: lexical-only. Toggling requires reindex. |
| Inline citations in responses | B | on | Experimental; doesn't work with all models. |
| Indexing strategy | I | on mode switch | never / on startup / on mode switch. Cost implications. |
| Max source chunks per QA call | I | 30 | Range 1128. More = slower + more context. |
| QA inclusions | I | empty | Folder/tag/title patterns. Empty = index everything. |
| QA exclusions | I | `copilot` | Always includes copilot folder. |
| Index sync via Obsidian Sync | I | on | When off: index goes to `.copilot/` instead of `.obsidian`. |
| Disable index on mobile | B | on | QA modes unavailable on mobile when on. |
**Performance tuning (collapse by default, all P)**
- Embedding requests per minute (default 60, range 1060).
- Embedding batch size (default 16, range 1128).
- Index partition count (default 1; values 1, 2, 4, 8, 16, 32, 40). For large vaults. Requires rebuild on change.
- Lexical search RAM limit MB (default 100, range 201000).
- Lexical boosts enabled (default on). Folder/graph relevance boosts.
**Index management actions** (I) — these are buttons, not toggles. The IA needs to show actions inside a settings page gracefully.
- Rebuild index
- Force reindex
- Clear index
- Garbage-collect index
- List indexed files
- Inspect file by path
- Clear index cache
### F. Agent Mode (NEW dedicated top-level section per audit)
**Desktop-only.** On mobile, show the section with a "desktop only" explainer.
| Setting | Tier | Default | Notes |
| -------------------- | ---- | -------- | ------------------------------------------ |
| Master enable | P | off | Toggles entire agent UI. |
| Active backend | P | opencode | opencode / claude-code / codex. |
| Max agent iterations | P | 4 | Range 416. |
| Auto-accept edits | P | off | When on, file edits apply without preview. |
| Diff view mode | P | split | side-by-side / split. |
**Per-backend slice** — repeat this pattern for each of the 3 backends. Designer needs a clean repeating layout pattern.
- Binary path (string).
- Binary version (read-only, detected).
- Binary source: managed / custom (radio).
- Selected model key (dropdown from probe).
- Selected effort/reasoning level (where applicable; e.g., codex uses `gpt-5-codex/high`).
- Selected operational mode: default / plan / auto.
- Per-model enable/disable overrides (table or list — power users curate which models appear in the picker).
**Tools allowlist** (I) — checkbox list of tools the agent may use:
- localSearch, readNote, webSearch, pomodoro, youtubeTranscription, writeFile, editFile, updateMemory.
**MCP servers** (P) — list/CRUD surface. Each entry has: name, command/URL, args, env vars, enabled. Designer needs a list-with-edit-form pattern.
### G. Plus & Self-Host (gated)
Show as locked section when license tier is insufficient. Show eligibility-pending state when validation is in flight.
**Plus features** (require Plus license)
- Self-host search provider (P): Firecrawl / Perplexity.
- Firecrawl API key (P).
- Perplexity API key (P).
- Supadata API key (P) — for YouTube transcripts.
**Memory system** (I, experimental, Plus)
- Memory folder (default `copilot/memory`).
- Recent conversations enabled (default on).
- Max recent conversations (default 30, range 1050).
- Saved memory enabled (default on).
**Document processor** (I, Plus)
- Converted document output folder (string; empty = don't save converted markdown).
**Self-host mode** (Believer-only — eligibility-gated)
- Self-host mode enabled (P; default off). Requires Believer license + 15-day re-validation. After 3 successful validations, becomes offline-permanent.
- Self-host backend URL (P).
- Self-host API key (P).
**Miyo (private index)** — requires self-host mode
- Miyo enabled (P; default off).
- Miyo server URL (P; empty = local discovery).
- Miyo search-all (P; default off — when off, limited to current vault folder).
### H. Skills & System Prompts
Per the ACP audit, **custom prompts and custom commands collapse into "skills."** Skills are file-based — each skill is a markdown file in a configured folder. The settings UI mostly governs _where they live_ and _how they're sorted_; the skills themselves are managed in a CRUD surface.
| Setting | Tier | Default | Notes |
| ---------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills folder | I | `copilot/copilot-custom-prompts` | Auto-loads `.md` files. |
| Default system prompt | P | empty | File name from custom system prompts folder. Empty = built-in. |
| Custom system prompts folder | P | `copilot/system-prompts` | |
| Skill sort | B | timestamp | timestamp / alphabetical / manual (drag). |
| Template variables enabled | I | on | When off: raw prompt text. Variables: `{activeNote}`, `{[[Note]]}`, `{#tag}`, `{folder/}`. |
The skills _list_ itself (CRUD: create, edit, reorder, delete, toggle visibility in slash menu, toggle visibility in context menu) is the larger surface and the IA must accommodate it.
### I. Projects (referenced, not configured here)
Projects live in the chat sidebar, not the settings page. Mentioned because the designer should know:
- Per-project: system prompt, vault scope (folders/tag patterns), web/YouTube sources, context cache.
- **Per-project model and parameters are dropped in the revamp** — global default applies.
### J. System & Privacy
| Setting | Tier | Default | Notes |
| ------------------------ | ---- | ------- | -------------------------------------------------------------------------------------- |
| Encrypt API keys at rest | P | off | Encryption-at-rest for stored credentials. |
| Debug logging | D | off | Console + log file. Performance impact. |
| ACP raw frame logging | D | off | `agentMode.debugFullFrames`. Writes NDJSON to disk; sensitive data. Surface a warning. |
| Open log file | D | action | Button. |
| User ID | D | UUID | Read-only, auto-generated. Analytics. |
---
## 6. Removed / Deprecated (don't design around these)
These existed in the current schema but go away in the post-revamp world (per audit and cleanup pass). The designer should not surface them.
- **`defaultChainType`** — the four chat modes (Chat / Vault QA / Copilot Plus / Projects) collapse into one chat surface.
- **`@`-command syntax for tools** (`@vault`, `@web`, `@memory`) — agent calls tools directly. `@`-mention for notes/folders/tags/URLs _stays_.
- **Per-project model and parameters** — global default applies.
- **`inlineEditCommands`** — legacy command format, migrated to file-based skills.
- **`autoIncludeTextSelection`** — renamed to `autoAddSelectionToContext`.
- **`chatNoteContextPath`, `chatNoteContextTags`** — unused.
- **`stream`** — hard-coded true; not a user choice.
- **YouTube transcript modal** — dropped.
- **Agent-tool exposure of file parsing** (PDFs, images, YouTube, web URLs) — backends handle these natively.
---
## 7. New (introduced by revamp)
- **Agent Mode tab** with per-backend configuration (binary path, model, mode, model-enable overrides). See §5.F.
- **MCP server management** as a list/CRUD surface inside Agent Mode.
- **Skills** consolidate custom prompts + custom commands into one file-based system.
- **Backend session id** persisted per chat (no setting; storage detail).
- **Eligibility-gated panes** (Self-host, Miyo) with three states: not eligible, checking, verified.
- **Settings search** (new affordance — required by this redesign).
---
## 8. IA Principles (asks for the design)
1. **Quick-setup first.** New user lands on a screen that gets them to a working chat in under a minute: pick a provider, paste an API key, pick a default model. Optional: Plus license. Everything else is reachable but not in their face.
2. **Progressive disclosure.** Each section has an essentials view by default; "advanced," "performance," and "experimental" knobs collapse behind a disclosure toggle. Beginner labels never appear next to expert knobs without a separator.
3. **Settings search.** Inventory is too large to navigate by tab alone. Search/filter must work across all settings (label, description, alias).
4. **Audience tiering visible.** Power-user knobs visually distinct (subdued, badged, or grouped under "Advanced"). Beginners should see "this is normal to skip."
5. **Action surfaces inside settings.** Index rebuild, log file open, license validation, eligibility re-check are buttons, not toggles. The IA must accommodate them gracefully — don't force them into "ghost toggles."
6. **No silent hiding.** When a setting is gated by tier or eligibility, render it with a locked / not-eligible / desktop-only state. Show what unlocks it.
7. **Inline side-effect warnings.** Settings that trigger reindex / migration / restart announce that _at the setting_, not in a separate "danger zone."
8. **Mobile-aware.** Desktop-only sections (Agent Mode, parts of Self-host) need a clear story on mobile.
9. **CRUD surfaces are first-class.** Model registries, skills list, MCP servers — each is a list with rows, edit forms, and reordering. The IA pattern for "settings page that's mostly a list" should be repeatable and consistent across these three.
10. **Repeatable patterns over snowflakes.** The 3 agent backends share structure → use one component pattern. The 19 BYOK providers share structure → use one component pattern. Avoid bespoke layouts per provider/backend.
---
## 9. Open Questions for the Designer
1. **API keys: modal or inline?** Today they're behind a modal. Should they live inline in the new IA, in a dedicated "Credentials" section, or stay modal-launched from a single "Manage API keys" entry?
2. **Top-level shape:** tabbed left-rail (current), single-page-with-anchors, sidebar-tree, or hybrid? Which fits Obsidian's settings shell best given the inventory size?
3. **Model registry placement:** inside settings as a CRUD list, or in a dedicated "Models" management view reachable from settings?
4. **Skills placement:** same question — settings or dedicated management view? Skills also surface in chat (slash menu) which complicates ownership.
5. **Inline reindex warnings:** what's the right pattern? Inline banner under the setting? Confirmation modal on save? Toast after save?
6. **Plus / Self-host / Miyo grouping:** keep separate sections with their own gating, or merge into one "Cloud & self-host" group with sub-states?
7. **Locked states:** badge, disabled-with-tooltip, expandable upsell card, or something else?
8. **Search UX:** persistent search bar, command-palette-style overlay, or filter chips by audience tier?
9. **"Quick setup" path:** is it a separate screen on first run, a pinned section at the top of settings, or integrated into the chat onboarding flow?
10. **Settings density on mobile:** same layout collapsed, or a fundamentally different mobile IA?
---
## 10. Briefing Prompt for Claude Design
Paste the prompt below into a fresh Claude Design conversation alongside this document.
---
> # Brief: IA Redesign for Copilot for Obsidian Settings
>
> You are a senior product designer being asked to propose an **information architecture** redesign for the settings UI of _Copilot for Obsidian_, a popular Obsidian plugin that integrates LLM chat, vault Q&A, and an agent mode.
>
> ## What you're working from
>
> The accompanying document `SETTINGS_REDESIGN_PRD.md` is the spec. It contains:
>
> - The user audiences (Beginner / Intermediate / Power / Developer) with examples.
> - A complete inventory of every setting that exists in the post-revamp world, grouped by _functional purpose_ (not current tabs), with audience tier and notes for each.
> - Platform constraints (Obsidian's settings modal, mobile vs desktop, Plus/Believer tiers, eligibility-gated settings).
> - 10 IA principles to design against.
> - 10 open questions you're expected to engage with.
> - A list of removed/deprecated settings (don't design around those).
>
> Read it carefully before proposing anything.
>
> ## What I need from you
>
> 1. **Top-level IA proposal.** What are the top-level sections? In what order? Why?
> 2. **Section-by-section breakdown.** For each top-level section, list its sub-groups and which settings (from the PRD inventory) live there. Use the PRD's setting names verbatim so I can map back.
> 3. **Progressive disclosure pattern.** Concrete pattern for how essentials show by default and how advanced/performance/experimental knobs collapse. Show the same pattern applied to two different sections so I can see it's repeatable.
> 4. **Settings search behavior.** Where it lives, what it searches, what filters it offers, what happens when a setting is in a collapsed group or a gated section.
> 5. **Locked / eligibility states.** Visual treatment pattern for: not-Plus, not-Believer, eligibility-checking, eligibility-failed, desktop-only. Show one example per state.
> 6. **CRUD surface pattern.** A single repeatable pattern for "settings page that's mostly a list" — applied consistently to the three lists (chat models registry, skills, MCP servers). Show the pattern once and note any per-list deviations.
> 7. **Per-backend pattern (Agent Mode).** A repeatable layout for the three agent backends (opencode, claude-code, codex), since their settings share structure.
> 8. **Mobile fallback.** What happens on Obsidian mobile? Specifically, how Agent Mode appears.
> 9. **Quick-setup path.** How does a brand-new user get from "just installed" to "chat works" in under 60 seconds? Where does this flow live?
> 10. **Two layout sketches.** One desktop, one mobile. ASCII or markdown is fine. Show one section at depth — preferably the busiest one (Agent Mode or Vault Search & Indexing) — so I can see how density and disclosure feel.
>
> ## Engage with the open questions
>
> The PRD has 10 open questions in §9. Pick a position on each and explain your reasoning briefly. Don't punt.
>
> ## What you can change
>
> - You may **rename** settings if a current name is unclear (note the rename and why).
> - You may **merge** settings if two are conceptually one (note what you merged).
> - You may **split** a setting if one knob is doing two jobs.
> - You may **reorder** anything in the inventory.
> - You may propose **new affordances** like inline help, contextual tooltips, examples — as long as they serve IA goals.
>
> ## What you cannot change
>
> - Don't propose new settings or remove existing ones (the inventory is fixed by the parallel ACP revamp).
> - Don't propose visual style choices (colors, typography, iconography). This is IA only.
> - Don't redesign the chat UI or any modal flow — only the settings surface.
>
> ## How to deliver
>
> One markdown response. Use headings, lists, and ASCII/markdown sketches where helpful. If you need to ask clarifying questions before committing to a structure, ask them first — I'd rather answer 3 good questions than receive a guess.
>
> Be opinionated. I want a designer's point of view, not a survey of options. Where you make a judgment call, name the trade-off you accepted.
---
## Appendix: Reference files
For implementers (not the designer):
- [src/settings/model.ts](../src/settings/model.ts) — `CopilotSettings` interface; source of truth for what exists today.
- [src/constants.ts](../src/constants.ts) — `DEFAULT_SETTINGS` defaults.
- [src/settings/v2/SettingsMainV2.tsx](../src/settings/v2/SettingsMainV2.tsx) — current tab container.
- [src/settings/v2/components/](../src/settings/v2/components/) — current per-tab components.
- `~/Developer/zeroliu_second_brain/notes/Copilot Feature Audit for ACP - Centric Revamp.md` — source of truth for the post-revamp world.

File diff suppressed because it is too large Load diff

View file

@ -572,6 +572,10 @@ The following items are intentionally deferred from MVP and should be revisited
- Decide vault-relative only skills folders vs external/absolute path support.
9. Advanced UI polish (end-of-project nits)
- Improve the advanced-section UI in agent mode settings. The current button styling looks rough; revisit visual treatment, spacing, and affordance once functionality is locked in. Bundle with other end-of-project polish passes.
## 14. Acceptance Criteria
- Agent mode can run Claude Code, Codex, OpenCode.

View file

@ -0,0 +1,107 @@
# ACP Agent Mode TODOs
- P0: Chat history
- [x] Load chat history from agents
- [x] Save chat history to notes
- [x] P0: Thoroughly assess whether migrating to Anthropic agent SDK is worth it.
- [ ] P0: [Bug] effort is not passed to claude code correctly on first load if not making any changes
- [ ] P0: Test Windows devices
- [ ] P0: Make sure bash command shows what command it runs (visibility)
- [ ] P0: Provide copilot specific system prompt
- [ ] Allow users to share system prompt
- [ ] Do a quick spike on the concrete behavior
- [ ] Investigate opencode provider specific prompt
- [ ] P0: Skills
- [x] Check out cc-switch to understand how to make skills compatible cross other agents https://github.com/farion1231/cc-switch
- [ ] P0: Permission management
- [ ] Permission UI improvement
- [ ] Permission "always allow" doesn't seem to persist
- [ ] P0: How to design the settings to configure the provider?
- [ ] Redesign the model settings - discussed on May 7 group meeting
- [ ] support self host model
- [ ] remove built-in models
- [ ] P0: Fix broken legacy agent mode
- [ ] Can we only support basic chat - not now but eventually yes
- [ ] P0: Auto-save chat history controls
- [ ] P0: Support image context
- [ ] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist
- [ ] P1: MCP
- Basic functionality is ready
- [ ] P1: Surface externally-managed MCP servers (claude.ai remote, plugin-provided) — see [MCP_EXTERNALLY_MANAGED_SERVERS.md](./MCP_EXTERNALLY_MANAGED_SERVERS.md)
- [ ] P1: Support oauth for MCP servers (the one example that I tested didn't work)
- [ ] P1: Support setting MCP by copy pasting JSON blobs
- [ ] P1: Content type support (image, audio) - https://agentclientprotocol.com/protocol/content
- [ ] P1: Edit diff UI - https://agentclientprotocol.com/protocol/tool-calls#diffs
- The edit diff should be based on well rendered markdown, not raw markdown file. For example, table is impossible to understand the diff with the raw format
- [ ] P1: Thoroughly test opencode, codex, and claude code with different test cases
- [ ] Create sample vaults for test cases.
- [ ] P1: Fix session title for claude code agent
- [ ] P1: Agent upgrade detection and helper UI in settings
- [ ] P1: Forward web-source context to the agent
- The agent should be able to access the content of the rendered tab
- Right-click "Add to Copilot context" excerpts from web tabs and the
"include active web tab" toggle currently surface a Notice and are
dropped before the prompt is built. Wire them into the
`<copilot-context>` envelope (e.g. a `Web excerpts:` section with
`title (url): content`) so the agent can actually read them.
- [ ] P1: Token counter
- To know how many context is left in the current session
- Nice-to-have: Cost estimate
- [ ] P1: Integrate copilot plus tool calls (convert them to skills)
- [ ] vault search (make it work with Miyo)
- may want to rename
- challenge - how to enable it in an agent
- challenge - agentic search often is better than RAG, how does the agent know when to trigger it
- we don't need index-free search
- [ ] web search (paid feature only)
- [ ] ~~edit~~
- [ ] youtube transcription (paid feature only)
- [ ] obsidian CLI
- [ ] P1: Opencode plan mode fine-tuning
- [ ] P1: compaction
- [ ] manual trigger
- [ ] configure when to auto compact
- [ ] P1: Project mode
- [ ] P2: Claude vscode plugin add comment to plan capability
- It makes iterating on plan a lot easier
- [ ] P2: [UX] Make mode more obvious
- idea: consider change the chat border color for different modes
- [ ] P2: [UX] fix the brief moment of "Read Read" tool call message
- [ ] P2: Edit previous user message
- [ ] P2: New agent command (/new, /usage)
- [ ] P2: Claude code / Codex authentication
- [ ] P2: Steering conversation (instead of queue)
- [ ] P2: Rollback everything to the state of previous message
- [ ] P3: Rerun agent response
- [ ] P3: Agent todo list
- [ ] make sure in case it renders, it won't be buggy
- [x] P1: Queue messages
- [x] P1: Only include the provided models
- hide openrouter models behind a modal selector
- [x] P2: [UX] Thought for x second shows 0 second
- [x] P2: Subagent nested tool calls
- [x] P2: Keyboard shortcut
- [x] P1: Model, effort, and mode is not persisted across sessions
- [x] P2: Rebuild agent session tabs and right click context menu
- [x] P2: Add agent brand indicator in chat
- so user can know which agent harness they are interacting with
- [x] P2: Rebrand chat send button
- Make it a send icon to save space and get rid of "chat" label which no longer applies
- [x] P1: Merge copilot models with opencode models
- [x] P1: Agent effort selector
- [x] P1: Codex support
- [x] P1: Cancel chat
- [x] P1: Clean up opencode model list (maybe it's related to the "effort" feature)
- [x] P1: Clicking new chat should reset the tab label
- [x] P2: Agent survey (asking for user input)
- ~~Not possible with ACP, need more digging~~ now possible after migrating to agent SDK
- Need to convert the UI to inline card in chat
- [x] P1: Agent message is not rendered in the correct order with the tool calls
- [x] P1: Plan mode preview display
- [x] P1: Support note context input
- [[note]], the "+ Note" picker, "include active note", and right-click
"Add to Copilot context" now forward vault-relative paths / inlined
excerpts in a `<copilot-context>` envelope so the agent's Read tool
can fetch them via `VaultClient.readTextFile`.
- [x] P1: Agent mode selector (yolo, plan, safe) - https://agentclientprotocol.com/protocol/session-config-options
- [x] P1: Basic functionality is added but doesn't work well yet. Need thorough test.

View file

@ -0,0 +1,59 @@
# MCP servers managed outside the local config
## Problem
When the Claude Code backend is active, the running `claude` binary connects to MCP servers from at least three sources we do not control through `~/.claude.json` or `.mcp.json`:
1. **claude.ai-provisioned remote MCPs** — Gmail, Google Calendar, Google Drive, Slack, etc. Provisioned server-side per claude.ai account; the binary connects to them at startup over HTTPS.
2. **Plugin-provided MCPs** — e.g. `plugin:context7:context7`. Bundled by Claude Code plugins, not declared in any user-editable file.
3. **(Already covered by the main sync plan)** Local config MCPs in `~/.claude.json` and `.mcp.json`.
`claude mcp list` enumerates all three at runtime. `~/.claude.json` only contains category 3. The only local hint of category 1 is a `claudeAiMcpEverConnected: string[]` field, which is informational ("ever connected", not "currently active") and does not include category 2 at all.
### Why this is bad
If the obsidian-copilot MCP panel only shows local-config entries, a user who already has `claude.ai Gmail` connected may add their own `gmail` MCP through the panel. Both register tools under similar/overlapping namespaces; the agent calls become ambiguous, and the user has no signal that the conflict exists.
We've already locked down "extra registration via ACP" by passing `mcpServers: []` when the backend owns runtime registration (see `MCP_BACKEND_SYNC` plan). That stops obsidian-copilot from causing duplicates itself, but it does not prevent the user from creating a duplicate **inside** the local config that collides with a remote claude.ai MCP.
## What we cannot do
- **Disable claude.ai MCPs locally.** No CLI flag, no env var, no config key. Only off-switch is at claude.ai/settings → Connectors (web) or signing the connector out.
- **Disable plugin MCPs without disabling the plugin.** No per-MCP toggle.
- **Get a structured (JSON) list from `claude mcp list`.** Output is human-formatted; parsing is brittle and version-coupled.
Treating these as user-editable is therefore not on the table. The only available shape is **read-only awareness**.
## Options
| Option | Effect | Cost |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **A. Surface via `claude mcp list`** | Shell out, parse output, render claude.ai/_ and plugin/_ entries as read-only "Managed externally" rows alongside editable local ones. Solves "I don't see it". | Brittle parsing; spawns `claude` (~500ms1s) on panel mount. Output format may change between Claude Code versions. |
| **B. Static informational note** | Show "Claude Code may connect to remote claude.ai MCPs (Gmail, Calendar, etc.) that aren't editable here. Manage them at claude.ai/settings → Connectors." Just text, no enumeration. | Cheap. Doesn't tell the user _which_ servers, so they can still duplicate by name. |
| **C. Hybrid: warn on name conflict** | Pre-fill known claude.ai MCP names (gmail, calendar, slack, drive — small static list) and warn if user adds a server matching a known remote. | Heuristic; misses unknown remote MCPs and any future additions. |
| **D. Do nothing** | Status quo. User experiences conflict, debugs, learns. | Bad UX. |
### Recommendation
**A + B combined.** Shell out to `claude mcp list` once when the panel mounts, render claude.ai/_ and plugin/_ entries as read-only rows tagged "Managed by claude.ai" / "Managed by plugin", with a static note pointing to claude.ai/settings → Connectors. This is exactly what the user already sees in the terminal, lifted into Obsidian. Cache the result for the lifetime of the panel mount; add a manual "Refresh" button to reread.
Risks to mitigate before implementation:
- Parser must tolerate format changes — fail closed (omit external rows + log warning), never crash the panel.
- The shell-out must be debounced and cancellable; don't block initial render.
- If `claude` is not on PATH at panel mount (binary configured via custom path only), fall back to invoking via the configured `binaryPath` if the same binary supports `mcp list` (it does — same binary).
## Open questions
1. **Render claude.ai/plugin entries inline, or behind a "show external MCPs" disclosure toggle?** Inline is more discoverable; toggle keeps the panel uncluttered for users who never touch external MCPs.
2. **Refresh cadence:** mount-only + manual button, or also re-fetch on `subscribe()` callback from the local-config file watch (since external state can change without local file changes)?
3. **Backend coverage:** does the same problem exist for OpenCode / future Codex backend? If so, the read-only surface should live on `McpStorageAdapter` (`listExternal()` method) rather than be Claude-Code specific.
## Out of scope
- Any attempt to disable, mute, or hide claude.ai MCPs at runtime. The user must do that through claude.ai web settings; we can only point them there.
- Forking or patching `claude-agent-acp`.
## Related
- Main sync plan: see [`AGENT_MODE_TODOS.md`](./AGENT_MODE_TODOS.md) — _P1: Syncing and managing MCP registered in the backends_. This doc covers the externally-managed sub-case that the main sync plan deliberately does not address.

View file

@ -1,9 +1,35 @@
import esbuild from "esbuild";
import svgPlugin from "esbuild-plugin-svg";
import { transform as svgrTransform } from "@svgr/core";
import jsxPlugin from "@svgr/plugin-jsx";
import { readFile } from "node:fs/promises";
import process from "process";
import { createRequire } from "module";
import wasmPlugin from "./wasmPlugin.mjs";
import nodeModuleShim from "./nodeModuleShim.mjs";
// Inline SVGR plugin: each `import Foo from "./foo.svg"` resolves to a React
// component (`React.FC<SVGProps<SVGSVGElement>>`) instead of a raw string.
// Source SVGs use `fill="currentColor"`, so theme color follows automatically.
const svgrPlugin = {
name: "svgr",
setup(build) {
build.onLoad({ filter: /\.svg$/ }, async (args) => {
const svg = await readFile(args.path, "utf8");
const contents = await svgrTransform(
svg,
{ jsxRuntime: "classic", typescript: false, plugins: [jsxPlugin] },
{ filePath: args.path, caller: { name: "esbuild-plugin-inline-svgr" } }
);
return { contents, loader: "jsx" };
});
},
};
// CommonJS plugin loaded via createRequire — pure JS, no ESM export needed.
const patchRendererUnsafeUnref = createRequire(import.meta.url)(
"./scripts/patchRendererUnsafeUnref.js"
);
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
@ -39,14 +65,38 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
// Node.js built-in modules (available in Electron) - except node:module which we shim
// Node.js built-in modules (available in Electron) - except `module` /
// `node:module` which we shim. Both prefixed (`node:foo`) and bare
// (`foo`) forms are listed because @anthropic-ai/claude-agent-sdk and
// its transitive deps mix the two styles.
"node:fs",
"node:fs/promises",
"node:path",
"node:os",
"node:url",
"node:buffer",
"node:stream",
"node:crypto",
"node:events",
"node:async_hooks",
"node:child_process",
"node:http",
"node:https",
"node:util",
"node:readline",
"node:process",
"async_hooks",
"child_process",
"crypto",
"events",
"fs",
"fs/promises",
"os",
"path",
"process",
"readline",
"url",
"util",
],
format: "cjs",
target: "es2020",
@ -54,7 +104,7 @@ const context = await esbuild.context({
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
plugins: [nodeModuleShim, svgPlugin(), wasmPlugin],
plugins: [nodeModuleShim, svgrPlugin, wasmPlugin, patchRendererUnsafeUnref],
define: {
global: "window",
"process.env.NODE_ENV": prod ? '"production"' : '"development"',

View file

@ -2,18 +2,12 @@ import obsidianmd from "eslint-plugin-obsidianmd";
import eslintReact from "@eslint-react/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import tailwind from "eslint-plugin-tailwindcss";
import boundaries from "eslint-plugin-boundaries";
import globals from "globals";
export default [
{
ignores: [
"node_modules/**",
"main.js",
"styles.css",
"data.json",
"designdocs/**",
"docs/**",
],
ignores: ["node_modules/**", "main.js", "styles.css", "data.json", "designdocs/**", "docs/**"],
},
// obsidianmd recommended brings:
@ -119,16 +113,35 @@ export default [
},
},
// Guardrail: every standalone React root in the plugin must go through
// `createPluginRoot` so descendants can rely on `useApp()` unconditionally
// (the bug class fixed in PR #2466). Forbid importing `createRoot` from
// `react-dom/client` anywhere except the helper itself.
// Two AST-level import bans, combined in one block:
//
// 1. Parent-relative imports (`../foo`, `..`) — use the `@/` path alias
// instead. Survives file moves, keeps grep unambiguous, avoids long
// `../../../` chains. Same-directory `./foo` remains allowed.
//
// 2. `createRoot` from `react-dom/client` outside `createPluginRoot` —
// every standalone React root must go through that helper so descendants
// can rely on `useApp()` unconditionally (bug class fixed in PR #2466).
//
// Both selectors must live in the same block: flat config replaces (does
// not merge) rule values when the same rule key appears in multiple
// matching blocks, so splitting them would silently disable the earlier
// ban on every file the later block also matches.
//
// `createPluginRoot.tsx` is exempted via `ignores` — it owns `createRoot`,
// and has no parent imports today.
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/utils/react/createPluginRoot.tsx"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"ImportDeclaration[source.value=/^\\.\\.($|\\u002f)/], ImportExpression[source.value=/^\\.\\.($|\\u002f)/]",
message:
"Parent-relative imports (`../foo`) are banned. Use the `@/` path alias (e.g. `@/components/Foo`) instead.",
},
{
selector:
"ImportDeclaration[source.value='react-dom/client'] ImportSpecifier[imported.name='createRoot']",
@ -153,6 +166,10 @@ export default [
// Tests use intentional `any` mocks; disable type-safety rules that flood
// the test suite without adding signal.
"@typescript-eslint/no-unsafe-member-access": "off",
// Tests freely reach across layers and import ACP wire types directly to
// build fixtures; the layer enforcement only applies to production code.
"boundaries/dependencies": "off",
"no-restricted-imports": "off",
},
},
@ -196,6 +213,130 @@ export default [
},
},
// Agent Mode: backends spawn subprocesses (ACP) and the in-process Claude
// SDK uses node:async_hooks. The plugin runs in Electron renderer where
// these modules are available; the desktop-only Agent Mode is also gated by
// `Platform.isMobile` at runtime in main.ts.
// detectBinary / rendererEventsShim are sibling utilities pulled in by
// agent-mode wiring and share the same Electron-renderer assumptions.
{
files: ["src/agentMode/**", "src/utils/detectBinary.ts", "src/utils/rendererEventsShim.ts"],
rules: {
"import/no-nodejs-modules": "off",
},
},
// Element types (order matters — first match wins; files before folders):
// registry src/agentMode/backends/registry.ts (file)
// barrel src/agentMode/index.ts (file)
// session src/agentMode/session
// acp src/agentMode/acp
// sdk src/agentMode/sdk
// backend src/agentMode/backends/<name>
// ui src/agentMode/ui
// skills src/agentMode/skills
// host src/** (everything else under src/)
{
files: ["src/**/*.{ts,tsx,js,jsx}"],
plugins: { boundaries },
settings: {
// Required so `eslint-plugin-boundaries` can resolve `@/*` path aliases
// to their `src/*` targets.
"import/resolver": {
typescript: { project: "./tsconfig.json" },
node: true,
},
"boundaries/include": ["src/**/*"],
"boundaries/elements": [
{ type: "registry", pattern: "src/agentMode/backends/registry.ts", mode: "file" },
{ type: "barrel", pattern: "src/agentMode/index.ts", mode: "file" },
{ type: "session", pattern: "src/agentMode/session" },
{ type: "acp", pattern: "src/agentMode/acp" },
{ type: "sdk", pattern: "src/agentMode/sdk" },
{ type: "backend", pattern: "src/agentMode/backends/*", capture: ["name"] },
{ type: "ui", pattern: "src/agentMode/ui" },
{ type: "skills", pattern: "src/agentMode/skills" },
{ type: "host", pattern: "src/**" },
],
},
rules: {
"boundaries/dependencies": [
"error",
{
default: "disallow",
rules: [
{ from: { type: "session" }, allow: { to: { type: ["session", "host"] } } },
{ from: { type: "acp" }, allow: { to: { type: ["acp", "session", "host"] } } },
{ from: { type: "sdk" }, allow: { to: { type: ["sdk", "session", "host"] } } },
{
from: { type: "backend" },
allow: [
{ to: { type: ["acp", "sdk", "session", "skills", "host"] } },
{ to: { type: "backend", captured: { name: "{{from.captured.name}}" } } },
{ to: { type: "backend", captured: { name: "shared" } } },
],
},
{ from: { type: "registry" }, allow: { to: { type: ["backend", "session", "host"] } } },
{
from: { type: "ui" },
allow: {
to: { type: ["ui", "session", "registry", "skills", "host"] },
},
},
{
from: { type: "skills" },
allow: { to: { type: ["skills", "session", "host", "registry"] } },
},
{
from: { type: "barrel" },
allow: {
to: {
type: ["acp", "session", "sdk", "backend", "registry", "ui", "skills", "host"],
},
},
},
{ from: { type: "host" }, allow: { to: { type: ["host", "barrel"] } } },
],
},
],
},
},
// Re-disable boundaries/dependencies for tests — the block above otherwise
// re-enables the rule for test files via the broader `src/**` pattern.
{
files: ["**/*.test.{js,jsx,ts,tsx}", "jest.setup.js", "__mocks__/**"],
rules: {
"boundaries/dependencies": "off",
},
},
// Only acp/ may import `@agentclientprotocol/sdk`. Tests are exempted via
// the test block; acp/ itself is exempted below.
{
files: ["src/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@agentclientprotocol/sdk",
message:
"ACP wire types are confined to src/agentMode/acp/. session/, sdk/, ui/, backends/, and skills/ should depend on the session-domain types in @/agentMode/session/types instead. See src/agentMode/AGENTS.md.",
},
],
},
],
},
},
{
files: ["src/agentMode/acp/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": "off",
},
},
// TypeScript-specific overrides (the @typescript-eslint plugin is registered
// by obsidianmd's recommended config only for .ts/.tsx files).
{
@ -227,6 +368,20 @@ export default [
},
},
// Agent Mode tests use heavy `any` mocking for backend / SDK / ACP wire
// types whose real shapes are vendor-controlled and inconvenient to model
// in test scaffolding. Loosen the test-only unsafe rules for the
// agent-mode subtree only; production code stays enforced. Placed after the
// general TS block so it actually overrides.
{
files: ["src/agentMode/**/*.test.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
},
},
// Non-TS files aren't in tsconfig.json — disable type-aware rules that
// obsidianmd's recommended config enables globally. Most typed obsidianmd
// rules are already gated to **/*.ts(x); only no-plugin-as-component leaks
@ -249,7 +404,9 @@ export default [
"error",
{
presets: ["native", "microutilities", "preferred"],
allowed: [],
// dotenv is used only by integration tests to load .env.test;
// the native --env-file flag doesn't work in jest.
allowed: ["dotenv"],
},
],
},

View file

@ -6,12 +6,15 @@ module.exports = {
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest",
},
moduleNameMapper: {
"\\.svg$": "<rootDir>/__mocks__/svg.js",
"^@/(.*)$": "<rootDir>/src/$1",
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
// The yaml package's "exports" field defaults to a browser ESM entry under
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
// build it ships under dist/.
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
"^@agentclientprotocol/sdk$": "<rootDir>/__mocks__/@agentclientprotocol/sdk.js",
"^@anthropic-ai/claude-agent-sdk$": "<rootDir>/__mocks__/@anthropic-ai/claude-agent-sdk.js",
},
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],

View file

@ -2,8 +2,10 @@
const nodeModuleShim = {
name: "node-module-shim",
setup(build) {
// Intercept node:module imports and provide a shim
build.onResolve({ filter: /^node:module$/ }, (args) => {
// Intercept node:module / module imports and provide a shim. Both prefixed
// and bare forms are matched — @anthropic-ai/claude-agent-sdk imports the
// bare form, while @langchain/community uses node:module.
build.onResolve({ filter: /^(node:)?module$/ }, (args) => {
return {
path: args.path,
namespace: "node-module-shim",

2811
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,8 @@
"@google/generative-ai": "^0.24.0",
"@jest/globals": "^29.7.0",
"@langchain/ollama": "^1.2.2",
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/react": "^14.0.0",
"@types/diff": "^7.0.1",
@ -54,6 +56,8 @@
"esbuild": "^0.25.0",
"esbuild-plugin-svg": "^0.1.0",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-tailwindcss": "^3.18.0",
@ -64,7 +68,7 @@
"nano-staged": "^0.8.0",
"npm-run-all2": "^7.0.2",
"obsidian": "^1.2.5",
"prettier": "^3.3.3",
"prettier": "3.8.3",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"ts-jest": "^29.1.0",
@ -74,6 +78,8 @@
"yaml": "^2.6.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.20.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.126",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -107,19 +113,23 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^7.0.0",
"dotenv": "^17.4.2",
"fuzzysort": "^3.1.0",
"jotai": "^2.10.3",
"lexical": "^0.34.0",
"lucide-react": "^0.462.0",
"luxon": "^3.5.0",
"minisearch": "^7.2.0",
"openai": "^6.10.0",
"openai": "^6.34.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^3.0.2",
"tailwind-merge": "^2.5.5",
"turndown": "^7.2.2",
"uuid": "^11.1.0",
"zod": "^3.25.76"
"zod": "^4.4.2"
},
"overrides": {
"@browserbasehq/stagehand": "3.2.1"
}
}

View file

@ -0,0 +1,241 @@
/* eslint-disable @typescript-eslint/no-require-imports */
"use strict";
const fs = require("fs");
/**
* Esbuild plugin: rewrite every `setTimeout(...).unref()` site in the bundled
* output to `(t=>{t&&t.unref&&t.unref()})(setTimeout(...))`.
*
* Why: the `@anthropic-ai/claude-agent-sdk` process-transport-close path and
* the transitive `@modelcontextprotocol/sdk` stdio-close-wait both call
* `.unref()` on a `setTimeout` handle. In Electron's renderer process the
* timer object lacks `.unref()`, and calling it crashes the renderer at
* teardown. The wrapper preserves Node-side semantics and is a safe no-op
* in the renderer.
*
* Runs as an `onEnd` plugin so it sees the final, post-minify, on-disk
* output. After rewriting it re-scans the file and throws if any
* unmatched site remains, failing the build.
*/
const patchRendererUnsafeUnref = {
name: "patch-renderer-unsafe-unref",
setup(build) {
build.onEnd((result) => {
// Skip when the build itself failed — esbuild has already surfaced errors.
if (result.errors && result.errors.length > 0) return;
const outfile = build.initialOptions.outfile;
if (!outfile) {
throw new Error(
"[patch-renderer-unsafe-unref] expected build.initialOptions.outfile to be set"
);
}
let source;
try {
source = fs.readFileSync(outfile, "utf8");
} catch (err) {
throw new Error(`[patch-renderer-unsafe-unref] failed to read ${outfile}: ${err.message}`);
}
// The SDKs whose unsafe `.unref()` sites motivate this patch
// (@anthropic-ai/claude-agent-sdk, @modelcontextprotocol/sdk) only get
// bundled once host code actually imports them. Until that happens the
// bundle has no `setTimeout(...).unref()` sites — that's fine, the
// patch becomes a no-op. The marker probe below distinguishes that
// benign case from "SDK is bundled but the matcher failed."
const sdkBundled = source.includes("@anthropic-ai/claude-agent-sdk");
// Iterate: each pass finds all top-level `setTimeout(...).unref()`
// sites, rewrites them right-to-left, and re-scans. Nested sites
// (e.g. an inner `setTimeout(...).unref()` living inside an outer
// setTimeout's argument list) are shadowed by their outer site on
// any single pass, so without iteration the verifier complains they
// remain. Cap at a few passes so a matcher bug can't infinite-loop.
let out = source;
let totalRewritten = 0;
const MAX_PASSES = 8;
for (let pass = 0; pass < MAX_PASSES; pass++) {
const sites = findUnsafeSites(out);
if (sites.length === 0) break;
for (let i = sites.length - 1; i >= 0; i--) {
const { setTimeoutStart, setTimeoutEnd, fullEnd } = sites[i];
const setTimeoutCall = out.slice(setTimeoutStart, setTimeoutEnd);
const wrapped = `(t=>{t&&t.unref&&t.unref()})(${setTimeoutCall})`;
out = out.slice(0, setTimeoutStart) + wrapped + out.slice(fullEnd);
}
totalRewritten += sites.length;
}
if (totalRewritten === 0) {
if (sdkBundled) {
throw new Error(
"[patch-renderer-unsafe-unref] @anthropic-ai/claude-agent-sdk " +
"appears bundled but no `setTimeout(...).unref()` sites were " +
"found. The matcher likely missed them after a minifier change."
);
}
console.log("[patch-renderer-unsafe-unref] no sites to rewrite (SDK not bundled yet)");
return;
}
// Verify: re-scan the final rewritten output. Any remaining site means
// the scanner missed something or iteration didn't converge — fail
// loud.
const remaining = findUnsafeSites(out);
if (remaining.length > 0) {
throw new Error(
`[patch-renderer-unsafe-unref] verifier found ${remaining.length} ` +
"unsafe `setTimeout(...).unref()` site(s) still present after " +
"rewrite. First site near offset " +
remaining[0].setTimeoutStart +
"."
);
}
fs.writeFileSync(outfile, out, "utf8");
console.log(`[patch-renderer-unsafe-unref] rewrote ${totalRewritten} site(s)`);
});
},
};
/**
* Find every `setTimeout(<balanced>)\.unref\(\)` site in the source.
* Returns an array sorted by offset.
*
* The scanner is string-literal-aware (single, double, backtick, and
* line/block comments) so parentheses inside string contents don't
* unbalance the count. Regex literals are intentionally not handled
* minified output rarely emits regex literals adjacent to `(` of a call,
* and the SDK call sites are simple.
*/
function findUnsafeSites(source) {
const NEEDLE = "setTimeout(";
const sites = [];
let i = 0;
while (i <= source.length - NEEDLE.length) {
const at = source.indexOf(NEEDLE, i);
if (at === -1) break;
// Reject identifier-prefixed matches like `mySetTimeout(` — the char before
// must not be an identifier-continuation character or `.`.
if (at > 0) {
const prev = source.charCodeAt(at - 1);
const isIdentChar =
(prev >= 0x30 && prev <= 0x39) || // 0-9
(prev >= 0x41 && prev <= 0x5a) || // A-Z
(prev >= 0x61 && prev <= 0x7a) || // a-z
prev === 0x24 || // $
prev === 0x5f || // _
prev === 0x2e; // .
if (isIdentChar) {
i = at + 1;
continue;
}
}
const setTimeoutStart = at;
const argStart = at + NEEDLE.length;
const closeIdx = findMatchingParen(source, argStart);
if (closeIdx === -1) {
i = at + 1;
continue;
}
const setTimeoutEnd = closeIdx + 1; // one past the `)`
if (source.startsWith(".unref()", setTimeoutEnd)) {
sites.push({
setTimeoutStart,
setTimeoutEnd,
fullEnd: setTimeoutEnd + ".unref()".length,
});
i = setTimeoutEnd + ".unref()".length;
} else {
i = at + 1;
}
}
return sites;
}
/**
* Given a position immediately after an opening `(`, return the index of
* its matching `)`, accounting for nested parens, string literals
* (`'`, `"`, `` ` ``), and `// ` / `/* ... *\/` comments. Returns -1 if
* unbalanced.
*/
function findMatchingParen(source, start) {
let depth = 1;
let i = start;
while (i < source.length) {
const ch = source[i];
if (ch === "(") {
depth++;
i++;
continue;
}
if (ch === ")") {
depth--;
if (depth === 0) return i;
i++;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
i = skipString(source, i, ch);
continue;
}
if (ch === "/" && i + 1 < source.length) {
const next = source[i + 1];
if (next === "/") {
i = source.indexOf("\n", i + 2);
if (i === -1) return -1;
continue;
}
if (next === "*") {
const end = source.indexOf("*/", i + 2);
if (end === -1) return -1;
i = end + 2;
continue;
}
}
i++;
}
return -1;
}
/**
* Skip a string literal starting at `i` whose opening quote is `quote`.
* Returns the index just past the closing quote. Handles backslash escapes.
* For template literals, recursively skips `${...}` expressions.
*/
function skipString(source, i, quote) {
i++; // past opening quote
while (i < source.length) {
const ch = source[i];
if (ch === "\\") {
i += 2;
continue;
}
if (ch === quote) return i + 1;
if (quote === "`" && ch === "$" && source[i + 1] === "{") {
// Skip the `${ ... }` interpolation, balancing inner braces.
let depth = 1;
let j = i + 2;
while (j < source.length && depth > 0) {
const c = source[j];
if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === "'" || c === '"' || c === "`") {
j = skipString(source, j, c);
continue;
}
j++;
}
i = j;
continue;
}
i++;
}
return i;
}
module.exports = patchRendererUnsafeUnref;

View file

@ -2,7 +2,7 @@ import { ABORT_REASON, AI_SENDER } from "@/constants";
import { logError, logInfo } from "@/logger";
import { ChatMessage, ResponseMetadata } from "@/types/message";
import { err2String, formatDateTime } from "@/utils";
import ChainManager from "../chainManager";
import ChainManager from "@/LLMProviders/chainManager";
export interface ChainRunner {
run(

172
src/agentMode/AGENTS.md Normal file
View file

@ -0,0 +1,172 @@
# Agent Mode — layer rules
Six element types, strict imports. Enforced by `eslint-plugin-boundaries`
(see root `eslint.config.mjs`). The list below mirrors `boundaries/elements` and
`boundaries/dependencies` exactly — when in doubt, the lint config wins.
1. **`session/`** — **the contract layer.** Hosts the
backend-agnostic session, message store, UI-state bridge,
persistence manager.
2. **`acp/`** — the generic ACP runtime (subprocess, JSON-RPC connection,
vault MCP client, JSON-RPC stream tap) —
the only place that touches `@agentclientprotocol/sdk`. **All ACP knowledge is confined here.**
3. **`sdk/`** — in-process SDK adapters that implement
`BackendProcess` directly. Today
this hosts the Claude Agent SDK driver. **Does not import `acp/` or `@agentclientprotocol/sdk`**
`acp/` and `sdk/` are siblings, and the SDK adapter speaks the
session domain natively.
4. **`backends/<id>/`** — one backend per folder. Exports a
`BackendDescriptor` whose `createBackendProcess` returns a finished
`BackendProcess`.
5. **`backends/registry.ts`** — the only place that names every
backend.
6. **`ui/`** — backend-agnostic React UI. — permission modals, model pickers, and
trail rendering all read session-domain types;
7. **`skills/`** — canonical-store discovery, symlink lifecycle, reconciliation,
and the Skills settings UI.
## Why two adapters under one session
The contract lives in `session/types.ts` (`BackendProcess`,
`BackendDescriptor`, the full session-domain type set) and
`session/errors.ts` (`MethodUnsupportedError`). `acp/AcpBackendProcess`
wraps a JSON-RPC subprocess and translates ACP wire ↔ session-domain
at its public boundary (via `acp/wireTranslate.ts`);
`sdk/ClaudeSdkBackendProcess` wraps an in-process async generator and
emits session-domain events natively. Both produce the same
`SessionEvent` stream and `BackendState`, so `AgentSession` stays
oblivious. Crucially, **neither adapter imports the other**,
`session/` doesn't import either, and `@agentclientprotocol/sdk`
imports outside `acp/` are blocked by lint
(`no-restricted-imports`).
## Adding a new backend
Pick a track based on what the agent gives you:
- **Subprocess track** (codex, opencode) — the agent speaks ACP
over stdio. Implement `AcpBackend` in `Backend.ts` and have the
descriptor's `createBackendProcess(args)` call
`simpleBinaryBackendProcess(args, new <Id>Backend())` from
`backends/shared/simpleBinaryBackend.ts`. The helper wraps the
spawn descriptor in `AcpBackendProcess` for you.
- **In-process / SDK track** (claude) — the agent ships an
in-process SDK. Put the `BackendProcess` implementation in `sdk/`
if any logic is reusable (translator, debug tap, MCP shim) and
have the descriptor's `createBackendProcess(args)` construct it
directly.
Then in either case:
1. Create `backends/<id>/` with:
- `descriptor.ts``export const <Id>BackendDescriptor: BackendDescriptor = {…}`
- `index.ts` — re-exports the descriptor
- any backend-specific UI (install modal, settings panel,
permission modal) co-located here
- `Backend.ts` (subprocess track only)
2. Add the entry to `backends/registry.ts`.
3. Settings: store backend-specific config under `agentMode.backends.<id>`
(extend `CopilotSettings.agentMode.backends` in `src/settings/model.ts`).
4. Done. **No edits to `acp/`, `session/`, `sdk/`, or `ui/` should be
required.** If you need one, the boundary is leaking — extend the
descriptor surface instead.
## Adding a new layer
1. Create `src/agentMode/<layer>/`.
2. Add an entry under `boundaries/elements` in root `eslint.config.mjs` and a
corresponding rule in `boundaries/dependencies`.
3. Re-export from `src/agentMode/index.ts` if it should be visible to
plugin host code.
4. Update this doc.
## What lives where (cheatsheet)
- "Backend-agnostic contract — `BackendProcess`, `BackendDescriptor`,
the session-domain types, `MethodUnsupportedError`, debug sink,
`translateBackendState` helper" → `session/`
- "ACP wire types, JSON-RPC subprocess plumbing, vault MCP client,
ACP↔domain translators" → `acp/` (the **only** layer that imports
`@agentclientprotocol/sdk`)
- "In-process driver for an SDK that produces session-domain events
natively" → `sdk/`
- "Helper used by more than one backend" → `backends/shared/`
- "Knows about a specific binary, install path, BYOK keys, vendor models" → `backends/<id>/`
- "React component or modal" → `ui/` (generic) or `backends/<id>/`
(backend-specific)
- "Canonical-store skill discovery, symlink lifecycle, SKILL.md
parser/serializer, Skills settings tab (reads `backends/registry.ts`
for the brand list)" → `skills/`
- "Plugin-level wiring" → `index.ts` only
## Modals and dialogs
**Always prefer Obsidian's native `Modal`** (from `obsidian`) over the
Radix-based `Dialog` primitive in `@/components/ui/dialog`. The native
modal gives us correct popout-window behavior, native header chrome,
ESC handling, and visual consistency with the rest of the plugin.
The standard pattern (see `src/components/modals/ConfirmModal.tsx` and
`src/agentMode/skills/ui/DeleteConfirmDialog.tsx`):
Only reach for the Radix `Dialog` when the surface needs to live
_inside_ an existing React tree (e.g. nested inside another modal)
and spawning a separate Obsidian `Modal` would break the focus or
layout flow.
## BackendDescriptor surface
The descriptor is the contract `session/`, `sdk/`, and `ui/` rely on.
If a UI component needs something the descriptor doesn't expose, **add
it to the descriptor** — don't reach into a specific backend. The
descriptor will keep growing; that's by design.
## Debugging tips
### Inspect full Agent Mode frames
The default debug log truncates each frame's payload to 400 chars. For
diagnostic frame logs with larger payload summaries, turn on
**Settings → Advanced → Log Full Agent Mode Frames**.
When enabled, every parsed frame is appended as one NDJSON line outside the
vault, under the OS temp directory:
```text
<tmp>/obsidian-copilot/acp-frames/<vault-hash>/acp-frames.ndjson
```
Each line is a `FrameRecord` (`src/agentMode/session/debugSink.ts`):
```ts
{ ts, dir: "→" | "←", tag, kind: "request" | "notif" | "result" | "error" | "raw",
method, id, payload }
```
`dir` is from the plugin's perspective: `→` = sent to the agent,
`←` = received from the agent. `tag` is the backend id (e.g. `claude-sdk`,
`opencode`, `codex`). The ACP runtime (`acp/debugTap`) and the Claude SDK
adapter (`sdk/sdkDebugTap`) both feed the shared sink, so JSON-RPC and
SDK turns appear in the same file.
Useful queries:
```bash
LOG="<path shown in Settings Advanced Agent Mode Frame Log>"
# count frames by method
jq -r .method "$LOG" | sort | uniq -c | sort -rn
# inspect every session/update payload
jq -c 'select(.method=="session/update") | .payload' "$LOG"
# only frames for one backend
jq -c 'select(.tag=="claude-sdk")' "$LOG"
```
The file is append-only and bounded. Oversized individual frames are replaced
with a `__truncated` summary, and at 50 MB the file rotates to
`acp-frames.old.ndjson` (overwriting any prior `.old`) in the same temp
folder. Use the **Open** / **Clear** buttons in the same settings section, or
delete the files directly. Disable the toggle when not actively debugging.

1
src/agentMode/CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -0,0 +1,182 @@
import { FileSystemAdapter, App } from "obsidian";
import type { BackendDescriptor } from "@/agentMode/session/types";
import { AcpBackendProcess } from "./AcpBackendProcess";
import type { AcpBackend } from "./types";
import type { VaultClient } from "./VaultClient";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
const exitListeners = new Set<() => void>();
let mockProcessIsRunning = true;
jest.mock("./AcpProcessManager", () => ({
AcpProcessManager: jest.fn().mockImplementation(() => ({
start: () => ({
stdin: new WritableStream<Uint8Array>(),
stdout: new ReadableStream<Uint8Array>(),
}),
onExit: (fn: () => void) => {
exitListeners.add(fn);
return () => exitListeners.delete(fn);
},
isRunning: () => mockProcessIsRunning,
shutdown: jest.fn().mockResolvedValue(undefined),
})),
}));
function buildApp(basePath = "/vault"): App {
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath);
return { vault: { adapter } } as unknown as App;
}
function buildStubBackend(): AcpBackend {
return {
id: "opencode",
displayName: "opencode",
buildSpawnDescriptor: jest.fn().mockResolvedValue({
command: "/bin/true",
args: [],
env: {},
}),
};
}
function buildStubDescriptor(): BackendDescriptor {
return {
id: "opencode",
displayName: "opencode",
} as unknown as BackendDescriptor;
}
/**
* Pull the VaultClient that AcpBackendProcess wires into the mock
* ClientSideConnection. The mock stores the `toClient(this)` result on
* `_client`, which lets tests trigger routing/permission paths the same way
* the agent backend would.
*/
function getVaultClient(backend: AcpBackendProcess): VaultClient {
const connection = (backend as unknown as { connection: { _client: VaultClient } }).connection;
return connection._client;
}
describe("AcpBackendProcess", () => {
beforeEach(() => {
exitListeners.clear();
mockProcessIsRunning = true;
});
it("routes session updates to the matching session handler and drops unknown ones", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const handler = jest.fn();
backend.registerSessionHandler("session-known", handler);
const client = getVaultClient(backend);
const knownUpdate = {
sessionId: "session-known",
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } },
} as unknown as Parameters<typeof client.sessionUpdate>[0];
await client.sessionUpdate(knownUpdate);
// Handler is called with a SessionEvent (translated from the wire shape).
expect(handler).toHaveBeenCalledTimes(1);
const got = handler.mock.calls[0][0];
expect(got.sessionId).toBe("session-known");
expect(got.update.sessionUpdate).toBe("agent_message_chunk");
const strayUpdate = {
sessionId: "session-unknown",
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "x" } },
} as unknown as Parameters<typeof client.sessionUpdate>[0];
await expect(client.sessionUpdate(strayUpdate)).resolves.toBeUndefined();
expect(handler).toHaveBeenCalledTimes(1);
});
it("returns cancelled outcome when permission is requested but no prompter is registered", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const client = getVaultClient(backend);
const response = await client.requestPermission({
sessionId: "s1",
toolCall: {
toolCallId: "tc1",
title: "Run dangerous thing",
},
options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }],
} as unknown as Parameters<typeof client.requestPermission>[0]);
expect(response).toEqual({ outcome: { outcome: "cancelled" } });
});
it("delegates to the registered prompter and forwards the response", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const prompter = jest
.fn()
.mockResolvedValue({ outcome: { outcome: "selected", optionId: "ok" } });
backend.setPermissionPrompter(prompter);
const client = getVaultClient(backend);
const req = {
sessionId: "s1",
toolCall: { toolCallId: "tc1", title: "Read" },
options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }],
} as unknown as Parameters<typeof client.requestPermission>[0];
const response = await client.requestPermission(req);
expect(prompter).toHaveBeenCalledTimes(1);
// Prompter receives a session-domain `PermissionPrompt`.
const prompt = prompter.mock.calls[0][0];
expect(prompt.sessionId).toBe("s1");
expect(prompt.toolCall.toolCallId).toBe("tc1");
expect(response).toEqual({ outcome: { outcome: "selected", optionId: "ok" } });
});
it("clears connection state on subprocess exit so subsequent ops fail with a clear error", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await backend.start();
const handler = jest.fn();
backend.registerSessionHandler("s1", handler);
// Simulate the subprocess dying.
mockProcessIsRunning = false;
for (const fn of exitListeners) fn();
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/has exited/);
expect(backend.isRunning()).toBe(false);
});
it("throws if start() was never called", async () => {
const backend = new AcpBackendProcess(
buildApp(),
buildStubBackend(),
"1.0.0",
buildStubDescriptor()
);
await expect(backend.prompt({ sessionId: "s1", prompt: [] })).rejects.toThrow(/start\(\)/);
});
});

View file

@ -0,0 +1,549 @@
import { logError, logInfo, logWarn } from "@/logger";
import {
ClientSideConnection,
PROTOCOL_VERSION,
RequestError,
ndJsonStream,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionConfigOption,
type SessionId as AcpSessionId,
type SessionModeState,
type SessionModelState,
type SessionNotification,
} from "@agentclientprotocol/sdk";
import { App, FileSystemAdapter } from "obsidian";
import { AcpProcessManager, AcpProcessManagerOptions } from "./AcpProcessManager";
import { VaultClient } from "./VaultClient";
import { JSONRPC_METHOD_NOT_FOUND, MethodUnsupportedError } from "@/agentMode/session/errors";
import type {
BackendDescriptor,
BackendProcess,
BackendState,
CancelInput,
ListSessionsInput,
ListSessionsOutput,
LoadSessionInput,
LoadSessionOutput,
OpenSessionInput,
OpenSessionOutput,
PermissionDecision,
PermissionPrompt,
PromptInput,
PromptOutput,
ResumeSessionInput,
ResumeSessionOutput,
SessionId,
SessionUpdateHandler as DomainSessionUpdateHandler,
} from "@/agentMode/session/types";
import { wrapStreamsForDebug } from "./debugTap";
import { AcpBackend } from "./types";
import {
acpNotificationToEvent,
acpPermissionRequestToPrompt,
acpStateToBackendState,
cancelInputToAcp,
decisionToAcpResponse,
listedSessionFromAcp,
mcpServerSpecToAcp,
promptContentToAcp,
sessionIdFromAcp,
sessionIdToAcp,
stopReasonFromAcp,
} from "./wireTranslate";
/**
* Capabilities the agent may or may not implement. Tracked in a single Set so
* adding a new capability is one constant + one branch in the unsupported
* handler instead of touching reset / probe / getter sites.
*/
export type AcpCapability =
| "session/list"
| "session/resume"
| "session/load"
| "session/set_model"
| "session/set_mode"
| "session/set_config_option"
| "mcp/http"
| "mcp/sse";
/**
* Detect a JSON-RPC -32601 (method not found) error from the ACP SDK. The SDK
* surfaces these as `RequestError` instances; we also tolerate plain objects
* shaped like `{ code: number }` defensively.
*/
function isMethodNotFoundError(err: unknown): boolean {
if (err instanceof RequestError) return err.code === JSONRPC_METHOD_NOT_FOUND;
if (typeof err === "object" && err !== null && "code" in err) {
return err.code === JSONRPC_METHOD_NOT_FOUND;
}
return false;
}
const COPILOT_CLIENT_NAME = "obsidian-copilot";
/**
* Per-session bookkeeping for the latest known wire-shaped catalogs. We keep
* these so that mid-session `current_mode_update` / `config_option_update`
* notifications and per-dimension `setSession*` calls can produce a fresh
* `BackendState` without having to refetch from the agent.
*/
interface SessionWireState {
models: SessionModelState | null;
modes: SessionModeState | null;
configOptions: SessionConfigOption[] | null;
}
/**
* One-per-vault wrapper around an ACP-speaking subprocess. Owns the
* `ClientSideConnection`, the `AcpProcessManager`, and the demultiplexer
* that fans `session/update` notifications out to the right `AgentSession`.
*
* Lifecycle: `start()` exactly once, then any number of `newSession`/`prompt`
* calls, finally `shutdown()`. All sessions on this backend share the
* subprocess and die together if it exits.
*/
export class AcpBackendProcess implements BackendProcess {
private process: AcpProcessManager | null = null;
private connection: ClientSideConnection | null = null;
private readonly domainHandlers = new Map<SessionId, DomainSessionUpdateHandler>();
/**
* Per-session FIFO of `session/update` notifications that arrived before a
* handler was registered. Buffers the wire-shaped notification so we
* translate at replay time (the destination handler is domain-typed).
*/
private readonly pendingUpdates = new Map<SessionId, SessionNotification[]>();
private static readonly PENDING_UPDATE_LIMIT = 32;
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
null;
private exitListeners = new Set<() => void>();
private capabilities = new Map<AcpCapability, boolean>();
private readonly sessionWireState = new Map<SessionId, SessionWireState>();
constructor(
private readonly app: App,
private readonly backend: AcpBackend,
private readonly clientVersion: string,
private readonly descriptor: BackendDescriptor
) {}
/**
* Spawn the subprocess and complete the ACP `initialize` handshake.
* Idempotent: a second call while an existing connection is live is a
* no-op.
*/
async start(): Promise<void> {
if (this.connection) return;
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
}
const descriptor = await this.backend.buildSpawnDescriptor({
vaultBasePath: adapter.getBasePath(),
});
const procOpts: AcpProcessManagerOptions = {
command: descriptor.command,
args: descriptor.args,
env: descriptor.env,
logTag: this.backend.id,
};
const proc = new AcpProcessManager(procOpts);
this.process = proc;
const raw = proc.start();
const { stdin, stdout } = wrapStreamsForDebug(raw.stdin, raw.stdout, this.backend.id);
proc.onExit(() => {
logWarn(`[AgentMode] backend ${this.backend.id} exited`);
this.connection = null;
this.domainHandlers.clear();
this.pendingUpdates.clear();
this.sessionWireState.clear();
this.permissionPrompter = null;
this.capabilities.clear();
for (const fn of this.exitListeners) {
try {
fn();
} catch (e) {
logWarn("[AgentMode] exit listener threw", e);
}
}
});
const stream = ndJsonStream(stdin, stdout);
const client = new VaultClient(this.app, {
onSessionUpdate: (sessionId, update) => this.routeSessionUpdate(sessionId, update),
requestPermission: (req) => this.handlePermission(req),
});
this.connection = new ClientSideConnection(() => client, stream);
try {
const init = await this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
clientInfo: {
name: COPILOT_CLIENT_NAME,
version: this.clientVersion,
},
});
if (init.agentCapabilities?.sessionCapabilities?.list != null) {
this.capabilities.set("session/list", true);
}
if (init.agentCapabilities?.sessionCapabilities?.resume != null) {
this.capabilities.set("session/resume", true);
}
if (init.agentCapabilities?.loadSession === true) {
this.capabilities.set("session/load", true);
}
if (init.agentCapabilities?.mcpCapabilities?.http === true) {
this.capabilities.set("mcp/http", true);
}
if (init.agentCapabilities?.mcpCapabilities?.sse === true) {
this.capabilities.set("mcp/sse", true);
}
logInfo(
`[AgentMode] initialized backend ${this.backend.id} (negotiated protocol v${init.protocolVersion}, listSessions=${this.hasCapability("session/list")}, resumeSession=${this.hasCapability("session/resume")}, loadSession=${this.hasCapability("session/load")}, mcp.http=${this.hasCapability("mcp/http")}, mcp.sse=${this.hasCapability("mcp/sse")})`
);
} catch (err) {
logError(
`[AgentMode] initialize failed for ${this.backend.id}; tearing down subprocess`,
err
);
this.connection = null;
try {
await proc.shutdown();
} catch (e) {
logError("[AgentMode] shutdown after failed initialize threw", e);
}
this.process = null;
throw err;
}
}
isRunning(): boolean {
return this.process?.isRunning() ?? false;
}
onExit(listener: () => void): () => void {
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
setPermissionPrompter(fn: (req: PermissionPrompt) => Promise<PermissionDecision>): void {
this.permissionPrompter = fn;
}
registerSessionHandler(sessionId: SessionId, handler: DomainSessionUpdateHandler): () => void {
this.domainHandlers.set(sessionId, handler);
const buffered = this.pendingUpdates.get(sessionId);
if (buffered) {
this.pendingUpdates.delete(sessionId);
for (const wire of buffered) {
try {
handler(acpNotificationToEvent(wire));
} catch (e) {
logWarn(`[AgentMode] replay of buffered session/update threw for ${sessionId}`, e);
}
}
}
return () => {
if (this.domainHandlers.get(sessionId) === handler) {
this.domainHandlers.delete(sessionId);
}
};
}
async newSession(params: OpenSessionInput): Promise<OpenSessionOutput> {
const wireResp = await this.requireConnection().newSession({
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
});
this.recordWireState(wireResp.sessionId, {
models: wireResp.models ?? null,
modes: wireResp.modes ?? null,
configOptions: wireResp.configOptions ?? null,
});
return {
sessionId: sessionIdFromAcp(wireResp.sessionId),
state: this.computeState(wireResp.sessionId),
};
}
async prompt(params: PromptInput): Promise<PromptOutput> {
const resp = await this.requireConnection().prompt({
sessionId: sessionIdToAcp(params.sessionId),
prompt: promptContentToAcp(params.prompt),
});
return { stopReason: stopReasonFromAcp(resp.stopReason) };
}
async cancel(params: CancelInput): Promise<void> {
return this.requireConnection().cancel(cancelInputToAcp(params));
}
hasCapability(cap: AcpCapability): boolean {
return this.capabilities.get(cap) === true;
}
supportsMcpTransport(transport: "http" | "sse"): boolean {
return this.hasCapability(transport === "http" ? "mcp/http" : "mcp/sse");
}
async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise<BackendState> {
await this.dispatchCapability("session/set_model", (c) =>
c.unstable_setSessionModel({
sessionId: sessionIdToAcp(params.sessionId),
modelId: params.modelId,
})
);
const wire = this.sessionWireState.get(params.sessionId);
if (wire) {
const current = wire.models;
wire.models = current
? { ...current, currentModelId: params.modelId }
: { availableModels: [], currentModelId: params.modelId };
}
return this.computeState(params.sessionId);
}
isSetSessionModelSupported(): boolean | null {
return this.capabilitySupported("session/set_model");
}
async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise<BackendState> {
await this.dispatchCapability("session/set_mode", (c) =>
c.setSessionMode({
sessionId: sessionIdToAcp(params.sessionId),
modeId: params.modeId,
})
);
const wire = this.sessionWireState.get(params.sessionId);
if (wire) {
const seed: SessionModeState = wire.modes ?? { availableModes: [], currentModeId: "" };
wire.modes = { ...seed, currentModeId: params.modeId };
}
return this.computeState(params.sessionId);
}
isSetSessionModeSupported(): boolean | null {
return this.capabilitySupported("session/set_mode");
}
async setSessionConfigOption(params: {
sessionId: SessionId;
configId: string;
value: string;
}): Promise<BackendState> {
const resp = await this.dispatchCapability("session/set_config_option", (c) =>
c.setSessionConfigOption({
sessionId: sessionIdToAcp(params.sessionId),
configId: params.configId,
value: params.value,
})
);
const wire = this.sessionWireState.get(params.sessionId);
if (wire) {
wire.configOptions = resp.configOptions;
}
return this.computeState(params.sessionId);
}
isSetSessionConfigOptionSupported(): boolean | null {
return this.capabilitySupported("session/set_config_option");
}
/**
* Run an RPC gated by capability. Throws `MethodUnsupportedError` if the
* capability is known unsupported (advertised off, or a previous -32601).
* On a fresh -32601 reply, cache the negative result and rethrow.
*/
private async dispatchCapability<T>(
capability: AcpCapability,
run: (c: ClientSideConnection) => Promise<T>,
opts: { mustBeAdvertised?: boolean } = {}
): Promise<T> {
const known = this.capabilities.get(capability);
if (known === false || (opts.mustBeAdvertised && known !== true)) {
throw new MethodUnsupportedError(capability);
}
try {
const resp = await run(this.requireConnection());
this.capabilities.set(capability, true);
return resp;
} catch (err) {
if (isMethodNotFoundError(err)) {
this.capabilities.set(capability, false);
throw new MethodUnsupportedError(capability);
}
throw err;
}
}
private capabilitySupported(capability: AcpCapability): boolean | null {
return this.capabilities.has(capability) ? this.capabilities.get(capability)! : null;
}
async listSessions(params: ListSessionsInput): Promise<ListSessionsOutput> {
const resp = await this.dispatchCapability(
"session/list",
(c) => c.listSessions(params.cwd ? { cwd: params.cwd } : {}),
{ mustBeAdvertised: true }
);
return {
sessions: resp.sessions.map((s) =>
listedSessionFromAcp({
sessionId: s.sessionId,
cwd: s.cwd,
title: (s as { title?: string | null }).title ?? null,
updatedAt: (s as { updatedAt?: string | null }).updatedAt ?? null,
})
),
};
}
async resumeSession(params: ResumeSessionInput): Promise<ResumeSessionOutput> {
const wireResp = await this.dispatchCapability(
"session/resume",
(c) =>
c.resumeSession({
sessionId: sessionIdToAcp(params.sessionId),
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
}),
{ mustBeAdvertised: true }
);
this.recordWireState(sessionIdToAcp(params.sessionId), {
models: wireResp.models ?? null,
modes: wireResp.modes ?? null,
configOptions: wireResp.configOptions ?? null,
});
return {
sessionId: params.sessionId,
state: this.computeState(sessionIdToAcp(params.sessionId)),
};
}
async loadSession(params: LoadSessionInput): Promise<LoadSessionOutput> {
const wireResp = await this.dispatchCapability(
"session/load",
(c) =>
c.loadSession({
sessionId: sessionIdToAcp(params.sessionId),
cwd: params.cwd,
mcpServers: params.mcpServers.map(mcpServerSpecToAcp),
}),
{ mustBeAdvertised: true }
);
this.recordWireState(sessionIdToAcp(params.sessionId), {
models: wireResp.models ?? null,
modes: wireResp.modes ?? null,
configOptions: wireResp.configOptions ?? null,
});
return {
sessionId: params.sessionId,
state: this.computeState(sessionIdToAcp(params.sessionId)),
};
}
async shutdown(): Promise<void> {
this.connection = null;
this.domainHandlers.clear();
this.pendingUpdates.clear();
this.sessionWireState.clear();
this.permissionPrompter = null;
this.capabilities.clear();
if (this.process) {
try {
await this.process.shutdown();
} catch (e) {
logError("[AgentMode] backend shutdown failed", e);
}
this.process = null;
}
}
private requireConnection(): ClientSideConnection {
if (!this.connection) {
throw new Error(
this.process
? "AcpBackendProcess subprocess has exited"
: "AcpBackendProcess.start() not called"
);
}
return this.connection;
}
private recordWireState(sessionId: AcpSessionId, wire: SessionWireState): void {
this.sessionWireState.set(sessionIdFromAcp(sessionId), wire);
}
private computeState(sessionId: AcpSessionId): BackendState {
const wire = this.sessionWireState.get(sessionIdFromAcp(sessionId)) ?? {
models: null,
modes: null,
configOptions: null,
};
return acpStateToBackendState(wire.models, wire.modes, wire.configOptions, this.descriptor);
}
private routeSessionUpdate(acpSessionId: AcpSessionId, update: SessionNotification): void {
const sessionId = sessionIdFromAcp(acpSessionId);
// Mirror per-dimension wire updates into our cache so subsequent
// setSession* calls (and the next `state_changed` event) reflect reality.
const wire = this.sessionWireState.get(sessionId);
if (wire) {
const u = update.update;
if (u.sessionUpdate === "current_mode_update") {
const seed = wire.modes ?? { availableModes: [], currentModeId: "" };
wire.modes = { ...seed, currentModeId: u.currentModeId };
} else if (u.sessionUpdate === "config_option_update") {
wire.configOptions = u.configOptions;
}
}
const handler = this.domainHandlers.get(sessionId);
if (!handler) {
let queue = this.pendingUpdates.get(sessionId);
if (!queue) {
queue = [];
this.pendingUpdates.set(sessionId, queue);
}
if (queue.length >= AcpBackendProcess.PENDING_UPDATE_LIMIT) {
const kind = update.update.sessionUpdate ?? "unknown";
logWarn(
`[AgentMode] dropping session/update for ${sessionId}: pending buffer full (${queue.length}, kind=${kind})`
);
return;
}
queue.push(update);
return;
}
// Per-dimension wire updates already mutated `wire` above; AgentSession
// ignores them and waits for the synthesized `state_changed` we publish
// below. Skip the original to avoid a wasted translation + dispatch.
const sub = update.update.sessionUpdate;
if (sub === "current_mode_update" || sub === "config_option_update") {
handler({
sessionId,
update: { sessionUpdate: "state_changed", state: this.computeState(sessionId) },
});
return;
}
handler(acpNotificationToEvent(update));
}
private async handlePermission(
req: RequestPermissionRequest
): Promise<RequestPermissionResponse> {
if (!this.permissionPrompter) {
logWarn(`[AgentMode] permission requested but no prompter is registered; auto-cancelling`);
return { outcome: { outcome: "cancelled" } };
}
const decision = await this.permissionPrompter(acpPermissionRequestToPrompt(req));
return decisionToAcpResponse(decision);
}
}

View file

@ -0,0 +1,176 @@
import { logError, logInfo, logWarn } from "@/logger";
import { ChildProcessByStdio, spawn } from "node:child_process";
import { Readable, Writable } from "node:stream";
const SIGTERM_GRACE_MS = 3_000;
export interface AcpProcessManagerOptions {
/** Absolute path to the agent backend binary (e.g. opencode). */
command: string;
/** Process arguments. */
args: string[];
/** Environment for the child. Pass through `process.env` plus any overrides. */
env: NodeJS.ProcessEnv;
/** Tag used in stderr/log lines so multiple agents can be distinguished. */
logTag?: string;
}
/**
* Spawns and supervises the ACP-speaking agent backend subprocess. Exposes
* the child's stdin/stdout as Web Streams (suitable for
* `@agentclientprotocol/sdk`'s `ndJsonStream`) and pipes stderr line-by-line
* into the Copilot logger.
*
* Single-shot: `start()` may only be called once. Use `shutdown()` for a
* graceful SIGTERMSIGKILL teardown.
*/
export class AcpProcessManager {
private child: ChildProcessByStdio<Writable, Readable, Readable> | null = null;
private exitListeners = new Set<(code: number | null, signal: NodeJS.Signals | null) => void>();
private hasExited = false;
private exitCode: number | null = null;
private exitSignal: NodeJS.Signals | null = null;
constructor(private readonly opts: AcpProcessManagerOptions) {}
/**
* Spawn the child and return its stdin/stdout as Web Streams. Stderr is
* consumed internally and routed to the logger; callers don't see it.
*/
start(): { stdin: WritableStream<Uint8Array>; stdout: ReadableStream<Uint8Array> } {
if (this.child) {
throw new Error("AcpProcessManager already started");
}
const tag = this.opts.logTag ?? "acp";
logInfo(`[AgentMode] spawning ${this.opts.command} ${this.opts.args.join(" ")} (tag=${tag})`);
const child = spawn(this.opts.command, this.opts.args, {
env: this.opts.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.child = child;
child.on("error", (err) => {
logError(`[AgentMode] subprocess error (${tag})`, err);
});
child.on("exit", (code, signal) => {
this.hasExited = true;
this.exitCode = code;
this.exitSignal = signal;
logInfo(`[AgentMode] subprocess exit (${tag}) code=${code} signal=${signal}`);
for (const fn of this.exitListeners) {
try {
fn(code, signal);
} catch (e) {
logWarn(`[AgentMode] exit listener threw`, e);
}
}
});
pipeStderrToLogger(child.stderr, tag);
// Bridge Node streams → Web Streams for `@agentclientprotocol/sdk`'s
// `ndJsonStream`. `toWeb` is Node ≥17 (Electron 27 ships Node 18), but
// missing from the older `@types/node` this project pins, hence the
// cast.
const writableToWeb = (
Writable as unknown as {
toWeb: (s: NodeJS.WritableStream) => WritableStream<Uint8Array>;
}
).toWeb;
const readableToWeb = (
Readable as unknown as {
toWeb: (s: NodeJS.ReadableStream) => ReadableStream<Uint8Array>;
}
).toWeb;
return {
stdin: writableToWeb(child.stdin),
stdout: readableToWeb(child.stdout),
};
}
onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): () => void {
if (this.hasExited) {
// Fire synchronously so callers don't miss the event when subscribing
// after exit (e.g. crash before they wired up).
try {
listener(this.exitCode, this.exitSignal);
} catch (e) {
logWarn(`[AgentMode] exit listener threw`, e);
}
return () => {};
}
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
isRunning(): boolean {
return this.child !== null && !this.hasExited;
}
/**
* Send SIGTERM, wait up to SIGTERM_GRACE_MS, then escalate to SIGKILL if
* the child is still alive. Resolves once the child has exited (or
* immediately if it already had).
*/
async shutdown(): Promise<void> {
if (!this.child || this.hasExited) return;
const child = this.child;
const tag = this.opts.logTag ?? "acp";
const exited = new Promise<void>((resolve) => {
this.onExit(() => resolve());
});
try {
child.kill("SIGTERM");
} catch (e) {
logWarn(`[AgentMode] SIGTERM failed (${tag})`, e);
}
const timeout = new Promise<"timeout">((resolve) =>
window.setTimeout(() => resolve("timeout"), SIGTERM_GRACE_MS)
);
const winner = await Promise.race([exited.then(() => "exited" as const), timeout]);
if (winner === "timeout" && !this.hasExited) {
logWarn(`[AgentMode] subprocess (${tag}) did not exit within ${SIGTERM_GRACE_MS}ms; SIGKILL`);
try {
child.kill("SIGKILL");
} catch (e) {
logWarn(`[AgentMode] SIGKILL failed (${tag})`, e);
}
await exited;
}
}
}
function pipeStderrToLogger(stderr: Readable, tag: string): void {
let buffer = "";
stderr.setEncoding("utf-8");
stderr.on("data", (chunk: string) => {
buffer += chunk;
let nlIdx: number;
while ((nlIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlIdx).trimEnd();
buffer = buffer.slice(nlIdx + 1);
if (line) emitStderrLine(line, tag);
}
});
stderr.on("end", () => {
if (buffer.trim()) emitStderrLine(buffer.trim(), tag);
});
}
function emitStderrLine(line: string, tag: string): void {
// Heuristic: lines starting with "error" / "ERROR" / "fatal" go to error,
// everything else stays at info. We don't want to drown the user log in
// opencode's noisy debug output, so warnings stay warnings.
const lower = line.toLowerCase();
if (lower.startsWith("error") || lower.startsWith("fatal")) {
logError(`[AgentMode][${tag}] ${line}`);
} else if (lower.startsWith("warn")) {
logWarn(`[AgentMode][${tag}] ${line}`);
} else {
logInfo(`[AgentMode][${tag}] ${line}`);
}
}

View file

@ -0,0 +1,163 @@
import { FileSystemAdapter, App } from "obsidian";
import { sliceLines, VaultClient } from "./VaultClient";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
describe("sliceLines", () => {
const text = "a\nb\nc\nd\ne";
it("returns full content when both line and limit are null", () => {
expect(sliceLines(text, null, null)).toBe(text);
});
it("returns from line N (1-based) to end when limit is null", () => {
expect(sliceLines(text, 3, null)).toBe("c\nd\ne");
});
it("returns first N lines when line is null", () => {
expect(sliceLines(text, null, 2)).toBe("a\nb");
});
it("respects both line and limit", () => {
expect(sliceLines(text, 2, 2)).toBe("b\nc");
});
it("returns empty string when line is past end", () => {
expect(sliceLines(text, 99, 5)).toBe("");
});
it("clamps limit when it overruns", () => {
expect(sliceLines(text, 4, 100)).toBe("d\ne");
});
});
describe("VaultClient", () => {
function buildApp(basePath = "/vault"): App {
// The mocked FileSystemAdapter takes the basePath via constructor; the
// real Obsidian type has a no-arg constructor, hence the cast.
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(
basePath
);
return { vault: { adapter } } as unknown as App;
}
it("readTextFile reads via adapter and applies line/limit", async () => {
const app = buildApp();
(app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue(
"one\ntwo\nthree\nfour"
);
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
const resp = await client.readTextFile({
sessionId: "s1",
path: "notes/foo.md",
line: 2,
limit: 2,
});
expect(resp.content).toBe("two\nthree");
expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith(
"notes/foo.md"
);
});
it("rejects out-of-vault relative paths with .. traversal", async () => {
const app = buildApp("/Users/me/vault");
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
await expect(client.readTextFile({ sessionId: "s1", path: "../outside.md" })).rejects.toThrow(
/outside the vault/
);
});
// eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture for hidden-dir guard
it("rejects dotfile paths under the vault (e.g. .obsidian config)", async () => {
const app = buildApp("/Users/me/vault");
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
await expect(
// eslint-disable-next-line obsidianmd/hardcoded-config-path -- test fixture
client.readTextFile({ sessionId: "s1", path: ".obsidian/plugins/copilot/data.json" })
).rejects.toThrow(/hidden directory/);
await expect(
client.writeTextFile({ sessionId: "s1", path: ".git/config", content: "x" })
).rejects.toThrow(/hidden directory/);
});
it("rejects absolute paths outside the vault base", async () => {
const app = buildApp("/Users/me/vault");
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
await expect(client.readTextFile({ sessionId: "s1", path: "/etc/passwd" })).rejects.toThrow(
/outside the vault/
);
});
it("accepts absolute paths inside the vault base and routes to adapter as relative", async () => {
const app = buildApp("/Users/me/vault");
(app.vault.adapter as unknown as { read: jest.Mock }).read.mockResolvedValue("hello");
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
const resp = await client.readTextFile({
sessionId: "s1",
path: "/Users/me/vault/notes/foo.md",
});
expect(resp.content).toBe("hello");
expect((app.vault.adapter as unknown as { read: jest.Mock }).read).toHaveBeenCalledWith(
"notes/foo.md"
);
});
it("writeTextFile creates parent dir if missing", async () => {
const app = buildApp();
const adapter = app.vault.adapter as unknown as {
exists: jest.Mock;
mkdir: jest.Mock;
write: jest.Mock;
};
adapter.exists.mockResolvedValueOnce(false);
adapter.write.mockResolvedValue(undefined);
const client = new VaultClient(app, {
onSessionUpdate: () => {},
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
await client.writeTextFile({
sessionId: "s1",
path: "Inbox/note.md",
content: "hi",
});
expect(adapter.mkdir).toHaveBeenCalledWith("Inbox");
expect(adapter.write).toHaveBeenCalledWith("Inbox/note.md", "hi");
});
it("sessionUpdate forwards to handler", async () => {
const app = buildApp();
const onSessionUpdate = jest.fn();
const client = new VaultClient(app, {
onSessionUpdate,
requestPermission: () => Promise.resolve({ outcome: { outcome: "cancelled" } }),
});
const update = {
sessionId: "s1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hi" },
},
} as unknown as Parameters<typeof client.sessionUpdate>[0];
await client.sessionUpdate(update);
expect(onSessionUpdate).toHaveBeenCalledWith("s1", update);
});
});

View file

@ -0,0 +1,129 @@
import type {
Client,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionId,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { RequestError } from "@agentclientprotocol/sdk";
import { logInfo, logWarn } from "@/logger";
import { App, FileSystemAdapter, normalizePath } from "obsidian";
import * as path from "node:path";
export interface PermissionPrompter {
(req: RequestPermissionRequest): Promise<RequestPermissionResponse>;
}
export interface VaultClientHandlers {
/** Routes a session/update to the right AgentSession. */
onSessionUpdate: (sessionId: SessionId, update: SessionNotification) => void;
/** Opens the permission UI; resolves with the user's choice. */
requestPermission: PermissionPrompter;
}
/**
* Implements the ACP `Client` interface against an Obsidian vault.
*
* - `readTextFile`/`writeTextFile` route through `app.vault.adapter`, with
* strict vault-relative path resolution. Out-of-vault paths are rejected
* with `invalidParams` so the agent gets a clear error.
* - `sessionUpdate` is demultiplexed via `handlers.onSessionUpdate`.
* - `requestPermission` defers to `handlers.requestPermission` which opens
* the modal UI.
*
* Terminal capabilities are deliberately *not* implemented we don't
* advertise the capability, and opencode falls back to its internal PTY.
*/
export class VaultClient implements Client {
constructor(
private readonly app: App,
private readonly handlers: VaultClientHandlers
) {}
async sessionUpdate(params: SessionNotification): Promise<void> {
try {
this.handlers.onSessionUpdate(params.sessionId, params);
} catch (e) {
logWarn(`[AgentMode] sessionUpdate handler threw`, e);
}
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
return this.handlers.requestPermission(params);
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const rel = this.resolveVaultRelative(params.path);
const full = await this.app.vault.adapter.read(rel);
const content = sliceLines(full, params.line ?? null, params.limit ?? null);
logInfo(`[AgentMode] readTextFile ${rel} (${content.length} chars)`);
return { content };
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const rel = this.resolveVaultRelative(params.path);
const adapter = this.app.vault.adapter;
const dir = path.posix.dirname(rel);
if (dir && dir !== "." && dir !== "/" && !(await adapter.exists(dir))) {
await adapter.mkdir(dir);
}
await adapter.write(rel, params.content);
logInfo(`[AgentMode] writeTextFile ${rel} (${params.content.length} chars)`);
return {};
}
/**
* Resolve `p` against the vault root. Returns a vault-relative,
* forward-slashed path for `app.vault.adapter`. Throws
* `RequestError.invalidParams` if the path escapes the vault.
*/
private resolveVaultRelative(p: string): string {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
throw RequestError.invalidParams(
undefined,
"Agent Mode requires a FileSystemAdapter (desktop)."
);
}
const vaultBase = path.resolve(adapter.getBasePath());
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(vaultBase, p);
const rel = path.relative(vaultBase, resolved);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw RequestError.invalidParams(
{ path: p },
`Path "${p}" is outside the vault and cannot be accessed by Agent Mode.`
);
}
const normalized = normalizePath(rel.split(path.sep).join("/"));
// Block dotfile dirs/files at the vault root (`.obsidian/`, `.copilot/`,
// `.git/`, etc.). They contain plugin settings — including encrypted-at-
// rest API keys, hotkey config, vault metadata — that the agent has no
// business reading or writing without an explicit user-facing flow.
const firstSegment = normalized.split("/")[0] ?? "";
if (firstSegment.startsWith(".")) {
throw RequestError.invalidParams(
{ path: p },
`Path "${p}" is in a hidden directory (${firstSegment}) and is not accessible to Agent Mode.`
);
}
return normalized;
}
}
/**
* Extract a 1-based line slice with an optional limit. Mirrors ACP's
* `ReadTextFileRequest.{line, limit}` semantics: read N lines starting from
* line `line`. Out-of-range starts return empty.
*/
export function sliceLines(content: string, line: number | null, limit: number | null): string {
if (line == null && limit == null) return content;
const lines = content.split("\n");
const start = line != null ? Math.max(0, line - 1) : 0;
const end = limit != null ? Math.min(lines.length, start + limit) : lines.length;
if (start >= lines.length) return "";
return lines.slice(start, end).join("\n");
}

View file

@ -0,0 +1,182 @@
import { logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink";
interface JsonRpcFrame {
jsonrpc?: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: { code?: number; message?: string; data?: unknown };
}
/**
* Wrap the subprocess stdin/stdout streams so every NDJSON-framed
* JSON-RPC message is logged in both directions. Outbound is what
* `ClientSideConnection` writes to stdin; inbound is what we read from
* stdout. The taps are passthroughs bytes flow through unchanged.
*
* Method names are remembered per request id so that responses (which
* carry only id + result/error) can be labeled with the method they
* answered.
*/
export function wrapStreamsForDebug(
stdin: WritableStream<Uint8Array>,
stdout: ReadableStream<Uint8Array>,
tag: string
): { stdin: WritableStream<Uint8Array>; stdout: ReadableStream<Uint8Array> } {
const outboundPending = new Map<string, string>();
const inboundPending = new Map<string, string>();
return {
stdin: tapWritable(stdin, (line) => logFrame("→", line, tag, outboundPending, inboundPending)),
stdout: tapReadable(stdout, (line) =>
logFrame("←", line, tag, inboundPending, outboundPending)
),
};
}
function tapWritable(
inner: WritableStream<Uint8Array>,
onLine: (line: string) => void
): WritableStream<Uint8Array> {
// Avoid `TransformStream` / `pipeTo` because the inner stream comes from
// Node's `Writable.toWeb()` and is branded against `node:internal/
// webstreams`; mixing it with global-realm streams throws
// `ERR_INVALID_ARG_TYPE`. A hand-rolled WritableStream that delegates to
// the inner writer side-steps the realm check entirely.
const writer = inner.getWriter();
const splitter = new NdjsonLineSplitter(onLine);
return new WritableStream<Uint8Array>({
async write(chunk) {
splitter.push(chunk);
await writer.write(chunk);
},
async close() {
splitter.flush();
await writer.close();
},
async abort(reason) {
splitter.flush();
await writer.abort(reason);
},
});
}
function tapReadable(
inner: ReadableStream<Uint8Array>,
onLine: (line: string) => void
): ReadableStream<Uint8Array> {
// `tee()` is a same-realm method (no class mismatch), so we get two
// branded-equivalent ReadableStreams: one for the SDK to consume, one we
// drain ourselves for logging.
const [forConsumer, forLogging] = inner.tee();
const splitter = new NdjsonLineSplitter(onLine);
void (async () => {
const reader = forLogging.getReader();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (value) splitter.push(value);
}
splitter.flush();
} catch {
// Stream closed/aborted; nothing for the tap to do.
}
})();
return forConsumer;
}
class NdjsonLineSplitter {
private buffer = "";
private decoder = new TextDecoder();
constructor(private readonly onLine: (line: string) => void) {}
push(chunk: Uint8Array): void {
this.buffer += this.decoder.decode(chunk, { stream: true });
let nl: number;
while ((nl = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
if (line) this.emit(line);
}
}
flush(): void {
const tail = this.buffer.trim();
this.buffer = "";
if (tail) this.emit(tail);
}
private emit(line: string): void {
try {
this.onLine(line);
} catch {
// Logging must never break the protocol stream.
}
}
}
function logFrame(
arrow: "→" | "←",
line: string,
tag: string,
/** Pending requests originated by *our* side of this stream. */
ownPending: Map<string, string>,
/** Pending requests originated by the *other* side of this stream. */
peerPending: Map<string, string>
): void {
// Read once per frame so the hot path doesn't allocate a record + timestamp
// when the toggle is off.
const fullFramesOn = !!getSettings().agentMode?.debugFullFrames;
const emit = fullFramesOn
? (kind: FrameRecord["kind"], method: string, id: string | null, payload: unknown) =>
frameSink.append({
ts: new Date().toISOString(),
dir: arrow,
tag,
kind,
method,
id,
payload,
})
: null;
let frame: JsonRpcFrame;
try {
frame = JSON.parse(line);
} catch {
logInfo(`[ACP ${arrow}][${tag}] (unparsed) ${formatPayload(line)}`);
emit?.("raw", "(unparsed)", null, { raw: line });
return;
}
const idStr = frame.id !== undefined ? String(frame.id) : null;
if (frame.method) {
// Request or notification.
const method = frame.method;
const idLabel = idStr !== null ? `#${idStr}` : "(notif)";
if (idStr !== null) ownPending.set(idStr, method);
logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.params)}`);
emit?.(idStr !== null ? "request" : "notif", method, idStr, frame.params);
return;
}
// Response (result or error). Method name comes from the side that
// originated the request — that's `peerPending` from this stream's
// perspective.
const method = idStr !== null ? (peerPending.get(idStr) ?? "(unknown)") : "(unknown)";
if (idStr !== null) peerPending.delete(idStr);
const idLabel = idStr !== null ? `#${idStr}` : "(no-id)";
if (frame.error) {
logInfo(`[ACP ${arrow}][${tag}] (error) ${method} ${idLabel} ${formatPayload(frame.error)}`);
emit?.("error", method, idStr, frame.error);
} else {
logInfo(`[ACP ${arrow}][${tag}] ${method} ${idLabel} ${formatPayload(frame.result)}`);
emit?.("result", method, idStr, frame.result);
}
}

View file

@ -0,0 +1,4 @@
export { AcpBackendProcess } from "./AcpBackendProcess";
export { AcpProcessManager, type AcpProcessManagerOptions } from "./AcpProcessManager";
export { VaultClient } from "./VaultClient";
export type { AcpBackend, AcpSpawnDescriptor } from "./types";

View file

@ -0,0 +1,35 @@
import * as path from "node:path";
/**
* macOS GUI apps (Obsidian) inherit a minimal PATH that omits Homebrew and
* common Node installer locations. Adapters that ship as `#!/usr/bin/env
* node` launchers fail to spawn with `env: node: No such file or directory`
* unless we put `node` on PATH ourselves.
*
* 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.
*/
export function augmentPathForNodeShebang(
binaryPath: string,
inherited: string | undefined
): string {
const sep = process.platform === "win32" ? ";" : ":";
const candidates = [
path.dirname(binaryPath),
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
];
const inheritedParts = (inherited ?? "").split(sep).filter(Boolean);
const seen = new Set<string>();
const merged: string[] = [];
for (const p of [...candidates, ...inheritedParts]) {
if (!seen.has(p)) {
seen.add(p);
merged.push(p);
}
}
return merged.join(sep);
}

View file

@ -0,0 +1,27 @@
import type { BackendId } from "@/agentMode/session/types";
/**
* Spawn descriptor for an ACP-speaking agent backend. Backends produce these
* lazily because they may need to read settings (BYOK keys, MCP config) at
* spawn time.
*/
export interface AcpSpawnDescriptor {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
}
/**
* One ACP-track agent backend. Implementers (OpencodeBackend, CodexBackend,
* etc.) own the spawn-time contract. The rest of Agent Mode
* `AcpBackendProcess`, `AgentSession`, `VaultClient` stays
* backend-agnostic.
*/
export interface AcpBackend {
/** Stable identifier, used for logging and settings selection. */
readonly id: BackendId;
/** Human-readable name surfaced in the UI. */
readonly displayName: string;
/** Build the spawn descriptor (BYOK keys decrypted, env composed). */
buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor>;
}

View file

@ -0,0 +1,425 @@
/**
* Pure translators between ACP wire types and the session-domain types
* defined in `session/types.ts`. The shapes mostly mirror each other (we
* intentionally modelled the session domain on ACP's vocabulary), so most
* translators are structural identity casts. Keeping them in one file
* isolates the seam: when ACP evolves, this is the only place that needs
* updating.
*/
import type {
CancelNotification,
ContentBlock,
McpServer as AcpMcpServer,
ModelInfo as AcpModelInfo,
PermissionOption as AcpPermissionOption,
Plan as AcpPlan,
RequestPermissionRequest,
RequestPermissionResponse,
SessionConfigOption,
SessionId as AcpSessionId,
SessionModeState,
SessionModelState,
SessionNotification,
StopReason as AcpStopReason,
ToolCall,
ToolCallContent as AcpToolCallContent,
ToolCallUpdate,
ToolKind as AcpToolKind,
} from "@agentclientprotocol/sdk";
import type {
AgentToolKind,
AgentToolStatus,
BackendConfigOption,
BackendDescriptor,
RawModeState,
RawModelState,
BackendState,
CancelInput,
ListedSessionInfo,
McpServerSpec,
PermissionDecision,
PermissionOption,
PermissionPrompt,
PromptContent,
SessionEvent,
SessionId,
SessionUpdate,
StopReason,
ToolCallContent,
ToolCallDelta,
ToolCallSnapshot,
} from "@/agentMode/session/types";
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
import { translateBackendState } from "@/agentMode/session/translateBackendState";
// ---- Catalog wire → neutral (pass-through, structural alias) -----------
export function modelStateFromAcp(
state: SessionModelState | null | undefined
): RawModelState | null {
if (!state) return null;
return {
currentModelId: state.currentModelId,
availableModels: state.availableModels.map((m) => ({
modelId: m.modelId,
name: m.name,
description: m.description ?? undefined,
})),
};
}
export function modeStateFromAcp(state: SessionModeState | null | undefined): RawModeState | null {
if (!state) return null;
return {
currentModeId: state.currentModeId,
availableModes: state.availableModes.map((m) => ({
id: m.id,
name: m.name,
description: m.description ?? undefined,
})),
};
}
export function configOptionsFromAcp(
options: SessionConfigOption[] | null | undefined
): BackendConfigOption[] | null {
if (!options) return null;
return options.map(configOptionFromAcp);
}
function configOptionFromAcp(opt: SessionConfigOption): BackendConfigOption {
if (opt.type === "select") {
return {
id: opt.id,
type: "select",
name: opt.name,
description: opt.description ?? undefined,
category: opt.category ?? null,
currentValue: String(opt.currentValue),
options: opt.options.map((entry) => {
if ("options" in entry) {
return {
name: entry.name,
options: entry.options.map((inner) => ({
value: inner.value,
name: inner.name,
description: inner.description ?? undefined,
})),
};
}
return {
value: entry.value,
name: entry.name,
description: entry.description ?? undefined,
};
}),
};
}
return {
id: opt.id,
type: "boolean",
name: opt.name,
description: opt.description ?? undefined,
category: opt.category ?? null,
currentValue: Boolean((opt as { currentValue: unknown }).currentValue),
};
}
export function configOptionToAcp(opt: BackendConfigOption): SessionConfigOption {
// SDK's SessionConfigOption shape mirrors our neutral shape; cast through.
return opt as unknown as SessionConfigOption;
}
export function modelInfoFromAcp(model: AcpModelInfo): {
modelId: string;
name: string;
description?: string;
} {
return {
modelId: model.modelId,
name: model.name,
description: model.description ?? undefined,
};
}
export function acpStateToBackendState(
models: SessionModelState | null | undefined,
modes: SessionModeState | null | undefined,
configOptions: SessionConfigOption[] | null | undefined,
descriptor: BackendDescriptor
): BackendState {
return translateBackendState(
{
models: modelStateFromAcp(models),
modes: modeStateFromAcp(modes),
configOptions: configOptionsFromAcp(configOptions),
},
descriptor
);
}
// ---- StopReason --------------------------------------------------------
export function stopReasonFromAcp(reason: AcpStopReason): StopReason {
switch (reason) {
case "end_turn":
case "cancelled":
case "refusal":
case "max_tokens":
case "max_turn_requests":
return reason;
default:
return "end_turn";
}
}
// ---- Tool kind / status (ACP enum subsets) -----------------------------
export function toolKindFromAcp(kind: AcpToolKind | undefined): AgentToolKind | undefined {
if (kind == null) return undefined;
return kind;
}
function toolStatusFromAcp(status: string | undefined): AgentToolStatus | undefined {
if (!status) return undefined;
return status as AgentToolStatus;
}
// ---- Content blocks ----------------------------------------------------
export function promptContentToAcp(blocks: PromptContent[]): ContentBlock[] {
return blocks.map((b): ContentBlock => {
if (b.type === "text") return { type: "text", text: b.text };
if (b.type === "image") return { type: "image", mimeType: b.mimeType, data: b.data };
return { type: "resource_link", uri: b.uri, name: b.name ?? b.uri };
});
}
export function promptContentFromAcp(block: ContentBlock): PromptContent | null {
if (block.type === "text") return { type: "text", text: block.text };
if (block.type === "image") return { type: "image", mimeType: block.mimeType, data: block.data };
if (block.type === "resource_link")
return { type: "resource_link", uri: block.uri, name: block.name ?? undefined };
return null;
}
function toolCallContentFromAcp(
content: AcpToolCallContent[] | null | undefined
): ToolCallContent[] | undefined {
if (!content) return undefined;
const out: ToolCallContent[] = [];
for (const item of content) {
if (item.type === "content" && item.content.type === "text") {
out.push({ type: "content", content: { type: "text", text: item.content.text } });
} else if (item.type === "diff") {
out.push({
type: "diff",
path: item.path,
oldText: item.oldText ?? null,
newText: item.newText,
});
}
}
return out.length > 0 ? out : undefined;
}
function toolCallSnapshotFromAcp(
call: ToolCall & { sessionUpdate?: "tool_call" }
): ToolCallSnapshot {
return {
toolCallId: call.toolCallId,
title: call.title,
kind: toolKindFromAcp(call.kind),
status: toolStatusFromAcp(call.status),
rawInput: call.rawInput,
content: toolCallContentFromAcp(call.content),
locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })),
};
}
function mapNullable<T, R>(value: T | null | undefined, fn: (value: T) => R): R | null | undefined {
if (value === null) return null;
if (value === undefined) return undefined;
return fn(value);
}
function toolCallDeltaFromAcp(
upd: ToolCallUpdate & { sessionUpdate?: "tool_call_update" }
): ToolCallDelta {
return {
toolCallId: upd.toolCallId,
title: upd.title ?? undefined,
kind: toolKindFromAcp(upd.kind ?? undefined),
status: toolStatusFromAcp(upd.status as string | undefined),
rawInput: upd.rawInput,
content: mapNullable(upd.content, toolCallContentFromAcp),
locations: mapNullable(upd.locations, (locs) =>
locs.map((l) => ({ path: l.path, line: l.line ?? undefined }))
),
};
}
// ---- Notification → SessionEvent --------------------------------------
export function acpNotificationToEvent(n: SessionNotification): SessionEvent {
return {
sessionId: sessionIdFromAcp(n.sessionId),
update: acpUpdateToSessionUpdate(n.update),
};
}
function acpUpdateToSessionUpdate(update: SessionNotification["update"]): SessionUpdate {
switch (update.sessionUpdate) {
case "agent_message_chunk":
return {
sessionUpdate: "agent_message_chunk",
content: promptContentFromAcp(update.content) ?? { type: "text", text: "" },
};
case "agent_thought_chunk":
return {
sessionUpdate: "agent_thought_chunk",
content: promptContentFromAcp(update.content) ?? { type: "text", text: "" },
};
case "tool_call":
return {
sessionUpdate: "tool_call",
...toolCallSnapshotFromAcp(update),
};
case "tool_call_update":
return {
sessionUpdate: "tool_call_update",
...toolCallDeltaFromAcp(update),
};
case "plan":
return {
sessionUpdate: "plan",
entries: update.entries.map((e: AcpPlan["entries"][number]) => ({
content: e.content,
priority: e.priority,
status: e.status,
})),
};
case "session_info_update":
return {
sessionUpdate: "session_info_update",
title: (update as { title?: string | null }).title ?? null,
};
case "current_mode_update":
return {
sessionUpdate: "current_mode_update",
currentModeId: update.currentModeId,
};
case "config_option_update":
return {
sessionUpdate: "config_option_update",
configOptions: configOptionsFromAcp(update.configOptions) ?? [],
};
default:
// Unknown discriminant — fall back to a benign session_info_update with no title.
return { sessionUpdate: "session_info_update", title: null };
}
}
// ---- Permission prompt / decision -------------------------------------
export function acpPermissionRequestToPrompt(req: RequestPermissionRequest): PermissionPrompt {
const call = req.toolCall;
return {
sessionId: sessionIdFromAcp(req.sessionId),
toolCall: {
toolCallId: call.toolCallId,
title: call.title ?? "Tool call",
kind: toolKindFromAcp(call.kind ?? undefined),
status: toolStatusFromAcp(call.status as string | undefined) ?? "pending",
rawInput: call.rawInput,
content: toolCallContentFromAcp(call.content),
locations: call.locations?.map((l) => ({ path: l.path, line: l.line ?? undefined })),
},
options: req.options.map(permissionOptionFromAcp),
};
}
function permissionOptionFromAcp(opt: AcpPermissionOption): PermissionOption {
return {
optionId: opt.optionId,
name: opt.name,
kind: (PERMISSION_OPTION_KINDS as readonly string[]).includes(opt.kind)
? opt.kind
: "reject_once",
};
}
export function permissionPromptToAcp(prompt: PermissionPrompt): RequestPermissionRequest {
return {
sessionId: prompt.sessionId,
toolCall: {
toolCallId: prompt.toolCall.toolCallId,
title: prompt.toolCall.title,
kind: prompt.toolCall.kind,
status: prompt.toolCall.status ?? "pending",
rawInput: prompt.toolCall.rawInput,
},
options: prompt.options.map((o) => ({
optionId: o.optionId,
name: o.name,
kind: o.kind,
})),
};
}
export function acpDecisionFromResponse(resp: RequestPermissionResponse): PermissionDecision {
if (resp.outcome.outcome === "cancelled") {
return { outcome: { outcome: "cancelled" } };
}
return { outcome: { outcome: "selected", optionId: resp.outcome.optionId } };
}
export function decisionToAcpResponse(decision: PermissionDecision): RequestPermissionResponse {
return decision;
}
// ---- MCP server --------------------------------------------------------
export function mcpServerSpecToAcp(spec: McpServerSpec): AcpMcpServer {
if ("type" in spec && spec.type === "http") {
return { type: "http", name: spec.name, url: spec.url, headers: spec.headers };
}
if ("type" in spec && spec.type === "sse") {
return { type: "sse", name: spec.name, url: spec.url, headers: spec.headers };
}
// stdio
return {
name: spec.name,
command: spec.command,
args: spec.args,
env: spec.env,
};
}
// ---- SessionId / Cancel -----------------------------------------------
export function sessionIdFromAcp(id: AcpSessionId): SessionId {
return id;
}
export function sessionIdToAcp(id: SessionId): AcpSessionId {
return id;
}
export function cancelInputToAcp(input: CancelInput): CancelNotification {
return { sessionId: sessionIdToAcp(input.sessionId) };
}
export function listedSessionFromAcp(s: {
sessionId: AcpSessionId;
cwd: string;
title?: string | null;
updatedAt?: string | null;
}): ListedSessionInfo {
return {
sessionId: sessionIdFromAcp(s.sessionId),
cwd: s.cwd,
title: s.title ?? null,
updatedAt: s.updatedAt ?? null,
};
}

View file

@ -0,0 +1,164 @@
import { Button } from "@/components/ui/button";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App, Modal } from "obsidian";
import React from "react";
import { Root } from "react-dom/client";
import type { AskUserQuestionInput } from "@/agentMode/sdk/permissionBridge";
type Questions = AskUserQuestionInput["questions"];
type Answers = { [questionText: string]: string };
interface ContentProps {
questions: Questions;
onSubmit: (answers: Answers) => void;
onCancel: () => void;
}
/**
* Modal that renders the SDK's `AskUserQuestion` payload as a series of
* single- or multi-choice question blocks. Resolves with `{ questionText:
* "label" }` (single-select) or `{ questionText: "label1, label2" }`
* (multi-select). Closing without submitting resolves with `{}` the
* permission bridge treats an empty answer map as a cancellation.
*/
const AskUserQuestionContent: React.FC<ContentProps> = ({ questions, onSubmit, onCancel }) => {
// Per-question selection: a single label for radio, a Set of labels for checkbox.
const [selections, setSelections] = React.useState<Record<number, string | Set<string>>>({});
const canSubmit = questions.every((q, idx) => {
if (q.multiSelect) return true;
return typeof selections[idx] === "string" && selections[idx] !== "";
});
const submit = (): void => {
const answers: Answers = {};
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const sel = selections[i];
if (q.multiSelect) {
answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : "";
} else {
answers[q.question] = typeof sel === "string" ? sel : "";
}
}
onSubmit(answers);
};
return (
<div className="tw-flex tw-flex-col tw-gap-4">
{questions.map((q, idx) => (
<div key={q.question} className="tw-flex tw-flex-col tw-gap-2">
{q.header && (
<div className="tw-text-xs tw-font-semibold tw-uppercase tw-text-muted">{q.header}</div>
)}
<div className="tw-text-sm">{q.question}</div>
<div className="tw-flex tw-flex-col tw-gap-1">
{q.options.map((opt) => {
const sel = selections[idx];
const checked = q.multiSelect
? sel instanceof Set && sel.has(opt.label)
: sel === opt.label;
return (
<label
key={opt.label}
className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1 hover:tw-bg-modifier-hover"
>
<input
type={q.multiSelect ? "checkbox" : "radio"}
name={`askq-${idx}`}
checked={checked}
onChange={() => {
setSelections((prev) => {
if (q.multiSelect) {
const cur = prev[idx];
const next = new Set(cur instanceof Set ? cur : []);
if (next.has(opt.label)) next.delete(opt.label);
else next.add(opt.label);
return { ...prev, [idx]: next };
}
return { ...prev, [idx]: opt.label };
});
}}
className="tw-mt-0.5"
/>
<div className="tw-min-w-0">
<div className="tw-text-sm">{opt.label}</div>
{opt.description && (
<div className="tw-text-xs tw-text-muted">{opt.description}</div>
)}
</div>
</label>
);
})}
</div>
</div>
))}
<div className="tw-mt-2 tw-flex tw-justify-end tw-gap-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button variant="default" onClick={submit} disabled={!canSubmit}>
Submit
</Button>
</div>
</div>
);
};
/**
* Open the AskUserQuestion modal for one Claude SDK `canUseTool` invocation.
* Resolves with the answers map (or `{}` on cancel the bridge maps empty
* to a deny-with-cancelled message).
*/
export function openAskUserQuestionModal(app: App, questions: Questions): Promise<Answers> {
return new Promise((resolve) => {
const modal = new AskUserQuestionModal(app, questions, resolve);
modal.open();
});
}
class AskUserQuestionModal extends Modal {
private root: Root | null = null;
private settled = false;
constructor(
app: App,
private readonly questions: Questions,
private readonly onSettle: (answers: Answers) => void
) {
super(app);
this.titleEl.setText("Agent Mode — Question from Claude");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
this.root = createPluginRoot(contentEl, this.app);
this.root.render(
<AskUserQuestionContent
questions={this.questions}
onSubmit={(answers) => {
this.settled = true;
this.onSettle(answers);
this.close();
}}
onCancel={() => {
this.settled = true;
this.onSettle({});
this.close();
}}
/>
);
}
onClose(): void {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
if (!this.settled) {
this.settled = true;
this.onSettle({});
}
}
}

View file

@ -0,0 +1,46 @@
import { App, Modal, Setting } from "obsidian";
import { CLAUDE_INSTALL_COMMAND } from "./descriptor";
/**
* Onboarding modal shown when the `claude` CLI cannot be located. Tells
* the user the exact install command and offers a "Re-detect" button (the
* resolver runs each time the descriptor's `getInstallState` is queried).
*/
export class ClaudeInstallModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Install Claude CLI" });
contentEl.createEl("p", {
text:
"The Claude (SDK) backend requires the official `claude` CLI installed on your system. " +
"Run this command in a terminal:",
});
const code = contentEl.createEl("pre");
code.createEl("code", { text: CLAUDE_INSTALL_COMMAND });
contentEl.createEl("p", {
text:
"The Claude Agent SDK is bundled with this plugin; the `claude` CLI provides " +
"authentication and runs the model — that's why you install `@anthropic-ai/claude-code`.",
});
contentEl.createEl("p", {
text:
"Then return to Obsidian and click 'Re-detect' in Agent Mode advanced settings. " +
"Authentication is inherited from the CLI's login state — run `claude` once to sign in if " +
"you haven't already, or set ANTHROPIC_API_KEY in your shell environment.",
});
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText("Close")
.setCta()
.onClick(() => this.close())
);
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,148 @@
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import type CopilotPlugin from "@/main";
import { setSettings, useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import type { App } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
import { CLAUDE_INSTALL_COMMAND, resolveClaudeCliPath, updateClaudeFields } from "./descriptor";
interface Props {
plugin: CopilotPlugin;
app: App;
}
interface AuthEnvSummary {
bedrock: boolean;
vertex: boolean;
apiKey: boolean;
}
/**
* Read Claude SDK auth-related env vars once. The SDK resolves credentials
* through the spawned `claude` CLI, so any of these may be unset and the
* agent still works (the CLI's saved login covers it).
*/
function readAuthEnv(): AuthEnvSummary {
return {
apiKey: Boolean(process.env.ANTHROPIC_API_KEY),
bedrock: Boolean(process.env.CLAUDE_CODE_USE_BEDROCK),
vertex: Boolean(process.env.CLAUDE_CODE_USE_VERTEX),
};
}
function renderAuthDescription(env: AuthEnvSummary): React.ReactNode {
const parts: string[] = [];
if (env.apiKey) parts.push("Anthropic API key set");
if (env.bedrock) parts.push("Bedrock configured");
if (env.vertex) parts.push("Vertex configured");
if (parts.length === 0) {
return (
<span className="tw-text-muted">
No auth env vars set credentials inherit from the <code>claude</code> CLI login state.
</span>
);
}
return <div>{parts.join(" · ")}</div>;
}
/**
* Settings panel for the Claude (Agent SDK) backend. Shows the resolved
* `claude` CLI status, lets users force a re-detect, override the path, and
* inspects auth-relevant env vars. There is no managed install for this
* backend the user must install the `claude` CLI themselves.
*/
export const ClaudeSettingsPanel: React.FC<Props> = () => {
const settings = useSettingsValue();
const [, force] = React.useReducer((x: number) => x + 1, 0);
const overridePath = settings.agentMode?.claudeCli?.path ?? "";
// Each render re-walks fs.existsSync via the resolver — pressing
// "Re-detect" simply forces a new render via `force`.
const resolvedPath = resolveClaudeCliPath(settings);
const isCustom = Boolean(overridePath);
// Env doesn't change without an Obsidian restart; read once on mount.
const authEnv = React.useMemo(readAuthEnv, []);
const statusDescription = resolvedPath ? (
<>
<div>
Ready <code>claude</code>
{isCustom && <span className="tw-text-muted"> (custom)</span>}
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{resolvedPath}</div>
</>
) : (
<span className="tw-text-warning">
Setup required Claude CLI not found. Install with <code>{CLAUDE_INSTALL_COMMAND}</code>.
</span>
);
const onSaveCustomPath = React.useCallback(async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
setSettings((cur) => ({
agentMode: { ...cur.agentMode, claudeCli: { path } },
}));
new Notice("Claude CLI path saved.");
return null;
}, []);
const clearCustomPath = (): void => {
setSettings((cur) => ({
agentMode: { ...cur.agentMode, claudeCli: undefined },
}));
new Notice("Claude CLI override cleared. Auto-detection will be used.");
};
return (
<>
<SettingItem type="custom" title="Claude CLI" description={statusDescription}>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
<Button variant="secondary" onClick={force}>
Re-detect
</Button>
{isCustom && (
<Button variant="destructive" onClick={clearCustomPath}>
Clear path
</Button>
)}
</div>
</SettingItem>
<SettingItem
type="custom"
title="Use custom Claude CLI path"
description="Skip auto-detection and point Agent Mode at a `claude` binary you already have on disk. Useful for non-standard prefixes (Volta, asdf, NVM, etc.)."
>
<BinaryPathSetting
binaryName="claude"
placeholder="/absolute/path/to/claude"
initialPath={overridePath}
notFoundHint={`claude not found on PATH. Install with \`${CLAUDE_INSTALL_COMMAND}\` and try again.`}
onSave={onSaveCustomPath}
persistOnAutoDetect
/>
</SettingItem>
<SettingItem
type="custom"
title="Authentication"
description={renderAuthDescription(authEnv)}
>
<span />
</SettingItem>
<SettingItem
type="switch"
title="Show extended thinking"
description="Stream the model's reasoning blocks during a turn. Increases token usage."
checked={Boolean(settings.agentMode?.backends?.claude?.enableThinking)}
onCheckedChange={(checked) => updateClaudeFields({ enableThinking: checked })}
/>
</>
);
};

View file

@ -0,0 +1,155 @@
import {
resolveClaudeBinary,
type ClaudeBinaryResolverFs,
type ClaudeBinaryResolverInput,
} from "./claudeBinaryResolver";
function makeFs(
paths: Iterable<string>,
contents: Record<string, string> = {}
): ClaudeBinaryResolverFs {
const set = new Set(paths);
return {
existsSync: (p: string) => set.has(p),
readFileSync: (p: string) => {
if (p in contents) return contents[p];
const err: NodeJS.ErrnoException = new Error(`ENOENT: ${p}`);
err.code = "ENOENT";
throw err;
},
};
}
function unixInput(
fs: ClaudeBinaryResolverFs,
overrides: Partial<ClaudeBinaryResolverInput> = {}
): ClaudeBinaryResolverInput {
return {
homeDir: "/home/me",
platform: "linux",
env: {},
fs,
...overrides,
};
}
function winInput(
fs: ClaudeBinaryResolverFs,
overrides: Partial<ClaudeBinaryResolverInput> = {}
): ClaudeBinaryResolverInput {
return {
homeDir: "C:\\Users\\me",
platform: "win32",
env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" },
fs,
...overrides,
};
}
describe("resolveClaudeBinary — Unix", () => {
it("returns the override when it exists", () => {
const fs = makeFs(["/custom/claude"]);
const path = resolveClaudeBinary(unixInput(fs, { override: "/custom/claude" }));
expect(path).toBe("/custom/claude");
});
it("falls through to the default search when the override is missing", () => {
const fs = makeFs(["/home/me/.claude/local/claude"]);
const path = resolveClaudeBinary(unixInput(fs, { override: "/missing" }));
expect(path).toBe("/home/me/.claude/local/claude");
});
it("prefers ~/.claude/local/claude over later candidates", () => {
const fs = makeFs([
"/home/me/.claude/local/claude",
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
]);
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.claude/local/claude");
});
it("finds /opt/homebrew/bin/claude when no earlier candidate exists", () => {
const fs = makeFs(["/opt/homebrew/bin/claude"]);
expect(resolveClaudeBinary(unixInput(fs))).toBe("/opt/homebrew/bin/claude");
});
it("finds ~/.volta/bin/claude", () => {
const fs = makeFs(["/home/me/.volta/bin/claude"]);
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.volta/bin/claude");
});
it("finds ~/.asdf/shims/claude", () => {
const fs = makeFs(["/home/me/.asdf/shims/claude"]);
expect(resolveClaudeBinary(unixInput(fs))).toBe("/home/me/.asdf/shims/claude");
});
it("uses npm_config_prefix when set", () => {
const fs = makeFs(["/opt/myprefix/bin/claude"]);
const path = resolveClaudeBinary(
unixInput(fs, { env: { npm_config_prefix: "/opt/myprefix" } })
);
expect(path).toBe("/opt/myprefix/bin/claude");
});
it("falls back to NVM default alias when no other candidate exists", () => {
const aliasPath = "/home/me/.nvm/alias/default";
const claudePath = "/home/me/.nvm/versions/node/v20.11.0/bin/claude";
const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "v20.11.0\n" });
expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath);
});
it("accepts NVM alias contents without the leading 'v'", () => {
const aliasPath = "/home/me/.nvm/alias/default";
const claudePath = "/home/me/.nvm/versions/node/v18.19.0/bin/claude";
const fs = makeFs([aliasPath, claudePath], { [aliasPath]: "18.19.0" });
expect(resolveClaudeBinary(unixInput(fs))).toBe(claudePath);
});
it("returns null when NVM alias is unparseable (e.g. 'lts/*')", () => {
const aliasPath = "/home/me/.nvm/alias/default";
const fs = makeFs([aliasPath], { [aliasPath]: "lts/*" });
expect(resolveClaudeBinary(unixInput(fs))).toBeNull();
});
it("falls back to cli.js under the npm-global lib root", () => {
const fs = makeFs(["/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"]);
expect(resolveClaudeBinary(unixInput(fs))).toBe(
"/home/me/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
);
});
it("returns null when nothing is found", () => {
const fs = makeFs([]);
expect(resolveClaudeBinary(unixInput(fs))).toBeNull();
});
});
describe("resolveClaudeBinary — Windows", () => {
it("prefers claude.exe over claude.cmd in the same dir", () => {
const fs = makeFs([
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe",
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd",
]);
expect(resolveClaudeBinary(winInput(fs))).toBe(
"C:\\Users\\me\\AppData\\Roaming\\npm\\claude.exe"
);
});
it("never picks claude.cmd even when it is the only file present", () => {
const fs = makeFs(["C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"]);
expect(resolveClaudeBinary(winInput(fs))).toBeNull();
});
it("falls back to cli.js under node_modules\\@anthropic-ai\\claude-code", () => {
const cliJs =
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js";
const fs = makeFs([cliJs]);
expect(resolveClaudeBinary(winInput(fs))).toBe(cliJs);
});
it("respects the override on Windows", () => {
const override = "C:\\tools\\claude.exe";
const fs = makeFs([override]);
expect(resolveClaudeBinary(winInput(fs, { override }))).toBe(override);
});
});

View file

@ -0,0 +1,123 @@
/**
* Locate the user-installed `claude` CLI to pass as
* `pathToClaudeCodeExecutable`. The SDK's auto-discovery walks
* `import.meta.url`, which fails inside Obsidian's bundled `main.js`.
*
* Pure leaf: callers inject `homeDir`, `platform`, `env`, and `fs` so tests
* don't touch real disk.
*/
import * as path from "node:path";
export interface ClaudeBinaryResolverFs {
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: "utf8") => string;
}
export interface ClaudeBinaryResolverInput {
/** User-configured override path. If set and exists, returned as-is. */
override?: string;
homeDir: string;
platform: NodeJS.Platform;
env: { NVM_BIN?: string; npm_config_prefix?: string; APPDATA?: string };
fs: ClaudeBinaryResolverFs;
}
export function resolveClaudeBinary(input: ClaudeBinaryResolverInput): string | null {
const { override, fs } = input;
if (override && fs.existsSync(override)) {
return override;
}
const candidates = input.platform === "win32" ? windowsCandidates(input) : unixCandidates(input);
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
const posix = path.posix;
const win = path.win32;
function unixCandidates(input: ClaudeBinaryResolverInput): Array<string | null> {
const { homeDir, env, fs } = input;
return [
posix.join(homeDir, ".claude", "local", "claude"),
posix.join(homeDir, ".local", "bin", "claude"),
posix.join(homeDir, ".volta", "bin", "claude"),
posix.join(homeDir, ".asdf", "shims", "claude"),
posix.join(homeDir, ".asdf", "bin", "claude"),
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
posix.join(homeDir, ".npm-global", "bin", "claude"),
env.npm_config_prefix ? posix.join(env.npm_config_prefix, "bin", "claude") : null,
env.NVM_BIN ? posix.join(env.NVM_BIN, "claude") : null,
resolveNvmDefaultClaude(homeDir, fs),
posix.join(
homeDir,
".npm-global",
"lib",
"node_modules",
"@anthropic-ai",
"claude-code",
"cli.js"
),
env.npm_config_prefix
? posix.join(
env.npm_config_prefix,
"lib",
"node_modules",
"@anthropic-ai",
"claude-code",
"cli.js"
)
: null,
"/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js",
"/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js",
];
}
function windowsCandidates(input: ClaudeBinaryResolverInput): Array<string | null> {
const { homeDir, env } = input;
// Per-dir, prefer `claude.exe`, then `cli.js` under that dir's
// node_modules. Never pick `claude.cmd` — it requires `shell: true` and
// breaks SDK stdio streaming.
const dirs = [
env.APPDATA ? win.join(env.APPDATA, "npm") : null,
env.npm_config_prefix ?? null,
win.join(homeDir, "AppData", "Roaming", "npm"),
];
const out: Array<string | null> = [];
for (const dir of dirs) {
if (!dir) continue;
out.push(win.join(dir, "claude.exe"));
out.push(win.join(dir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"));
}
return out;
}
/**
* NVM doesn't export `NVM_BIN` to GUI applications on macOS, so reading
* `~/.nvm/alias/default` is the most reliable way to find the user's default
* Node install. The file may contain `vX.Y.Z`, `X.Y.Z`, or an unresolvable
* alias like `lts/*` the latter we silently skip.
*/
function resolveNvmDefaultClaude(homeDir: string, fs: ClaudeBinaryResolverFs): string | null {
const aliasPath = posix.join(homeDir, ".nvm", "alias", "default");
let raw: string;
try {
raw = fs.readFileSync(aliasPath, "utf8");
} catch {
return null;
}
const trimmed = raw.trim();
if (!trimmed) return null;
const versionPattern = /^v?\d+\.\d+\.\d+/;
if (!versionPattern.test(trimmed)) return null;
const version = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
return posix.join(homeDir, ".nvm", "versions", "node", version, "bin", "claude");
}

View file

@ -0,0 +1,284 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import {
getSettings,
subscribeToSettingsChange,
updateAgentModeBackendFields,
type ClaudeBackendSettings,
type CopilotSettings,
} from "@/settings/model";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveClaudeBinary } from "./claudeBinaryResolver";
import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess";
import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption";
import {
buildSkillCreationDirective,
DEFAULT_SKILLS_FOLDER,
SkillManager,
} from "@/agentMode/skills";
import type {
BackendConfigOption,
CopilotMode,
ModeMapping,
ModelSelection,
ModelWireCodec,
} from "@/agentMode/session/types";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
import { ClaudeInstallModal } from "./ClaudeInstallModal";
import ClaudeLogo from "./logo.svg";
import { ClaudeSettingsPanel } from "./ClaudeSettingsPanel";
import { openAskUserQuestionModal } from "./AskUserQuestionModal";
export const CLAUDE_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
export function updateClaudeFields(partial: Partial<ClaudeBackendSettings>): void {
updateAgentModeBackendFields("claude", partial);
}
/**
* Wire-format codec for Claude bare base id only. Effort is dispatched
* via `setSessionConfigOption`, not encoded in the model id, so `encode`
* drops `effort` and `effortConfigFor` provides the config option spec.
*/
const claudeWire: ModelWireCodec = {
encode: (selection: ModelSelection) => selection.baseModelId,
decode: (wireId: string) => ({
selection: { baseModelId: wireId, effort: null },
provider: "anthropic",
}),
effortConfigFor: (baseModelId: string): BackendConfigOption | null => {
const catalog = getCachedSdkCatalog();
if (!catalog) return null;
const modelInfo = catalog.find((m) => m.value === baseModelId);
if (!modelInfo) return null;
return synthesizeEffortConfigOption(modelInfo, undefined);
},
};
/**
* Resolve the `claude` CLI path from settings + auto-detection. Mirrors the
* `getInstallState` logic: explicit override wins, otherwise the resolver
* walks Volta/asdf/NVM/Homebrew/npm-global.
*/
export function resolveClaudeCliPath(settings: CopilotSettings): string | null {
const override = settings.agentMode?.claudeCli?.path;
return resolveClaudeBinary({
override,
homeDir: os.homedir(),
platform: process.platform,
env: {
NVM_BIN: process.env.NVM_BIN,
npm_config_prefix: process.env.npm_config_prefix,
APPDATA: process.env.APPDATA,
},
fs: {
existsSync: (p) => fs.existsSync(p),
readFileSync: (p, encoding) => fs.readFileSync(p, encoding),
},
});
}
/**
* Plan mode writes its proposal to `<claude-config-dir>/plans/<slug>.md`
* (typically `~/.claude/plans/`, but `CLAUDE_CONFIG_DIR` / `XDG_CONFIG_HOME`
* can relocate it). We suffix-match on `.claude/plans` rather than prefix-
* matching `os.homedir()` so the predicate stays correct under those env
* overrides and across platforms `path.dirname` + `path.join` produce
* native separators on macOS/Linux/Windows.
*/
function isClaudePlanModePlanFilePath(absolutePath: string): boolean {
if (!path.isAbsolute(absolutePath)) return false;
if (!absolutePath.endsWith(".md")) return false;
const dir = path.dirname(absolutePath);
return dir.endsWith(path.join(".claude", "plans"));
}
/**
* Claude backend backed by the official `@anthropic-ai/claude-agent-sdk`.
* Replaces the legacy `claude-code-acp` shim. Auth is inherited from the
* user-installed `claude` CLI's login state (or `ANTHROPIC_API_KEY` /
* Bedrock / Vertex env if configured) the SDK handles credential
* resolution through the spawned CLI; we never see or pass the secret.
*/
export const ClaudeBackendDescriptor: BackendDescriptor = {
id: "claude",
displayName: "Claude",
Icon: ClaudeLogo,
skillsProjectDir: ".claude/skills",
crossDiscoveredAgents: [],
restartOnManagedSkillsChange: false,
wire: claudeWire,
getInstallState(settings: CopilotSettings): InstallState {
const path = resolveClaudeCliPath(settings);
if (!path) return { kind: "absent" };
return {
kind: "ready",
source: settings.agentMode?.claudeCli?.path ? "custom" : "managed",
};
},
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
return subscribeToSettingsChange((prev, next) => {
if (prev.agentMode?.claudeCli?.path !== next.agentMode?.claudeCli?.path) {
cb();
}
});
},
openInstallUI(plugin: CopilotPlugin): void {
new ClaudeInstallModal(plugin.app).open();
},
isPlanModePlanFilePath(absolutePath: string): boolean {
return isClaudePlanModePlanFilePath(absolutePath);
},
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
// Claude's wire id is just the baseModelId — effort travels through
// `setConfigOption`, not the model id. Skip the model round-trip when
// the base hasn't changed, otherwise effort-only ticks would fire a
// pointless `setSessionModel` on every slider drag.
const currentBase = session.getState()?.model?.current.baseModelId;
if (currentBase !== selection.baseModelId) {
await session.setModel(claudeWire.encode(selection));
}
if (selection.effort === null) return;
const cfgOpt = claudeWire.effortConfigFor?.(selection.baseModelId);
if (!cfgOpt) return;
try {
await session.setConfigOption(cfgOpt.id, selection.effort);
} catch (e) {
if (!(e instanceof MethodUnsupportedError)) throw e;
}
},
createBackendProcess(args): BackendProcess {
const claudePath = resolveClaudeCliPath(getSettings());
if (!claudePath) {
throw new Error(
`Claude CLI not found. Install with: ${CLAUDE_INSTALL_COMMAND}, ` +
`or set agentMode.claudeCli.path in settings.`
);
}
return new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: claudePath,
app: args.app,
clientVersion: args.clientVersion,
descriptor: args.descriptor,
askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions),
getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId,
// Spawn-time skill-creation directive: read the current
// `agentMode.skills.folder` so the directive templates the live value
// on every new session. See the Skills Management spec.
//
// Claude has no cross-discovery surface — it only loads
// `.claude/skills/`, and the symlink fanout already enforces
// visibility (no link = not seen). If the Claude Agent SDK ever
// grows a per-skill deny hook, wire `composeDenyList(getManagedSkills(),
// "claude")` in here.
getSkillCreationDirective: () => {
const folder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
return buildSkillCreationDirective("claude", folder, dirs);
},
});
},
SettingsPanel: ClaudeSettingsPanel,
/**
* Map Copilot's canonical modes onto the SDK's `PermissionMode` strings.
* `acceptEdits` / `dontAsk` exist upstream but stay hidden the picker is
* a 3-mode UI (default / plan / auto). The session adapter normalizes
* unknown ids to `default`.
*/
getModeMapping(): ModeMapping {
return {
kind: "setMode",
canonical: {
default: "default",
plan: "plan",
auto: "bypassPermissions",
},
};
},
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
updateClaudeFields({ selectedMode: value });
},
/**
* Replay persisted mode + effort on a freshly created session. The
* Claude SDK adapter probes the model catalog asynchronously, so the
* effort `SessionConfigOption` may not be present yet when this runs;
* `replayPersistedEffort` subscribes to the session and applies once the
* option arrives (with a timeout guard to avoid leaking listeners on
* agents that never report effort).
*/
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
const claudeSettings = settings.agentMode?.backends?.claude;
const persistedEffort = claudeSettings?.defaultModel?.effort ?? null;
await Promise.all([
applyPersistedMode(session, claudeSettings?.selectedMode ?? "default"),
replayPersistedEffort(session, persistedEffort ?? undefined),
]);
},
};
async function replayPersistedEffort(
session: AgentSession,
persistedEffort: string | undefined
): Promise<void> {
if (!persistedEffort) return;
const tryApply = async (): Promise<boolean> => {
const state = session.getState();
const current = state?.model?.current;
if (!current) return false;
if (current.effort === persistedEffort) return true;
const entry = state?.model?.availableModels.find((e) => e.baseModelId === current.baseModelId);
if (!entry?.effortOptions.some((o) => o.value === persistedEffort)) return true;
const cfgOpt = ClaudeBackendDescriptor.wire.effortConfigFor?.(current.baseModelId);
if (!cfgOpt) return true;
try {
await session.setConfigOption(cfgOpt.id, persistedEffort);
} catch (e) {
if (e instanceof MethodUnsupportedError) return true;
logWarn(`[AgentMode] could not apply default effort ${persistedEffort}`, e);
}
return true;
};
if (await tryApply()) return;
// Effort hasn't been advertised yet — wait for the first
// config_option_update. Bound the wait so we don't keep a listener alive
// on agents that never emit an effort option.
await new Promise<void>((resolve) => {
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
unsub();
window.clearTimeout(timer);
resolve();
};
const unsub = session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: () => {},
onModelChanged: () => {
void tryApply().then((applied) => {
if (applied) finish();
});
},
});
const timer = window.setTimeout(finish, 10_000);
});
}

View file

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

View file

@ -0,0 +1 @@
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,132 @@
import { resetSettings, setSettings } from "@/settings/model";
import { CodexBackend, toTomlBasicString } from "./CodexBackend";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
jest.mock("@/agentMode/skills", () => {
const actual = jest.requireActual("@/agentMode/skills");
return {
...actual,
SkillManager: {
hasInstance: () => true,
getInstance: () => ({
getAgentDirsProjectRel: () => ({
claude: ".claude/skills",
codex: ".agents/skills",
opencode: ".opencode/skills",
}),
}),
},
};
});
describe("CodexBackend.buildSpawnDescriptor", () => {
beforeEach(() => {
resetSettings();
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
codex: { binaryPath: "/usr/local/bin/codex-acp" },
},
},
});
});
it("injects the skill-creation directive via -c developer_instructions", async () => {
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.command).toBe("/usr/local/bin/codex-acp");
const cIdx = desc.args.indexOf("-c");
expect(cIdx).toBeGreaterThanOrEqual(0);
const value = desc.args[cIdx + 1];
expect(value.startsWith("developer_instructions=")).toBe(true);
// The TOML value carries the directive text (newlines escaped as \n).
expect(value).toContain('metadata.copilot-enabled-agents: \\"codex\\"');
expect(value).toContain("copilot/skills/<name>/SKILL.md");
expect(value).toContain(".claude/skills/");
expect(value).toContain(".agents/skills/");
expect(value).toContain(".opencode/skills/");
});
it("templates a custom skills folder at spawn time", async () => {
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
skills: { folder: "team-skills" },
backends: { codex: { binaryPath: "/usr/local/bin/codex-acp" } },
},
});
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const cIdx = desc.args.indexOf("-c");
const value = desc.args[cIdx + 1];
expect(value).toContain("team-skills/<name>/SKILL.md");
expect(value).not.toContain("copilot/skills");
});
it("escapes embedded double quotes and backslashes for TOML safety", async () => {
// Folders can't contain quotes in practice (validateSkillsFolder
// strips them), but the escape logic should still be airtight — the
// resulting -c value is consumed by a TOML parser, so an unescaped
// quote would terminate the basic-string literal and break
// codex-acp's startup.
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
const cIdx = desc.args.indexOf("-c");
const value = desc.args[cIdx + 1];
// The value is wrapped in unescaped outer quotes; any inner double
// quote must be `\"` and every newline `\n` (no raw newlines, which
// would also break TOML basic strings).
expect(value).not.toMatch(/\n/);
// Confirm the outer literal is well-formed: starts with `key="…` and
// ends with `…"` (the closing quote of the TOML string).
expect(value.startsWith('developer_instructions="')).toBe(true);
expect(value.endsWith('"')).toBe(true);
});
it("escapes the full TOML basic-string control set", () => {
// Named escapes per the TOML 1.0 spec.
expect(toTomlBasicString("a\bb\tc\nd\fe\rf")).toBe('"a\\bb\\tc\\nd\\fe\\rf"');
// Backslash + double-quote.
expect(toTomlBasicString('back\\slash"quote')).toBe('"back\\\\slash\\"quote"');
// Other controls fall through as \\uXXXX. Build the input from char
// codes so the source file stays plain ASCII (and copies/pastes cleanly).
const controls =
String.fromCharCode(0x01) + String.fromCharCode(0x1f) + String.fromCharCode(0x7f);
expect(toTomlBasicString(controls)).toBe('"\\u0001\\u001f\\u007f"');
// Non-ASCII passes through unescaped.
expect(toTomlBasicString("über — café")).toBe('"über — café"');
});
it("throws when the codex binary path is unset", async () => {
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "codex",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {},
},
});
const backend = new CodexBackend();
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
/Codex binary path not configured/
);
});
});

View file

@ -0,0 +1,77 @@
import { getSettings } from "@/settings/model";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
import {
buildSkillCreationDirective,
DEFAULT_SKILLS_FOLDER,
SkillManager,
} from "@/agentMode/skills";
/**
* Spawns the user-provided `codex-acp` binary
* (`@zed-industries/codex-acp`). The package wraps the local `codex` CLI
* and exposes it as an ACP server over stdio. Authentication is inherited
* from the user's existing `codex login` (`~/.codex/auth.json`) or
* `OPENAI_API_KEY` / `CODEX_API_KEY` exported in the user's shell we
* deliberately do not inject keys so ChatGPT-login subscriptions work
* transparently.
*/
export class CodexBackend implements AcpBackend {
readonly id = "codex" as const;
readonly displayName = "Codex";
async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
const descriptor = buildSimpleSpawnDescriptor(
getSettings().agentMode?.backends?.codex?.binaryPath,
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp."
);
// Spawn-time skill-creation directive: forwarded into codex's
// `developer_instructions` config field via codex-acp's `-c key=value`
// override (see codex's `core/src/config/mod.rs`: "Developer
// instructions override injected as a separate message"). The value
// is wrapped in a TOML 1.0 basic string — the `-c` parser runs through
// TOML, so we escape per the spec rules (`\`, `"`, the named escapes
// `\b \t \n \f \r`, and remaining controls as `\uXXXX`). Folder is
// read live from settings so a setting change applies on the next
// session. See the Skills Management spec.
const skillsFolder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
const directive = buildSkillCreationDirective("codex", skillsFolder, dirs);
descriptor.args = [
...descriptor.args,
"-c",
`developer_instructions=${toTomlBasicString(directive)}`,
];
return descriptor;
}
}
/**
* Encode `value` as a TOML 1.0 basic string (double-quoted). Escapes:
* - `\` and `"`
* - named escapes `\b \t \n \f \r`
* - any other byte in 0x000x1F and 0x7F as `\uXXXX`
*
* Non-ASCII characters above 0x7F are valid in basic strings and pass
* through unescaped. Exported for unit testing.
*/
export function toTomlBasicString(value: string): string {
let out = '"';
for (let i = 0; i < value.length; i++) {
const ch = value.charCodeAt(i);
if (ch === 0x5c) out += "\\\\";
else if (ch === 0x22) out += '\\"';
else if (ch === 0x08) out += "\\b";
else if (ch === 0x09) out += "\\t";
else if (ch === 0x0a) out += "\\n";
else if (ch === 0x0c) out += "\\f";
else if (ch === 0x0d) out += "\\r";
else if (ch < 0x20 || ch === 0x7f) {
out += "\\u" + ch.toString(16).padStart(4, "0");
} else {
out += value[i];
}
}
out += '"';
return out;
}

View file

@ -0,0 +1,27 @@
import { getSettings } from "@/settings/model";
import { App } from "obsidian";
import React from "react";
import { BinaryInstallModal } from "@/agentMode/backends/shared/BinaryInstallContent";
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
export class CodexInstallModal extends BinaryInstallModal {
constructor(app: App) {
super(app, {
modalTitle: "Configure Codex (Agent backend)",
binaryDisplayName: "Codex",
binaryName: CODEX_BINARY_NAME,
installCommand: CODEX_INSTALL_COMMAND,
pathPlaceholder: "/absolute/path/to/codex-acp",
initialPath: getSettings().agentMode?.backends?.codex?.binaryPath ?? "",
description: (
<>
Codex uses the official <code>@zed-industries/codex-acp</code> adapter, which wraps the
local <code>codex</code> CLI. It inherits your existing <code>codex login</code>{" "}
credentials or set <code>OPENAI_API_KEY</code> / <code>CODEX_API_KEY</code> in your
shell if you prefer API-key auth.
</>
),
onPersist: (path) => updateCodexFields({ binaryPath: path }),
});
}
}

View file

@ -0,0 +1,26 @@
import type CopilotPlugin from "@/main";
import { getSettings } from "@/settings/model";
import type { App } from "obsidian";
import React from "react";
import { SimpleBackendSettingsPanel } from "@/agentMode/backends/shared/SimpleBackendSettingsPanel";
import { CodexInstallModal } from "./CodexInstallModal";
import { CODEX_BINARY_NAME, CODEX_INSTALL_COMMAND, updateCodexFields } from "./descriptor";
interface Props {
plugin: CopilotPlugin;
app: App;
}
export const CodexSettingsPanel: React.FC<Props> = ({ app }) => (
<SimpleBackendSettingsPanel
displayName="Codex"
binaryName={CODEX_BINARY_NAME}
installCommand={CODEX_INSTALL_COMMAND}
pathPlaceholder="/absolute/path/to/codex-acp"
customPathTitle="Custom codex-acp path"
customPathDescription="Point Agent Mode at the codex-acp binary. Codex inherits auth from your local `codex login` credentials, or from `OPENAI_API_KEY` / `CODEX_API_KEY` exported in your shell."
readStoredPath={() => getSettings().agentMode?.backends?.codex?.binaryPath ?? ""}
persistPath={(path) => updateCodexFields({ binaryPath: path })}
openInstallModal={() => new CodexInstallModal(app).open()}
/>
);

View file

@ -0,0 +1,31 @@
import { CodexBackendDescriptor } from "./descriptor";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
describe("CodexBackendDescriptor.isModelEnabledByDefault", () => {
const fn = CodexBackendDescriptor.isModelEnabledByDefault!;
it("matches gpt-5.5 family by modelId", () => {
expect(fn({ modelId: "gpt-5.5", name: "GPT-5.5" })).toBe(true);
expect(fn({ modelId: "gpt-5.5/high", name: "GPT-5.5 (high)" })).toBe(true);
});
it("matches gpt-5.5 family by display name", () => {
expect(fn({ modelId: "some-internal-id", name: "GPT-5.5" })).toBe(true);
});
it("does not match version numbers that merely contain 5.5", () => {
expect(fn({ modelId: "model-15.5-x", name: "Model 15.5x" })).toBe(false);
expect(fn({ modelId: "model-5.50", name: "Model 5.50" })).toBe(false);
});
it("rejects unrelated models", () => {
expect(fn({ modelId: "gpt-5", name: "GPT-5" })).toBe(false);
expect(fn({ modelId: "gpt-5-codex/high", name: "GPT-5 Codex (high)" })).toBe(false);
expect(fn({ modelId: "o3", name: "o3" })).toBe(false);
});
});

View file

@ -0,0 +1,157 @@
import type CopilotPlugin from "@/main";
import {
subscribeToSettingsChange,
updateAgentModeBackendFields,
type CodexBackendSettings,
type CopilotSettings,
} from "@/settings/model";
import { CodexBackend } from "./CodexBackend";
import { CodexInstallModal } from "./CodexInstallModal";
import CodexLogo from "./logo.svg";
import { CodexSettingsPanel } from "./CodexSettingsPanel";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
import {
binaryPathInstallState,
simpleBinaryBackendProcess,
} from "@/agentMode/backends/shared/simpleBinaryBackend";
import type {
CopilotMode,
ModeMapping,
ModelSelection,
ModelWireCodec,
} from "@/agentMode/session/types";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
export const CODEX_BINARY_NAME = "codex-acp";
export const CODEX_INSTALL_COMMAND = "npm install -g @zed-industries/codex-acp";
/**
* Vocabulary mirrors codex-acp's advertised efforts. `minimal` is included
* for forward-compat codex CLI accepts it as a reasoning level even though
* codex-acp doesn't currently advertise it.
*/
const KNOWN_CODEX_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
export function updateCodexFields(partial: Partial<CodexBackendSettings>): void {
updateAgentModeBackendFields("codex", partial);
}
/**
* Wire-format codec for Codex `<base>[/<effort>]`. No provider segment
* (Codex's catalog isn't routed through Copilot BYOK keys, so
* `decode().provider` stays `null`).
*/
const codexWire: ModelWireCodec = {
encode: (selection: ModelSelection) =>
selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId,
decode: (wireId: string) => {
if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null };
const segments = wireId.split("/");
if (segments.length === 1) {
return { selection: { baseModelId: wireId, effort: null }, provider: null };
}
if (segments.length === 2 && KNOWN_CODEX_EFFORTS.has(segments[1])) {
return {
selection: { baseModelId: segments[0], effort: segments[1] },
provider: null,
};
}
return { selection: { baseModelId: wireId, effort: null }, provider: null };
},
};
/**
* Codex backend wraps `@zed-industries/codex-acp`, which inherits auth
* from the local `codex` CLI login. Independent of Copilot's
* `activeModels` / BYOK keys, so the picker is fed entirely by live
* `availableModels` (active session or preloader cache).
*
* Effort is surfaced via opencode-style model-id parsing codex-acp
* advertises one model per (base × effort) combination, and we collapse
* them into a single picker row plus a sibling effort dropdown.
*/
export const CodexBackendDescriptor: BackendDescriptor = {
id: "codex",
displayName: "Codex",
Icon: CodexLogo,
skillsProjectDir: ".agents/skills",
crossDiscoveredAgents: [],
restartOnManagedSkillsChange: false,
wire: codexWire,
getInstallState(settings: CopilotSettings): InstallState {
return binaryPathInstallState(settings.agentMode?.backends?.codex?.binaryPath);
},
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
return subscribeToSettingsChange((prev, next) => {
if (
prev.agentMode?.backends?.codex?.binaryPath !== next.agentMode?.backends?.codex?.binaryPath
) {
cb();
}
});
},
openInstallUI(plugin: CopilotPlugin): void {
new CodexInstallModal(plugin.app).open();
},
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
await session.setModel(codexWire.encode(selection));
},
createBackendProcess(args): BackendProcess {
// Codex sees managed skills only via the `.agents/skills/<name>`
// symlink. The per-agent toggle drives whether the symlink exists; no
// deny synthesis is needed because Codex does not cross-discover from
// `.claude/skills/` or `.opencode/skills/`.
return simpleBinaryBackendProcess(args, new CodexBackend());
},
SettingsPanel: CodexSettingsPanel,
isModelEnabledByDefault(model) {
// Default-enable only gpt-5.5; digit-boundary on each side avoids
// matching `15.5` or `5.50`. Users widen via the Agents tab toggles.
const re = /(^|[^0-9])5\.5([^0-9]|$)/;
return re.test(model.name) || re.test(model.modelId);
},
/**
* Codex exposes sandbox/approval presets via ACP setMode: `read-only`,
* `auto`, and `full-access`. We surface all three:
* - build "auto" (workspace-write, on-request approvals)
* - plan "read-only" (no writes, no exec; closest ACP analog)
* - auto-build "full-access" (no sandbox, no approvals)
*
* Note: this is a sandbox restriction, not Codex CLI's real `ModeKind::Plan`
* (which would draft a plan artifact). That mode lives behind the app-server
* `turn/start.collaborationMode` field, which `@zed-industries/codex-acp`
* does not forward it translates ACP modes to
* `Op::OverrideTurnContext { approval_policy, sandbox_policy }` only.
* Read-only is the closest available analog: the agent can read and reason
* but cannot mutate the vault, which matches user intent for "Plan".
*/
getModeMapping(): ModeMapping {
return {
kind: "setMode",
canonical: { default: "auto", plan: "read-only", auto: "full-access" },
};
},
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
updateCodexFields({ selectedMode: value });
},
/**
* Replay the persisted mode on a freshly created session. Skipped when
* the agent doesn't advertise modes, or when the persisted mode's native
* id isn't currently in `availableModes` (filtered out by `getModeMapping`).
*/
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
const persistedMode = settings.agentMode?.backends?.codex?.selectedMode ?? "default";
await applyPersistedMode(session, persistedMode);
},
};

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,560 @@
import { ChatModelProviders } from "@/constants";
import { resetSettings, setSettings, updateSetting } from "@/settings/model";
import type { Skill } from "@/agentMode/skills";
import { buildOpencodeConfig, OPENCODE_PROVIDER_MAP, OpencodeBackend } from "./OpencodeBackend";
import { COPILOT_PROMPT_BASE, selectCopilotPrompt } from "./prompts";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
// Mock the skills package so we can drive the deny-list synthesis path
// without booting the real jotai store / Obsidian App singleton.
let mockSkills: Skill[] = [];
let mockSkillManagerReady = false;
jest.mock("@/agentMode/skills", () => {
const actual = jest.requireActual("@/agentMode/skills");
return {
...actual,
getManagedSkills: () => mockSkills,
SkillManager: {
hasInstance: () => mockSkillManagerReady,
getInstance: () => {
if (!mockSkillManagerReady) {
throw new Error("SkillManager.getInstance called before initialize");
}
return { getAgentDirsProjectRel: () => ({}) } as unknown;
},
},
};
});
function makeSkill(name: string, enabledAgents: Skill["enabledAgents"]): Skill {
return {
name,
description: `${name} skill`,
filePath: `/x/${name}/SKILL.md`,
dirPath: `/x/${name}`,
body: "",
enabledAgents,
};
}
function seedSkills(skills: Skill[]): void {
mockSkills = skills;
mockSkillManagerReady = skills.length > 0;
}
/**
* Most tests below disable the built-in `activeModels` so injection is a
* blank slate. The few that exercise injection set their own active models
* explicitly.
*/
function clearActiveModels() {
setSettings({ activeModels: [] });
}
describe("buildOpencodeConfig", () => {
beforeEach(() => {
resetSettings();
clearActiveModels();
seedSkills([]);
});
it("emits provider entries only for non-empty keys", async () => {
updateSetting("anthropicApiKey", "anth-123");
updateSetting("openAIApiKey", "");
updateSetting("googleApiKey", "g-456");
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(Object.keys(cfg.provider).sort()).toEqual(["anthropic", "google"]);
expect(cfg.provider.anthropic).toEqual({ options: { apiKey: "anth-123" } });
expect(cfg.provider.google).toEqual({ options: { apiKey: "g-456" } });
});
it("returns empty provider map when no keys are set", async () => {
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("injects enabled active models under their provider's `models` map", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
activeModels: [
{
name: "claude-sonnet-4-6",
provider: ChatModelProviders.ANTHROPIC,
enabled: true,
},
{
name: "claude-haiku",
provider: ChatModelProviders.ANTHROPIC,
enabled: false, // disabled — should NOT inject
},
],
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { options?: unknown; models?: Record<string, unknown> }>;
};
expect(cfg.provider.anthropic.models).toEqual({ "claude-sonnet-4-6": {} });
});
it("does not inject a model when neither top-level nor per-model key is available", async () => {
setSettings({
activeModels: [
{
name: "gpt-5",
provider: ChatModelProviders.OPENAI,
enabled: true,
},
],
});
// Neither openAIApiKey nor model.apiKey configured
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("falls back to per-model apiKey when top-level provider key is missing", async () => {
setSettings({
activeModels: [
{
name: "gpt-5",
provider: ChatModelProviders.OPENAI,
enabled: true,
apiKey: "per-model-key",
},
],
});
// No openAIApiKey configured globally, but the model carries its own key.
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { options?: { apiKey?: string }; models?: Record<string, unknown> }>;
};
expect(cfg.provider.openai.options).toEqual({ apiKey: "per-model-key" });
expect(cfg.provider.openai.models).toEqual({ "gpt-5": {} });
});
it("prefers the top-level provider key when both are present", async () => {
updateSetting("openAIApiKey", "global-key");
setSettings({
activeModels: [
{
name: "gpt-5",
provider: ChatModelProviders.OPENAI,
enabled: true,
apiKey: "per-model-key",
},
],
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { options?: { apiKey?: string } }>;
};
// Top-level wins because the provider entry is built before the
// per-model fallback runs — keeps the historical behaviour.
expect(cfg.provider.openai.options).toEqual({ apiKey: "global-key" });
});
it("does not inject models for providers OpenCode cannot route", async () => {
setSettings({
activeModels: [
{
name: "claude-via-bedrock",
provider: ChatModelProviders.AMAZON_BEDROCK,
enabled: true,
},
{
name: "llama",
provider: ChatModelProviders.OLLAMA,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider).toEqual({});
});
it("skips embedding models", async () => {
updateSetting("openAIApiKey", "key");
setSettings({
activeModels: [
{
name: "text-embedding-3-large",
provider: ChatModelProviders.OPENAI,
enabled: true,
isEmbeddingModel: true,
},
{
name: "gpt-test",
provider: ChatModelProviders.OPENAI,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<string, { models?: Record<string, unknown> }>;
};
// gpt-test is injected; the embedding model is not, even though both have
// the same provider and enabled flag.
expect(cfg.provider.openai.models).toHaveProperty("gpt-test");
expect(cfg.provider.openai.models).not.toHaveProperty("text-embedding-3-large");
});
it("sets top-level model from the persisted defaultModel.baseModelId", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
binaryPath: "/x",
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: null },
},
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6");
});
it("appends effort suffix when defaultModel.effort is set", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
binaryPath: "/x",
defaultModel: { baseModelId: "anthropic/claude-sonnet-4-6", effort: "high" },
},
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("anthropic/claude-sonnet-4-6/high");
});
it("registers a custom copilot-plus provider when plusLicenseKey is set", async () => {
updateSetting("plusLicenseKey", "plus-token-123");
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as {
provider: Record<
string,
{
npm?: string;
name?: string;
options?: { baseURL?: string; apiKey?: string };
models?: Record<string, unknown>;
}
>;
};
const cp = cfg.provider["copilot-plus"];
expect(cp.npm).toBe("@ai-sdk/openai-compatible");
expect(cp.name).toBe("Copilot Plus");
expect(cp.options?.baseURL).toBe("https://models.brevilabs.com/v1");
expect(cp.options?.apiKey).toBe("plus-token-123");
expect(cp.models).toEqual({ "copilot-plus-flash": {} });
});
it("does not register copilot-plus provider when plusLicenseKey is empty", async () => {
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
});
const cfg = (await buildOpencodeConfig()) as { provider: Record<string, unknown> };
expect(cfg.provider["copilot-plus"]).toBeUndefined();
});
it("uses a Copilot-Plus-shaped defaultModel.baseModelId verbatim", async () => {
updateSetting("plusLicenseKey", "plus-token-123");
setSettings({
activeModels: [
{
name: "copilot-plus-flash",
provider: ChatModelProviders.COPILOT_PLUS,
enabled: true,
},
],
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
binaryPath: "/x",
defaultModel: { baseModelId: "copilot-plus/copilot-plus-flash", effort: null },
},
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBe("copilot-plus/copilot-plus-flash");
});
it("sets default_agent from persisted selectedMode (canonical → native id)", async () => {
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
selectedMode: "default",
},
},
},
});
const defaultCfg = (await buildOpencodeConfig()) as { default_agent?: string };
expect(defaultCfg.default_agent).toBe("copilot-build");
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: { opencode: { selectedMode: "auto" } },
},
});
const autoCfg = (await buildOpencodeConfig()) as { default_agent?: string };
expect(autoCfg.default_agent).toBe("build");
});
it("falls back to canonical default (copilot-build) when no mode is persisted", async () => {
const cfg = (await buildOpencodeConfig()) as { default_agent?: string };
expect(cfg.default_agent).toBe("copilot-build");
});
it("overrides system prompt on both build and copilot-build agents", async () => {
const cfg = (await buildOpencodeConfig()) as {
agent: Record<string, { prompt?: string; permission?: unknown; mode?: string }>;
};
// Prompt now starts with the COPILOT_PROMPT_BASE and ends with the
// spawn-time skill-creation directive (see the Skills Management
// spec). Assert the base is the prefix so future directive changes
// don't break this test.
expect(cfg.agent["copilot-build"].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
expect(cfg.agent.build.prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
expect(cfg.agent["copilot-build"].prompt).toContain(
'metadata.copilot-enabled-agents: "opencode"'
);
expect(cfg.agent.build.prompt).toContain('metadata.copilot-enabled-agents: "opencode"');
// Regression guard: the copilot-build permission block must survive
// alongside the new prompt field — opencode's field-wise merge depends
// on us not stomping native fields.
expect(cfg.agent["copilot-build"].permission).toEqual({ bash: "ask", edit: "ask" });
expect(cfg.agent["copilot-build"].mode).toBe("primary");
});
it("templates a custom skills folder into the opencode directive", async () => {
setSettings({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "team-skills" },
backends: {},
},
});
const cfg = (await buildOpencodeConfig()) as {
agent: Record<string, { prompt?: string }>;
};
expect(cfg.agent["copilot-build"].prompt).toContain("<vault>/team-skills/<name>/SKILL.md");
expect(cfg.agent.build.prompt).toContain("<vault>/team-skills/<name>/SKILL.md");
});
it("denies a skill enabled for Claude only (cross-discovered, not enabled for opencode)", async () => {
seedSkills([makeSkill("foo", ["claude"])]);
const cfg = (await buildOpencodeConfig()) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission?.skill?.foo).toBe("deny");
});
it("does not deny a skill enabled for both Claude and OpenCode", async () => {
seedSkills([makeSkill("foo", ["claude", "opencode"])]);
const cfg = (await buildOpencodeConfig()) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission?.skill?.foo).toBeUndefined();
});
it("does not emit a permission.skill block when no skills need denying", async () => {
seedSkills([makeSkill("foo", ["opencode"])]);
const cfg = (await buildOpencodeConfig()) as {
permission?: { skill?: Record<string, string> };
};
expect(cfg.permission).toBeUndefined();
});
it("does not emit a permission.skill block when there are no skills at all", async () => {
seedSkills([]);
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
expect(cfg.permission).toBeUndefined();
});
it("synthesises deny rules for a mix of skills (only cross-discovered + not-enabled wins)", async () => {
seedSkills([
makeSkill("a", ["claude"]),
makeSkill("b", ["claude", "opencode"]),
makeSkill("c", []),
makeSkill("d", ["opencode"]),
makeSkill("e", ["codex"]),
]);
const cfg = (await buildOpencodeConfig()) as {
permission?: { skill?: Record<string, string> };
};
// a is claude-only → denied. e is codex-only → denied (codex also
// populates the cross-discovered `.agents/skills/` path). b/c/d not denied.
expect(cfg.permission?.skill).toEqual({ a: "deny", e: "deny" });
});
it("skips deny synthesis when SkillManager has not initialised yet", async () => {
// Place a skill in the snapshot, but mark the singleton as not ready.
mockSkills = [makeSkill("foo", ["claude"])];
mockSkillManagerReady = false;
const cfg = (await buildOpencodeConfig()) as { permission?: unknown };
expect(cfg.permission).toBeUndefined();
});
it("omits cfg.model when no defaultModel is set", async () => {
updateSetting("anthropicApiKey", "anth-123");
setSettings({
activeModels: [],
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: { binaryPath: "/x" },
},
},
});
const cfg = (await buildOpencodeConfig()) as { model?: string };
expect(cfg.model).toBeUndefined();
});
});
describe("OpencodeBackend.buildSpawnDescriptor", () => {
beforeEach(() => {
resetSettings();
clearActiveModels();
});
it("throws if no binary is installed", async () => {
const backend = new OpencodeBackend();
await expect(backend.buildSpawnDescriptor({ vaultBasePath: "/vault" })).rejects.toThrow(
/binary not installed/
);
});
it("uses agentMode.backends.opencode.binaryPath as command and passes cwd in args", async () => {
updateSetting("agentMode", {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "opencode",
debugFullFrames: false,
skills: { folder: "copilot/skills" },
backends: {
opencode: {
binaryPath: "/path/to/opencode",
binaryVersion: "1.3.17",
binarySource: "managed",
},
},
});
updateSetting("anthropicApiKey", "anth-xyz");
const backend = new OpencodeBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault/abs" });
expect(desc.command).toBe("/path/to/opencode");
expect(desc.args).toEqual(["acp", "--cwd", "/vault/abs"]);
expect(desc.env.OPENCODE_CONFIG_CONTENT).toBeDefined();
const cfg = JSON.parse(desc.env.OPENCODE_CONFIG_CONTENT as string);
expect(cfg.provider.anthropic.options).toEqual({ apiKey: "anth-xyz" });
});
});
describe("selectCopilotPrompt", () => {
it("returns COPILOT_PROMPT_BASE for any model id (no per-provider variants yet)", () => {
expect(selectCopilotPrompt(undefined)).toBe(COPILOT_PROMPT_BASE);
expect(selectCopilotPrompt("copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE);
expect(selectCopilotPrompt("copilot-plus/copilot-plus-flash")).toBe(COPILOT_PROMPT_BASE);
expect(selectCopilotPrompt("anthropic/claude-sonnet-4-6")).toBe(COPILOT_PROMPT_BASE);
expect(selectCopilotPrompt("google/gemini-2.5-flash")).toBe(COPILOT_PROMPT_BASE);
});
});
describe("COPILOT_PROMPT_BASE", () => {
it("establishes Obsidian Copilot identity, not a CLI/coding agent", () => {
expect(COPILOT_PROMPT_BASE).toMatch(/Obsidian Copilot/);
expect(COPILOT_PROMPT_BASE).toMatch(/NOT a software-engineering agent or CLI coding tool/);
});
it("does not carry chat-mode-only baggage that misfires in tool-driven agents", () => {
// @vault and getCurrentTime/getTimeRangeMs are chat-mode injections that
// do not exist in opencode. YouTube auto-transcription is also chat-only.
expect(COPILOT_PROMPT_BASE).not.toMatch(/@vault/);
expect(COPILOT_PROMPT_BASE).not.toMatch(/getCurrentTime/);
expect(COPILOT_PROMPT_BASE).not.toMatch(/getTimeRangeMs/);
expect(COPILOT_PROMPT_BASE).not.toMatch(/YouTube/);
});
it("ports AGENT_LOOP_GUIDANCE behavior bullets", () => {
expect(COPILOT_PROMPT_BASE).toMatch(/NEVER search for the same/);
});
});
describe("OPENCODE_PROVIDER_MAP", () => {
it("includes the eight BYOK-mapped providers plus Copilot Plus", () => {
expect(Object.keys(OPENCODE_PROVIDER_MAP).sort()).toEqual(
[
ChatModelProviders.ANTHROPIC,
ChatModelProviders.COPILOT_PLUS,
ChatModelProviders.DEEPSEEK,
ChatModelProviders.GOOGLE,
ChatModelProviders.GROQ,
ChatModelProviders.MISTRAL,
ChatModelProviders.OPENAI,
ChatModelProviders.OPENROUTERAI,
ChatModelProviders.XAI,
].sort()
);
});
});

View file

@ -0,0 +1,315 @@
import { BREVILABS_MODELS_BASE_URL, ChatModelProviders } from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
import { logInfo, logWarn } from "@/logger";
import { getSettings } from "@/settings/model";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import type { CopilotMode } from "@/agentMode/session/types";
import {
buildSkillCreationDirective,
composeDenyList,
DEFAULT_SKILLS_FOLDER,
getManagedSkills,
SkillManager,
} from "@/agentMode/skills";
import { OpencodeBackendDescriptor } from "./descriptor";
import { selectCopilotPrompt } from "./prompts";
/**
* Map from Copilot's `ChatModelProviders` enum value (as stored in
* `CustomModel.provider`) to OpenCode's provider id (as it appears in
* OpenCode's `availableModels` and config). Only providers in this map are
* routable through OpenCode; everything else (Azure, Bedrock, Ollama,
* LM Studio, GitHub Copilot, etc.) is filtered out of the picker.
*
* Copilot Plus is handled separately because it isn't a built-in OpenCode
* provider we register it as a custom `@ai-sdk/openai-compatible` entry
* pointing at brevilabs and authed via the user's `plusLicenseKey`.
*/
export const OPENCODE_PROVIDER_MAP: Partial<Record<ChatModelProviders, string>> = {
[ChatModelProviders.ANTHROPIC]: "anthropic",
[ChatModelProviders.OPENAI]: "openai",
[ChatModelProviders.GOOGLE]: "google",
[ChatModelProviders.GROQ]: "groq",
[ChatModelProviders.MISTRAL]: "mistral",
[ChatModelProviders.DEEPSEEK]: "deepseek",
[ChatModelProviders.OPENROUTERAI]: "openrouter",
[ChatModelProviders.XAI]: "xai",
[ChatModelProviders.COPILOT_PLUS]: "copilot-plus",
};
/** OpenCode provider id reserved for Copilot Plus's brevilabs proxy. */
const COPILOT_PLUS_PROVIDER_ID = "copilot-plus";
/**
* Custom OpenCode agent id provisioned via `OPENCODE_CONFIG_CONTENT`. Maps
* to Copilot's canonical `default` mode (writes/exec allowed, but the user
* approves each request). The built-in `build` agent doesn't ask.
*/
export const OPENCODE_COPILOT_BUILD_AGENT_ID = "copilot-build";
/** OpenCode's built-in build agent id (full perms, no permission asks). */
export const OPENCODE_BUILTIN_BUILD_AGENT_ID = "build";
/**
* Shared canonicalnative agent-id mapping for OpenCode. Used both at spawn
* time (`buildOpencodeConfig` sets `default_agent`) and at runtime (the
* descriptor's `getModeMapping` for `session/set_config_option`). Keeping
* one source of truth so the spawn-time default and the runtime picker
* never disagree. Plan mode is intentionally absent opencode's plan
* agent has no ACP-visible finalization tool, so we don't expose it.
*/
export const OPENCODE_CANONICAL_MODE_AGENT_IDS: Partial<Record<CopilotMode, string>> = {
default: OPENCODE_COPILOT_BUILD_AGENT_ID,
auto: OPENCODE_BUILTIN_BUILD_AGENT_ID,
};
/**
* Spawns `opencode acp --cwd <vault>` with `OPENCODE_CONFIG_CONTENT`
* containing decrypted BYOK keys pulled from the existing Copilot settings.
*
* Reuses Copilot's top-level `*ApiKey` fields so users don't have to re-enter
* them in an Agent Mode-specific settings panel.
*/
export class OpencodeBackend implements AcpBackend {
readonly id = "opencode" as const;
readonly displayName = "opencode";
async buildSpawnDescriptor(ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
const binaryPath = getSettings().agentMode?.backends?.opencode?.binaryPath;
if (!binaryPath) {
throw new Error(
"opencode binary not installed. Open Agent Mode settings and install it before starting a session."
);
}
const config = await buildOpencodeConfig();
return {
command: binaryPath,
args: ["acp", "--cwd", ctx.vaultBasePath],
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
},
};
}
}
/**
* Build the `OPENCODE_CONFIG_CONTENT` payload from current Copilot settings.
*
* - Per-provider `options.apiKey` is set for any BYOK key configured in
* Copilot, decrypted in-process.
* - Each enabled `activeModel` whose provider is in `OPENCODE_PROVIDER_MAP`
* is registered under `provider.<id>.models.<modelName>` so OpenCode
* reports it in `NewSessionResponse.models.availableModels`. Built-in
* providers (anthropic, openai, ) carry their own models.dev snapshot
* so this is largely additive there; for the custom Copilot Plus
* provider and for OpenRouter models the snapshot doesn't cover
* the registration is what makes the model visible at all. The
* Agents-tab catalog modal then curates from opencode's reported
* `availableModels`.
* - The top-level `model` field carries the user's sticky preference so
* a fresh session boots with the right default, even before
* `unstable_setSessionModel` is called.
*
* Exported for unit tests.
*/
export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
const s = getSettings();
type Mapping = { providerId: string; settingsKey: keyof typeof s };
const mappings: Mapping[] = [
{ providerId: "anthropic", settingsKey: "anthropicApiKey" },
{ providerId: "openai", settingsKey: "openAIApiKey" },
{ providerId: "google", settingsKey: "googleApiKey" },
{ providerId: "groq", settingsKey: "groqApiKey" },
{ providerId: "mistral", settingsKey: "mistralApiKey" },
{ providerId: "deepseek", settingsKey: "deepseekApiKey" },
{ providerId: "openrouter", settingsKey: "openRouterAiApiKey" },
{ providerId: "xai", settingsKey: "xaiApiKey" },
];
const decrypted = await Promise.all(
mappings.map(async (m) => {
const raw = s[m.settingsKey];
if (typeof raw !== "string" || !raw) return null;
const apiKey = await getDecryptedKey(raw);
if (!apiKey) return null;
return { providerId: m.providerId, apiKey };
})
);
type ProviderConfig = {
npm?: string;
name?: string;
options?: { apiKey?: string; baseURL?: string; headers?: Record<string, string> };
models?: Record<string, Record<string, unknown>>;
};
const provider: Record<string, ProviderConfig> = {};
for (const entry of decrypted) {
if (entry) provider[entry.providerId] = { options: { apiKey: entry.apiKey } };
}
// Copilot Plus speaks OpenAI's wire format but isn't a built-in OpenCode
// provider. Register it as a custom `@ai-sdk/openai-compatible` entry
// pointing at brevilabs and authed via the user's `plusLicenseKey`.
if (typeof s.plusLicenseKey === "string" && s.plusLicenseKey) {
const licenseKey = await getDecryptedKey(s.plusLicenseKey);
if (licenseKey) {
provider[COPILOT_PLUS_PROVIDER_ID] = {
npm: "@ai-sdk/openai-compatible",
name: "Copilot Plus",
options: { baseURL: BREVILABS_MODELS_BASE_URL, apiKey: licenseKey },
};
}
}
// Register Copilot-configured models under their respective providers so
// OpenCode treats them as known when reporting `availableModels`. When the
// top-level provider key is absent, fall back to the per-model `apiKey` so
// models the user configured with a model-specific key still reach the
// agent. Without this fallback any such model would be silently dropped.
const injected: string[] = [];
for (const model of s.activeModels ?? []) {
if (!model.enabled) continue;
if (model.isEmbeddingModel) continue;
const providerId = OPENCODE_PROVIDER_MAP[model.provider as ChatModelProviders];
if (!providerId) continue;
if (!provider[providerId]) {
const perModel = model.apiKey ? await getDecryptedKey(model.apiKey) : null;
if (!perModel) {
logWarn(
`[AgentMode] skipping ${model.provider}/${model.name}: no API key (set the provider key in Copilot settings or on the model itself)`
);
continue;
}
provider[providerId] = { options: { apiKey: perModel } };
}
if (!provider[providerId].models) provider[providerId].models = {};
provider[providerId].models[model.name] = {};
injected.push(`${providerId}/${model.name}`);
}
if (injected.length > 0) {
logInfo(
`[AgentMode] injected ${injected.length} model(s) into opencode config: ${injected.join(", ")}`
);
} else if (Object.keys(provider).length === 0) {
logInfo(
"[AgentMode] no BYOK keys found; opencode will rely on its own auth. Set provider keys in Copilot settings to use Agent Mode end-to-end."
);
}
const config: Record<string, unknown> = { provider };
// Inject a managed `copilot-build` agent so the mode picker can offer the
// canonical "default" semantic — let the agent edit, but ask first. The
// built-in `build` agent never asks (used as our `auto` mode); it doesn't
// cover ask-before-write, hence the custom agent.
//
// Both agents also carry a Copilot-authored `prompt` that overrides
// opencode's provider-default prompt picker (`session/system.ts`). Without
// this override, Copilot Plus model names (e.g. `copilot-plus-flash`) miss
// every substring branch and fall through to the generic `default.txt`
// CLI-coding-agent prompt — wrong domain for an Obsidian vault assistant.
// opencode's `cfg.agent.<id>` merge is field-wise, so adding `prompt` to
// the built-in `build` agent leaves its native permissions intact.
const basePrompt = selectCopilotPrompt(
s.agentMode?.backends?.opencode?.defaultModel?.baseModelId
);
// Append the spawn-time skill-creation directive so agent-authored skills
// land in the canonical managed folder instead of `.opencode/skills/`.
// Folder is read live from settings on each spawn — see the Skills
// Management spec.
const skillsFolder = s.agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const skillManagerReady = SkillManager.hasInstance();
const skillsDirs = skillManagerReady
? Object.values(SkillManager.getInstance().getAgentDirsProjectRel())
: [];
const prompt = `${basePrompt}\n\n${buildSkillCreationDirective("opencode", skillsFolder, skillsDirs)}`;
config.agent = {
[OPENCODE_BUILTIN_BUILD_AGENT_ID]: {
prompt,
},
[OPENCODE_COPILOT_BUILD_AGENT_ID]: {
mode: "primary",
permission: { bash: "ask", edit: "ask" },
prompt,
},
};
// Apply sticky model preference at spawn so the very first turn (before
// `unstable_setSessionModel` lands) uses the user's pick. The persisted
// shape is `{ baseModelId, effort }` where `baseModelId` is opencode's
// `<provider>/<model>` form — append the effort suffix when present.
const defaultModel = s.agentMode?.backends?.opencode?.defaultModel;
if (defaultModel?.baseModelId) {
config.model = defaultModel.effort
? `${defaultModel.baseModelId}/${defaultModel.effort}`
: defaultModel.baseModelId;
}
// Apply sticky mode preference at spawn via OpenCode's `default_agent` so
// the first turn already runs in the user's chosen agent — closes the
// cold-start gap where the `mode` configOption isn't yet registered.
// Falls back to canonical `default` (ask-before-write `copilot-build`);
// otherwise OpenCode would land on its no-ask built-in `build` agent.
const selectedMode = s.agentMode?.backends?.opencode?.selectedMode ?? "default";
config.default_agent =
OPENCODE_CANONICAL_MODE_AGENT_IDS[selectedMode] ?? OPENCODE_COPILOT_BUILD_AGENT_ID;
// Synthesize deny rules for managed skills that OpenCode would
// cross-discover (via `.claude/skills/` and `.agents/skills/`) but are
// not enabled for OpenCode in their `metadata.copilot-enabled-agents`.
// Read SkillManager live at spawn time — same pattern as the
// skill-creation directive. If SkillManager isn't initialised yet (plugin
// still booting; OpenCode session spawned before the Skills tab has
// hydrated), fall back to an empty deny list — the next reconciliation
// pass + session restart closes the eventual-consistency window.
//
// Note: we intentionally do NOT set `OPENCODE_DISABLE_EXTERNAL_SKILLS` or
// `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS`. We want OpenCode to walk the
// cross-discovery paths so the per-name `permission.skill.<name> = "deny"`
// entries below can take effect.
if (!skillManagerReady) {
// SkillManager initialises asynchronously from `main.ts onload`. If
// OpenCode spawns before that finishes, we ship an empty deny list
// for this session — the next OpenCode spawn (after SkillManager
// hydrates) gets the correct one.
logInfo(
"[AgentMode] SkillManager not yet initialised at OpenCode spawn — shipping empty deny list; next session will pick it up."
);
}
const managedSkills = skillManagerReady ? getManagedSkills() : [];
const denyNames = composeDenyList(
managedSkills,
OpencodeBackendDescriptor.id,
OpencodeBackendDescriptor.crossDiscoveredAgents
);
if (denyNames.length > 0) {
// Be additive: opencode's config schema allows `permission` as a
// top-level key with sub-fields (`skill`, `tool`, `write`, …). Preserve
// any existing `permission.*` settings the user may have provided
// through other surfaces.
const existingPermission = config.permission as Record<string, unknown> | undefined;
const existingSkillMap = existingPermission?.skill as Record<string, string> | undefined;
const mergedSkillMap: Record<string, string> = { ...(existingSkillMap ?? {}) };
for (const name of denyNames) {
// User-provided entries win — if the user explicitly allowed a skill
// we'd otherwise deny, respect their override.
if (!(name in mergedSkillMap)) mergedSkillMap[name] = "deny";
}
config.permission = {
...(existingPermission ?? {}),
skill: mergedSkillMap,
};
logInfo(
`[AgentMode] opencode deny list: ${denyNames.length} cross-discovered skill(s) denied (${denyNames.join(", ")})`
);
}
return config;
}

View file

@ -0,0 +1,317 @@
jest.mock("obsidian", () => ({
// pickMatchingAsset is pure; FileSystemAdapter and requestUrl are
// referenced by the manager class but not by these tests.
FileSystemAdapter: class {},
requestUrl: jest.fn(),
}));
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
// 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 = {
binaryPath?: string;
binaryVersion?: string;
binarySource?: "managed" | "custom";
};
type AgentMode = {
enabled?: boolean;
activeBackend?: string;
backends?: { opencode?: OpencodeSlice };
};
type Store = { agentMode: AgentMode };
let store: Store = {
agentMode: { backends: { opencode: {} } },
};
return {
__esModule: true,
__reset: (initial: OpencodeSlice = {}) => {
store = { agentMode: { backends: { opencode: { ...initial } } } };
},
__get: () => store.agentMode.backends?.opencode ?? {},
getSettings: () => store,
setSettings: (settings: Partial<Store> | ((current: Store) => Partial<Store>)) => {
const partial = typeof settings === "function" ? settings(store) : settings;
store = { ...store, ...partial };
},
};
});
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
computeInstallState,
OpencodeBinaryManager,
parseVersionFromStdout,
pickMatchingAsset,
verifyOpencodeBinary,
} from "./OpencodeBinaryManager";
// Pull the mock helpers off the mocked module without TS complaints.
const settingsMock = jest.requireMock("@/settings/model");
// Minimal CopilotPlugin stand-in. Only `manifest.id` and `app.vault.adapter`
// are touched by the methods under test, and only via getDataDir() (which we
// don't exercise here).
const fakePlugin = {
app: { vault: { adapter: {} } },
manifest: { id: "copilot-test" },
} as never;
describe("pickMatchingAsset", () => {
const release = {
tag_name: "v1.14.24",
assets: [
{
name: "opencode-darwin-arm64.zip",
size: 100,
browser_download_url: "https://example.com/opencode-darwin-arm64.zip",
},
{
name: "opencode-linux-x64.tar.gz",
size: 100,
browser_download_url: "https://example.com/opencode-linux-x64.tar.gz",
},
{
name: "opencode-linux-x64-musl.tar.gz",
size: 100,
browser_download_url: "https://example.com/opencode-linux-x64-musl.tar.gz",
},
{
name: "opencode-windows-x64.zip",
size: 100,
browser_download_url: "https://example.com/opencode-windows-x64.zip",
},
],
};
it("picks the first matching candidate stem", () => {
const asset = pickMatchingAsset(release, ["opencode-darwin-arm64"]);
expect(asset.name).toBe("opencode-darwin-arm64.zip");
});
it("falls back to the next candidate when the preferred one is missing", () => {
const asset = pickMatchingAsset(release, [
"opencode-linux-x64-musl-baseline",
"opencode-linux-x64-musl",
"opencode-linux-x64",
]);
expect(asset.name).toBe("opencode-linux-x64-musl.tar.gz");
});
it("strips .tar.gz before matching stems", () => {
const asset = pickMatchingAsset(release, ["opencode-linux-x64"]);
expect(asset.name).toBe("opencode-linux-x64.tar.gz");
});
it("throws when no candidate matches", () => {
expect(() => pickMatchingAsset(release, ["opencode-windows-arm64"])).toThrow(
/No matching opencode release asset/
);
});
});
describe("verifyOpencodeBinary", () => {
// We use the running Node binary as a stand-in for any executable that
// accepts `--version` and exits 0. This exercises the success path without
// requiring a real opencode binary on disk.
it("resolves when the binary returns 0 to --version", async () => {
const result = await verifyOpencodeBinary(process.execPath);
expect(result.stdout).toMatch(/^v\d+\./);
});
it("throws ENOENT-style error for a non-existent path", async () => {
await expect(verifyOpencodeBinary("/definitely/not/a/real/path/opencode")).rejects.toThrow(
/No file at/
);
});
});
describe("computeInstallState", () => {
it("absent when no path is set", () => {
expect(computeInstallState({})).toEqual({ kind: "absent" });
expect(computeInstallState(undefined)).toEqual({ kind: "absent" });
});
it("absent when path is set but version is missing", () => {
// We no longer surface a path-only state — without a version we can't
// tell what binary the user is pointing at, so the manager forces
// install/setCustomBinaryPath to populate both fields together.
expect(computeInstallState({ binaryPath: "/p" })).toEqual({ kind: "absent" });
});
it("installed (managed) when source is missing — legacy data defaults to managed", () => {
expect(computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3" })).toEqual({
kind: "installed",
version: "1.2.3",
path: "/p",
source: "managed",
});
});
it("installed with explicit source preserves the value", () => {
expect(
computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "custom" })
).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "custom" });
expect(
computeInstallState({ binaryPath: "/p", binaryVersion: "1.2.3", binarySource: "managed" })
).toEqual({ kind: "installed", version: "1.2.3", path: "/p", source: "managed" });
});
});
describe("parseVersionFromStdout", () => {
it.each([
["1.14.24", "1.14.24"],
["v1.14.24", "1.14.24"],
["opencode 1.14.24", "1.14.24"],
["opencode\nversion: 1.14.24\n", "1.14.24"],
["1.14.24-rc.1", "1.14.24-rc.1"],
["1.14.24+build.5", "1.14.24+build.5"],
])("parses %j → %s", (input, expected) => {
expect(parseVersionFromStdout(input)).toBe(expected);
});
it("returns undefined when no semver-shaped token is present", () => {
expect(parseVersionFromStdout("not a version")).toBeUndefined();
expect(parseVersionFromStdout("")).toBeUndefined();
expect(parseVersionFromStdout("1.2")).toBeUndefined();
});
});
describe("OpencodeBinaryManager.refreshInstallState", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-mgr-"));
});
afterEach(async () => {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
it("no-op for absent state", async () => {
settingsMock.__reset({});
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.refreshInstallState();
expect(settingsMock.__get()).toEqual({});
});
it("no-op for custom-source installs even if the file is missing", async () => {
// Custom paths are validated at config time and shouldn't be re-checked
// on every plugin load — a transient mount issue shouldn't wipe user state.
const ghost = path.join(tmpDir, "does-not-exist", "opencode");
settingsMock.__reset({
binaryPath: ghost,
binaryVersion: "1.14.24",
binarySource: "custom",
});
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.refreshInstallState();
expect(settingsMock.__get()).toEqual({
binaryPath: ghost,
binaryVersion: "1.14.24",
binarySource: "custom",
});
});
it("clears settings when persisted managed binary is missing on disk", async () => {
const ghost = path.join(tmpDir, "does-not-exist", "opencode");
settingsMock.__reset({
binaryPath: ghost,
binaryVersion: "1.14.24",
binarySource: "managed",
});
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.refreshInstallState();
expect(settingsMock.__get()).toEqual({
binaryPath: undefined,
binaryVersion: undefined,
binarySource: undefined,
});
});
it("leaves settings intact when persisted managed binary is present", async () => {
const realFile = path.join(tmpDir, "opencode");
await fs.promises.writeFile(realFile, "");
settingsMock.__reset({
binaryPath: realFile,
binaryVersion: "1.14.24",
binarySource: "managed",
});
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.refreshInstallState();
expect(settingsMock.__get()).toEqual({
binaryPath: realFile,
binaryVersion: "1.14.24",
binarySource: "managed",
});
});
});
describe("OpencodeBinaryManager.setCustomBinaryPath", () => {
let tmpDir: string;
beforeEach(async () => {
settingsMock.__reset({});
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "opencode-custom-"));
});
afterEach(async () => {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
});
it("rejects when the file does not exist", async () => {
const mgr = new OpencodeBinaryManager(fakePlugin);
await expect(mgr.setCustomBinaryPath(path.join(tmpDir, "nope"))).rejects.toThrow(/No file at/);
expect(settingsMock.__get()).toEqual({});
});
it("rejects when the path is a directory, not a file", async () => {
const mgr = new OpencodeBinaryManager(fakePlugin);
await expect(mgr.setCustomBinaryPath(tmpDir)).rejects.toThrow(/No file at/);
expect(settingsMock.__get()).toEqual({});
});
it("rejects non-executable files on POSIX", async () => {
if (process.platform === "win32") return; // skip — XOK semantics differ on Windows
const file = path.join(tmpDir, "not-exec");
await fs.promises.writeFile(file, "");
await fs.promises.chmod(file, 0o644);
const mgr = new OpencodeBinaryManager(fakePlugin);
await expect(mgr.setCustomBinaryPath(file)).rejects.toThrow(/not executable/);
expect(settingsMock.__get()).toEqual({});
});
it("clearing (null) wipes all binary fields and does not touch disk", async () => {
settingsMock.__reset({
binaryPath: "/p",
binaryVersion: "1.0.0",
binarySource: "managed",
});
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.setCustomBinaryPath(null);
expect(settingsMock.__get()).toEqual({
binaryPath: undefined,
binaryVersion: undefined,
binarySource: undefined,
});
});
it("accepting a real binary captures version from --version and tags source as custom", async () => {
// Use the running node binary as a stand-in: it exists, is executable,
// and `--version` exits 0 — the same shape verifyOpencodeBinary expects.
// Node prints `v22.x.y`; the parser strips the `v` and gives us a semver.
const mgr = new OpencodeBinaryManager(fakePlugin);
await mgr.setCustomBinaryPath(process.execPath);
const stored = settingsMock.__get();
expect(stored.binaryPath).toBe(process.execPath);
expect(stored.binarySource).toBe("custom");
expect(stored.binaryVersion).toMatch(/^\d+\.\d+\.\d+/);
});
});

View file

@ -0,0 +1,599 @@
import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants";
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
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";
import * as https from "node:https";
import { IncomingMessage } from "node:http";
import { FileSystemAdapter, requestUrl } from "obsidian";
import * as path from "node:path";
import { promisify } from "node:util";
import { renameWithRetry } from "@/agentMode/skills/renameWithRetry";
import { expectedBinaryName, resolveOpencodeTarget } from "./platformResolver";
const execFileAsync = promisify(execFile);
const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 30_000;
// Generous: first-run on Windows (Defender real-time scan) and macOS
// (Gatekeeper translocation) can add a few seconds before the binary responds.
const VERIFY_BINARY_TIMEOUT_MS = 8_000;
export type ProgressEvent =
| { phase: "resolve"; message: string }
| { phase: "download"; received: number; total?: number; assetName: string }
| { phase: "extract"; message: string }
| { phase: "done"; version: string; path: string };
export interface InstallOptions {
onProgress?: (e: ProgressEvent) => void;
signal?: AbortSignal;
/** Override pinned version. Defaults to OPENCODE_PINNED_VERSION. */
version?: string;
}
export type InstallState =
| { kind: "absent" }
| { kind: "installed"; version: string; path: string; source: "managed" | "custom" };
interface GithubAsset {
name: string;
size: number;
browser_download_url: string;
}
interface GithubRelease {
tag_name: string;
assets: GithubAsset[];
}
interface InstallManifest {
version: string;
assetName: string;
installedAt: string;
}
export class AbortError extends Error {
constructor() {
super("Aborted");
this.name = "AbortError";
}
}
export function pickMatchingAsset(release: GithubRelease, candidates: string[]): GithubAsset {
const ARCHIVE_EXTS = [".zip", ".tar.gz", ".tar.xz", ".tgz"];
const stemOf = (name: string): string => {
for (const ext of ARCHIVE_EXTS) {
if (name.endsWith(ext)) return name.slice(0, -ext.length);
}
return name;
};
const byStem = new Map<string, GithubAsset>();
for (const a of release.assets) {
byStem.set(stemOf(a.name), a);
}
for (const stem of candidates) {
const asset = byStem.get(stem);
if (asset) return asset;
}
throw new Error(
`No matching opencode release asset found. Tried: ${candidates.join(", ")}. ` +
`Available: ${release.assets.map((a) => a.name).join(", ")}`
);
}
/** Derive the install state from the persisted `agentMode.backends.opencode` slice. */
export function computeInstallState(opencode: OpencodeBackendSettings | undefined): InstallState {
const s = opencode ?? {};
if (s.binaryPath && s.binaryVersion) {
return {
kind: "installed",
version: s.binaryVersion,
path: s.binaryPath,
source: s.binarySource ?? "managed",
};
}
return { kind: "absent" };
}
/** Read the OpenCode-specific settings slice from current settings. */
export function readOpencodeSettings(): OpencodeBackendSettings {
return getSettings().agentMode?.backends?.opencode ?? {};
}
function updateOpencodeFields(partial: Partial<OpencodeBackendSettings>): void {
setSettings((cur) => ({
agentMode: {
...cur.agentMode,
backends: {
...cur.agentMode.backends,
opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial },
},
},
}));
}
function clearOpencodeBinary(): void {
updateOpencodeFields({
binaryVersion: undefined,
binaryPath: undefined,
binarySource: undefined,
});
}
/**
* Manages the lifecycle of the opencode binary on disk: platform-aware
* download from GitHub releases, extraction into the plugin data dir, and
* persistence of the install location into `settings.agentMode`. Desktop-only.
*/
export class OpencodeBinaryManager {
constructor(private readonly plugin: CopilotPlugin) {}
getInstallState(): InstallState {
return computeInstallState(readOpencodeSettings());
}
/**
* Reconcile persisted install state with what's actually on disk. If we
* believe a managed install exists but the binary is gone (user deleted it,
* restored a vault from backup, etc.), demote to `absent`. Skipped for
* custom-source installs re-checking on every plugin load would punish
* users for transient filesystem hiccups (network mounts, etc.).
*/
async refreshInstallState(): Promise<void> {
const state = this.getInstallState();
if (state.kind !== "installed") return;
if (state.source !== "managed") return;
if (await fileExists(state.path)) return;
logWarn(`[AgentMode] persisted opencode binary missing at ${state.path}; clearing settings.`);
clearOpencodeBinary();
}
/** Absolute path to `<vault>/.obsidian/plugins/<id>/data/opencode`. */
getDataDir(): string {
const adapter = this.plugin.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
}
const base = adapter.getBasePath();
const id = this.plugin.manifest.id;
return path.join(base, this.plugin.app.vault.configDir, "plugins", id, "data", "opencode");
}
getPinnedVersion(): string {
return OPENCODE_PINNED_VERSION;
}
/**
* Full install pipeline: resolve target fetch release metadata
* download extract atomic rename persist settings. Idempotent when
* an existing install matches the pinned manifest.
*/
async install(opts: InstallOptions = {}): Promise<{ version: string; path: string }> {
const version = opts.version ?? OPENCODE_PINNED_VERSION;
const dataDir = this.getDataDir();
const versionDir = path.join(dataDir, version);
opts.onProgress?.({ phase: "resolve", message: "Resolving platform asset…" });
const { target, candidates } = await resolveOpencodeTarget();
this.throwIfAborted(opts.signal);
const release = await this.fetchReleaseMetadata(version);
this.throwIfAborted(opts.signal);
const asset = pickMatchingAsset(release, candidates);
const binName = expectedBinaryName(target.platform);
const finalBinPath = path.join(versionDir, "bin", binName);
// Idempotency: if the existing manifest matches and the binary is in place, no-op.
const existing = await readManifest(path.join(versionDir, "install-manifest.json"));
if (existing && existing.assetName === asset.name && (await fileExists(finalBinPath))) {
logInfo(`[AgentMode] opencode ${version} already installed at ${finalBinPath}`);
// Skip the write when settings already match — avoids spuriously waking
// every settings subscriber on healthy plugin loads.
const cur = readOpencodeSettings();
if (
cur.binaryVersion !== version ||
cur.binaryPath !== finalBinPath ||
cur.binarySource !== "managed"
) {
updateOpencodeFields({
binaryVersion: version,
binaryPath: finalBinPath,
binarySource: "managed",
});
}
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
return { version, path: finalBinPath };
}
await fs.promises.mkdir(dataDir, { recursive: true });
const tmpDir = path.join(dataDir, `.tmp-${version}-${randomBytes(4).toString("hex")}`);
await fs.promises.mkdir(tmpDir, { recursive: true });
try {
const archivePath = path.join(tmpDir, asset.name);
await downloadToFile(asset.browser_download_url, archivePath, asset.size, opts);
this.throwIfAborted(opts.signal);
opts.onProgress?.({ phase: "extract", message: "Extracting archive…" });
const extractDir = path.join(tmpDir, "extract");
await fs.promises.mkdir(extractDir, { recursive: true });
await extractArchive(archivePath, extractDir);
this.throwIfAborted(opts.signal);
const extractedBin = await locateFile(extractDir, binName);
if (target.platform !== "windows") {
await fs.promises.chmod(extractedBin, 0o755);
}
// Stage the final layout under tmpDir, then atomically rename into place.
const stageDir = path.join(tmpDir, "stage");
const stageBinDir = path.join(stageDir, "bin");
await fs.promises.mkdir(stageBinDir, { recursive: true });
await fs.promises.rename(extractedBin, path.join(stageBinDir, binName));
const manifest: InstallManifest = {
version,
assetName: asset.name,
installedAt: new Date().toISOString(),
};
await fs.promises.writeFile(
path.join(stageDir, "install-manifest.json"),
JSON.stringify(manifest, null, 2)
);
// Rename-aside-then-rename: move any existing versionDir out of the way,
// promote the staged dir into place, and only then delete the old one.
// If the second rename fails, we restore the original so the user keeps
// a working install instead of a half-deleted one.
let asideDir: string | null = null;
if (await fileExists(versionDir)) {
asideDir = `${versionDir}.old-${randomBytes(4).toString("hex")}`;
await renameWithRetry(versionDir, asideDir);
}
try {
await renameWithRetry(stageDir, versionDir);
} catch (e) {
if (asideDir) {
await renameWithRetry(asideDir, versionDir).catch((restoreErr) =>
logError("[AgentMode] failed to restore previous opencode install", restoreErr)
);
}
throw e;
}
if (asideDir) {
await removeDir(asideDir).catch((rmErr) =>
logWarn(`[AgentMode] failed to remove ${asideDir}: ${rmErr}`)
);
}
// Smoke-test the installed binary. Catches corrupt extracts and
// platform/libc mismatches before the user hits them at ACP boot —
// failures here surface in the install Modal where a Retry is one click
// away.
await verifyOpencodeBinary(finalBinPath);
this.throwIfAborted(opts.signal);
updateOpencodeFields({
binaryVersion: version,
binaryPath: finalBinPath,
binarySource: "managed",
});
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
logInfo(`[AgentMode] opencode ${version} installed at ${finalBinPath}`);
return { version, path: finalBinPath };
} catch (err) {
logError("[AgentMode] opencode install failed", err);
throw err;
} finally {
await removeDir(tmpDir).catch(() => {});
}
}
/**
* Remove the active managed version dir and clear settings. For a custom
* binary this only clears settings the binary on disk belongs to the user
* and we don't touch it. Other version dirs are kept either way.
*/
async uninstall(): Promise<void> {
const s = readOpencodeSettings();
if (s.binarySource === "managed" && s.binaryVersion) {
const versionDir = path.join(this.getDataDir(), s.binaryVersion);
await removeDir(versionDir).catch((e) =>
logError(`[AgentMode] failed to remove ${versionDir}`, e)
);
}
clearOpencodeBinary();
}
/**
* Point Agent Mode at a user-supplied opencode binary. Pass `null` to
* clear the override (without removing any managed install on disk).
* Performs filesystem checks plus a `--version` smoke test so misconfigured
* paths are caught at config time rather than later when ACP tries to boot.
*/
async setCustomBinaryPath(p: string | null): Promise<void> {
if (p === null) {
clearOpencodeBinary();
return;
}
const stat = await fs.promises.stat(p).catch(() => null);
if (!stat || !stat.isFile()) {
throw new Error(`No file at ${p}`);
}
if (process.platform !== "win32") {
try {
await fs.promises.access(p, fs.constants.X_OK);
} catch {
throw new Error(`${p} is not executable. chmod +x and try again.`);
}
}
const { stdout } = await verifyOpencodeBinary(p);
const version = parseVersionFromStdout(stdout);
if (!version) {
throw new Error(
`${p} --version output didn't include a version number. Is this an opencode binary?`
);
}
updateOpencodeFields({ binaryVersion: version, binaryPath: p, binarySource: "custom" });
}
private async fetchReleaseMetadata(version: string): Promise<GithubRelease> {
const url = OPENCODE_RELEASE_API_URL_TEMPLATE.replace("{version}", version);
const res = await requestUrl({
url,
method: "GET",
headers: { Accept: "application/vnd.github+json" },
throw: false,
});
if (res.status === 403) {
throw new Error(
"GitHub API rate-limited (60/hour for unauthenticated requests). Set GITHUB_TOKEN or retry later."
);
}
if (res.status === 404) {
throw new Error(`opencode release v${version} not found on GitHub.`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`GitHub release fetch failed with status ${res.status}`);
}
return res.json as GithubRelease;
}
private throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new AbortError();
}
}
// Tolerant of leading `v`, build metadata, etc. — keeps the parser working
// across opencode releases that decorate the version string differently.
export function parseVersionFromStdout(stdout: string): string | undefined {
const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
return match?.[0];
}
async function fileExists(p: string): Promise<boolean> {
try {
await fs.promises.access(p);
return true;
} catch {
return false;
}
}
async function readManifest(p: string): Promise<InstallManifest | null> {
try {
const raw = await fs.promises.readFile(p, "utf-8");
return JSON.parse(raw) as InstallManifest;
} catch {
return null;
}
}
async function removeDir(p: string): Promise<void> {
await fs.promises.rm(p, { recursive: true, force: true });
}
/**
* Issue a GET against `url`, following up to `maxRedirects` 3xx hops. Resolves
* with the response stream on a 2xx or rejects on any other terminal status.
* Honors the supplied AbortSignal at every step.
*/
function httpsGetWithRedirects(
url: string,
signal: AbortSignal | undefined,
maxRedirects = 5
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const request = (currentUrl: string, redirectsLeft: number): void => {
let onAbort: (() => void) | null = null;
const detachAbort = (): void => {
if (onAbort) signal?.removeEventListener("abort", onAbort);
onAbort = null;
};
const req = https.get(currentUrl, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
detachAbort();
if (redirectsLeft <= 0) {
res.resume();
reject(new Error(`Too many redirects fetching ${url}`));
return;
}
res.resume();
const next = new URL(res.headers.location, currentUrl).toString();
request(next, redirectsLeft - 1);
return;
}
if (status !== 200) {
detachAbort();
res.resume();
reject(new Error(`HTTP ${status} fetching ${currentUrl}`));
return;
}
// Hand the response off without detaching: the consumer may still
// need to abort mid-stream. Cleanup happens on res 'close'/'end'.
res.on("close", detachAbort);
resolve(res);
});
req.on("error", (e) => {
detachAbort();
reject(e);
});
if (signal) {
if (signal.aborted) {
req.destroy(new AbortError());
} else {
onAbort = (): void => {
req.destroy(new AbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
}
}
};
request(url, maxRedirects);
});
}
/**
* Stream a remote asset to `dest`, emitting progress events and aborting if
* no bytes arrive for `DOWNLOAD_INACTIVITY_TIMEOUT_MS`. Stalled connections
* surface a clear "download stalled" error instead of hanging forever.
*/
async function downloadToFile(
url: string,
dest: string,
expectedSize: number | undefined,
opts: InstallOptions
): Promise<void> {
const assetName = path.basename(dest);
const res = await httpsGetWithRedirects(url, opts.signal);
const total =
expectedSize ??
(res.headers["content-length"] ? Number(res.headers["content-length"]) : undefined);
let received = 0;
let stalled = false;
let inactivityTimer: number | null = null;
const out = fs.createWriteStream(dest);
await new Promise<void>((resolve, reject) => {
const clearInactivity = (): void => {
if (inactivityTimer) {
window.clearTimeout(inactivityTimer);
inactivityTimer = null;
}
};
const armInactivity = (): void => {
clearInactivity();
inactivityTimer = window.setTimeout(() => {
stalled = true;
res.destroy(
new Error(
`Download stalled — no bytes received for ${Math.round(DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000)}s. Check your network and retry.`
)
);
}, DOWNLOAD_INACTIVITY_TIMEOUT_MS);
};
armInactivity();
res.on("data", (chunk: Uint8Array) => {
received += chunk.length;
armInactivity();
opts.onProgress?.({ phase: "download", received, total, assetName });
});
const fail = (e: Error): void => {
clearInactivity();
reject(e);
};
res.on("error", fail);
out.on("error", fail);
out.on("finish", () => {
clearInactivity();
if (stalled) return; // already rejected via res.destroy()
resolve();
});
res.pipe(out);
});
}
/**
* Extract `archivePath` into `destDir` by shelling out to the system `tar`
* (bsdtar on Windows 10 1803+). Distinguishes "tar not found" from
* non-zero exits so the user gets actionable error text.
*
* Path-traversal note: both GNU tar and bsdtar strip leading `/` and refuse
* to follow `..` outside the extraction root by default, so a malicious
* archive cannot escape `destDir`. We rely on that default rather than
* re-implementing extraction in JS.
*/
async function extractArchive(archivePath: string, destDir: string): Promise<void> {
// Cross-platform: macOS and Linux ship `tar`; Windows 10 1803+ ships `tar.exe`
// built in (`bsdtar`), which handles .zip / .tar.gz / .tar.xz transparently.
await new Promise<void>((resolve, reject) => {
const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], {
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
proc.stderr.on("data", (d: Uint8Array) => {
stderr += Buffer.from(d).toString();
});
proc.on("error", (e: NodeJS.ErrnoException) => {
if (e.code === "ENOENT") {
reject(
new Error(
"`tar` was not found on PATH. macOS/Linux ship it by default; on Windows you need 10 1803+ (which ships `tar.exe`/bsdtar) or to install bsdtar manually."
)
);
} else {
reject(new Error(`Failed to launch tar: ${e instanceof Error ? e.message : String(e)}`));
}
});
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`));
});
});
}
// BFS because the binary's depth inside the upstream archive varies across
// opencode releases — first match wins.
async function locateFile(root: string, name: string): Promise<string> {
const queue: string[] = [root];
while (queue.length > 0) {
const dir = queue.shift() as string;
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) queue.push(full);
else if (e.isFile() && e.name === name) return full;
}
}
throw new Error(`File "${name}" not found anywhere under ${root}`);
}
export async function verifyOpencodeBinary(p: string): Promise<{ stdout: string }> {
try {
const { stdout } = await execFileAsync(p, ["--version"], {
timeout: VERIFY_BINARY_TIMEOUT_MS,
windowsHide: true,
});
return { stdout: stdout.toString().trim() };
} catch (e) {
const err = e as NodeJS.ErrnoException & { signal?: string; code?: string | number };
if (err.code === "ENOENT") {
throw new Error(`No file at ${p}`);
}
if (err.signal === "SIGTERM") {
throw new Error(
`${p} did not respond to --version within ${VERIFY_BINARY_TIMEOUT_MS}ms. Is this an opencode binary?`
);
}
throw new Error(
`${p} --version failed: ${err.message ?? String(err)}. Is this an opencode binary?`
);
}
}

View file

@ -0,0 +1,209 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
AbortError,
InstallOptions,
ProgressEvent,
} from "@/agentMode/backends/opencode/OpencodeBinaryManager";
import type { OpencodeBinaryManager } from "@/agentMode/backends/opencode/OpencodeBinaryManager";
import { OPENCODE_PINNED_VERSION } from "@/constants";
import { App } from "obsidian";
import React from "react";
type ModalState =
| { kind: "confirm" }
| { kind: "running"; progress: ProgressEvent | null }
| { kind: "success"; version: string; path: string }
| { kind: "error"; message: string };
interface ContentProps {
manager: OpencodeBinaryManager;
hostPlatform: string;
hostArch: string;
pinnedVersion: string;
destinationDir: string;
onClose: () => void;
}
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const phaseLabel = (e: ProgressEvent | null): string => {
if (!e) return "Starting…";
switch (e.phase) {
case "resolve":
return e.message;
case "download":
if (e.total) {
const pct = Math.floor((e.received / e.total) * 100);
return `Downloading ${e.assetName}${formatBytes(e.received)} / ${formatBytes(e.total)} (${pct}%)`;
}
return `Downloading ${e.assetName}${formatBytes(e.received)}`;
case "extract":
return e.message;
case "done":
return "Done";
}
};
const phaseProgress = (e: ProgressEvent | null): number | undefined => {
if (!e) return undefined;
if (e.phase === "download" && e.total) {
return Math.min(100, Math.floor((e.received / e.total) * 100));
}
if (e.phase === "extract") return 98;
if (e.phase === "done") return 100;
return undefined;
};
const OpencodeInstallContent: React.FC<ContentProps> = ({
manager,
hostPlatform,
hostArch,
pinnedVersion,
destinationDir,
onClose,
}) => {
const [state, setState] = React.useState<ModalState>({ kind: "confirm" });
const abortRef = React.useRef<AbortController | null>(null);
const startInstall = React.useCallback(() => {
const controller = new AbortController();
abortRef.current = controller;
setState({ kind: "running", progress: null });
const opts: InstallOptions = {
signal: controller.signal,
onProgress: (e) => setState({ kind: "running", progress: e }),
};
manager
.install(opts)
.then(({ version, path }) => setState({ kind: "success", version, path }))
.catch((err: unknown) => {
if (err instanceof AbortError || (err as Error)?.name === "AbortError") {
// User cancelled — go back to confirm so they can retry.
setState({ kind: "confirm" });
return;
}
const message = err instanceof Error ? err.message : String(err);
setState({ kind: "error", message });
});
}, [manager]);
const cancelInstall = React.useCallback(() => {
abortRef.current?.abort();
}, []);
React.useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
if (state.kind === "confirm") {
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>
opencode runs locally on your machine. The official binary will be downloaded from{" "}
<code>github.com/sst/opencode/releases</code> over HTTPS and smoke-tested with{" "}
<code>--version</code> before being activated.
</p>
<dl className="tw-grid tw-grid-cols-[max-content_1fr] tw-gap-x-4 tw-gap-y-1 tw-text-sm">
<dt className="tw-text-muted">Platform</dt>
<dd className="tw-font-mono">
{hostPlatform}-{hostArch}
</dd>
<dt className="tw-text-muted">Version</dt>
<dd className="tw-font-mono">v{pinnedVersion} (pinned)</dd>
<dt className="tw-text-muted">Destination</dt>
<dd className="tw-break-all tw-font-mono tw-text-xs">{destinationDir}</dd>
</dl>
<div className="tw-flex tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="default" onClick={startInstall}>
Download & install
</Button>
</div>
</div>
);
}
if (state.kind === "running") {
const pct = phaseProgress(state.progress);
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p className="tw-text-sm">{phaseLabel(state.progress)}</p>
<Progress value={pct ?? 0} />
<div className="tw-flex tw-justify-end">
<Button variant="ghost" onClick={cancelInstall}>
Cancel
</Button>
</div>
</div>
);
}
if (state.kind === "success") {
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>
opencode <code>v{state.version}</code> installed successfully.
</p>
<p className="tw-break-all tw-font-mono tw-text-xs tw-text-muted">{state.path}</p>
<div className="tw-flex tw-justify-end">
<Button variant="default" onClick={onClose}>
Done
</Button>
</div>
</div>
);
}
// error
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p className="tw-text-error">Install failed.</p>
<pre className="tw-max-h-32 tw-overflow-auto tw-whitespace-pre-wrap tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{state.message}
</pre>
<div className="tw-flex tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button variant="default" onClick={startInstall}>
Retry
</Button>
</div>
</div>
);
};
export class OpencodeInstallModal extends ReactModal {
constructor(
app: App,
private readonly manager: OpencodeBinaryManager,
private readonly hostInfo: { platform: string; arch: string }
) {
super(app, "Install opencode (BYOK Agent backend)");
}
protected renderContent(close: () => void): React.ReactElement {
return (
<OpencodeInstallContent
manager={this.manager}
hostPlatform={this.hostInfo.platform}
hostArch={this.hostInfo.arch}
pinnedVersion={OPENCODE_PINNED_VERSION}
destinationDir={this.manager.getDataDir()}
onClose={close}
/>
);
}
}

View file

@ -0,0 +1,151 @@
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
import { useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
import { computeInstallState, type InstallState } from "./OpencodeBinaryManager";
import { getOpencodeBinaryManager } from "./descriptor";
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
interface Props {
plugin: CopilotPlugin;
app: App;
}
function renderStatusDescription(state: InstallState): React.ReactNode {
if (state.kind === "absent") {
return <span className="tw-text-warning">Setup required opencode is not installed.</span>;
}
return (
<>
<div>
Ready opencode <code>v{state.version}</code>
{state.source === "custom" && <span className="tw-text-muted"> (custom)</span>}
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{state.path}</div>
</>
);
}
/**
* OpenCode-specific settings panel. Lives in the OpenCode backend folder so
* the generic Agent Mode settings tab stays backend-agnostic it just
* renders `descriptor.SettingsPanel`.
*/
export const OpencodeSettingsPanel: React.FC<Props> = ({ plugin, app }) => {
const settings = useSettingsValue();
const manager = getOpencodeBinaryManager(plugin);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const installState = computeInstallState(settings.agentMode?.backends?.opencode);
const openInstallModal = (): void => {
new OpencodeInstallModal(app, manager, {
platform: mapNodePlatform(process.platform) ?? process.platform,
arch: mapNodeArch(process.arch) ?? process.arch,
}).open();
};
const handleUninstall = (): void => {
new ConfirmModal(
app,
async () => {
try {
await manager.uninstall();
new Notice("opencode uninstalled.");
} catch (e) {
logError("[AgentMode] uninstall failed", e);
new Notice(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
}
},
"Remove the installed opencode binary? Your BYOK keys and MCP config will be kept.",
"Uninstall opencode",
"Uninstall"
).open();
};
const onSaveCustomPath = React.useCallback(
async (path: string): Promise<string | null> => {
try {
await manager.setCustomBinaryPath(path);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
new Notice("Custom opencode binary path saved.");
return null;
},
[manager]
);
const clearCustomPath = async (): Promise<void> => {
await manager.setCustomBinaryPath(null);
};
return (
<>
<SettingItem
type="custom"
title="opencode binary"
description={renderStatusDescription(installState)}
>
<div className="tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
{installState.kind === "absent" && (
<Button variant="default" onClick={openInstallModal}>
Install opencode
</Button>
)}
{installState.kind === "installed" && installState.source === "managed" && (
<>
<Button variant="secondary" onClick={openInstallModal}>
Reinstall
</Button>
<Button variant="destructive" onClick={handleUninstall}>
Uninstall
</Button>
</>
)}
{installState.kind === "installed" && installState.source === "custom" && (
<Button variant="secondary" onClick={clearCustomPath}>
Clear custom path
</Button>
)}
</div>
</SettingItem>
{!(installState.kind === "installed" && installState.source === "custom") && (
<div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced((s) => !s)}
aria-expanded={showAdvanced}
>
{showAdvanced ? "▾ Advanced" : "▸ Advanced"}
</Button>
{showAdvanced && (
<SettingItem
type="custom"
title="Use custom opencode binary path"
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."
>
<BinaryPathSetting
binaryName="opencode"
placeholder="/absolute/path/to/opencode"
initialPath=""
notFoundHint="opencode not found on PATH. Install it or paste a custom path manually."
onSave={onSaveCustomPath}
persistOnAutoDetect
/>
</SettingItem>
)}
</div>
)}
</>
);
};

View file

@ -0,0 +1,151 @@
import { OpencodeBackendDescriptor } from "./descriptor";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
describe("OpencodeBackendDescriptor.wire.decode", () => {
const decode = OpencodeBackendDescriptor.wire.decode;
it("parses 2-segment ids as bare/default with provider mapped to Copilot", () => {
expect(decode("anthropic/claude-sonnet-4-5")).toEqual({
selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: null },
provider: "anthropic",
});
});
it("parses 3-segment ids as variants when the suffix is a known effort", () => {
expect(decode("anthropic/claude-sonnet-4-5/medium")).toEqual({
selection: { baseModelId: "anthropic/claude-sonnet-4-5", effort: "medium" },
provider: "anthropic",
});
expect(decode("openai/gpt-5/minimal")).toEqual({
selection: { baseModelId: "openai/gpt-5", effort: "minimal" },
provider: "openai",
});
});
it("recognizes opencode's full effort vocabulary (none/minimal/low/medium/high/xhigh/max)", () => {
// Opencode advertises Anthropic models with `/max` and `/xhigh` and
// OpenRouter reasoning models with `/none`. Each must collapse onto
// its bare base.
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) {
expect(decode(`anthropic/claude-opus-4-7/${effort}`)).toEqual({
selection: { baseModelId: "anthropic/claude-opus-4-7", effort },
provider: "anthropic",
});
}
});
it("returns no-effort representation for 3-segment ids whose suffix isn't a known effort", () => {
// OpenRouter-style 3-segment ids without an effort suffix — the
// trailing segment is part of the model name. The whole id is the
// baseModelId; provider is still attributed from the leading segment.
expect(decode("openrouter/anthropic/claude-sonnet-4-5")).toEqual({
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4-5", effort: null },
provider: "openrouterai",
});
expect(decode("openrouter/anthropic/claude-3.5-haiku")).toEqual({
selection: { baseModelId: "openrouter/anthropic/claude-3.5-haiku", effort: null },
provider: "openrouterai",
});
});
it("parses 4-segment umbrella ids as variants when the last segment is a known effort", () => {
// OpenRouter wraps native ids under `openrouter/`, so its variants
// are 4-segment: `openrouter/<sub>/<model>/<effort>`. Without this
// case the picker would render seven duplicate rows per OpenRouter
// reasoning model.
expect(decode("openrouter/anthropic/claude-sonnet-4.5/high")).toEqual({
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "high" },
provider: "openrouterai",
});
expect(decode("openrouter/anthropic/claude-sonnet-4.5/none")).toEqual({
selection: { baseModelId: "openrouter/anthropic/claude-sonnet-4.5", effort: "none" },
provider: "openrouterai",
});
expect(decode("openrouter/openai/gpt-5/xhigh")).toEqual({
selection: { baseModelId: "openrouter/openai/gpt-5", effort: "xhigh" },
provider: "openrouterai",
});
// OpenRouter route variants like `:exacto` live inside the model
// segment — the effort suffix still attaches at the trailing slash.
expect(decode("openrouter/openai/gpt-oss-120b:exacto/none")).toEqual({
selection: { baseModelId: "openrouter/openai/gpt-oss-120b:exacto", effort: "none" },
provider: "openrouterai",
});
});
it("returns no-effort representation for unparseable shapes (1 segment or unknown trailing segment)", () => {
// 1-segment ids have no provider segment to attribute.
expect(decode("just-a-name")).toEqual({
selection: { baseModelId: "just-a-name", effort: null },
provider: null,
});
// 4+ segment ids whose trailing segment isn't a known effort fall
// through to a no-effort representation. The leading segment still
// attributes a provider when it maps.
expect(decode("anthropic/foo/bar/baz")).toEqual({
selection: { baseModelId: "anthropic/foo/bar/baz", effort: null },
provider: "anthropic",
});
expect(decode("a/b/c/d")).toEqual({
selection: { baseModelId: "a/b/c/d", effort: null },
provider: null,
});
});
});
describe("OpencodeBackendDescriptor.wire.encode", () => {
const encode = OpencodeBackendDescriptor.wire.encode;
it("returns the bare baseModelId when effort is null", () => {
expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: null })).toBe(
"anthropic/claude-sonnet-4-5"
);
});
it("appends the variant when effort is set", () => {
expect(encode({ baseModelId: "anthropic/claude-sonnet-4-5", effort: "high" })).toBe(
"anthropic/claude-sonnet-4-5/high"
);
});
it("round-trips via wire.decode", () => {
const ids = [
"anthropic/claude-sonnet-4-5",
"anthropic/claude-sonnet-4-5/low",
"openai/gpt-5/high",
"anthropic/claude-opus-4-7/max",
"openrouter/anthropic/claude-sonnet-4.5",
"openrouter/anthropic/claude-sonnet-4.5/none",
"openrouter/anthropic/claude-sonnet-4.5/high",
];
for (const id of ids) {
const decoded = OpencodeBackendDescriptor.wire.decode(id);
expect(encode(decoded.selection)).toBe(id);
}
});
});
describe("OpencodeBackendDescriptor.isModelEnabledByDefault", () => {
const fn = OpencodeBackendDescriptor.isModelEnabledByDefault!;
it("matches 'Big Pickle' in name", () => {
expect(fn({ modelId: "anthropic/foo", name: "Big Pickle" })).toBe(true);
expect(fn({ modelId: "anthropic/foo", name: "BIG PICKLE" })).toBe(true);
expect(fn({ modelId: "anthropic/foo", name: "big-pickle" })).toBe(true);
});
it("matches 'big-pickle' in modelId", () => {
expect(fn({ modelId: "openai/big-pickle", name: "Some Display" })).toBe(true);
expect(fn({ modelId: "openai/big_pickle", name: "Some Display" })).toBe(true);
});
it("returns false for unrelated models", () => {
expect(fn({ modelId: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" })).toBe(false);
expect(fn({ modelId: "openai/gpt-5", name: "GPT-5" })).toBe(false);
});
});

View file

@ -0,0 +1,202 @@
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
import OpencodeLogo from "@/agentMode/backends/opencode/logo.svg";
import type CopilotPlugin from "@/main";
import {
subscribeToSettingsChange,
updateAgentModeBackendFields,
type CopilotSettings,
} from "@/settings/model";
import {
OPENCODE_CANONICAL_MODE_AGENT_IDS,
OpencodeBackend,
OPENCODE_PROVIDER_MAP,
} from "./OpencodeBackend";
import { computeInstallState, OpencodeBinaryManager } from "./OpencodeBinaryManager";
import { OpencodeSettingsPanel } from "./OpencodeSettingsPanel";
import { mapNodeArch, mapNodePlatform } from "./platformResolver";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { applyPersistedMode } from "@/agentMode/session/applyPersistedMode";
import { simpleBinaryBackendProcess } from "@/agentMode/backends/shared/simpleBinaryBackend";
import type {
CopilotMode,
ModeMapping,
ModelSelection,
ModelWireCodec,
} from "@/agentMode/session/types";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
/** Config option id OpenCode uses to switch the active agent at runtime. */
const OPENCODE_MODE_CONFIG_OPTION_ID = "mode";
// Lazy-created singleton manager. The first plugin to ask for it wins; in a
// running Obsidian instance there's exactly one CopilotPlugin so this is safe.
let managerRef: OpencodeBinaryManager | null = null;
/**
* Effort suffixes opencode appends to model ids. Used to disambiguate
* genuine effort variants from ids whose trailing segment is part of
* the model name (e.g. `openrouter/anthropic/claude-3.5-haiku` the
* last segment `claude-3.5-haiku` is the model, not an effort).
*/
const KNOWN_OPENCODE_EFFORTS = new Set([
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
/**
* Wire-format codec for Opencode. Native providers emit
* `<provider>/<model>[/<effort>]` (3 segments with effort); umbrella
* providers like OpenRouter emit `<provider>/<sub>/<model>[/<effort>]`
* (4 segments with effort). The leading segment is always the opencode
* provider id, mapped onto a Copilot `ChatModelProviders` value via
* `OPENCODE_PROVIDER_MAP` for picker section grouping. We classify the
* trailing segment as effort iff it's in the known effort vocabulary
* that gates out 3-seg umbrella ids whose last segment is part of the
* model name (e.g. `openrouter/anthropic/claude-3.5-haiku`).
*/
const opencodeWire: ModelWireCodec = {
encode: (selection: ModelSelection) =>
selection.effort ? `${selection.baseModelId}/${selection.effort}` : selection.baseModelId,
decode: (wireId: string) => {
if (!wireId) return { selection: { baseModelId: wireId, effort: null }, provider: null };
const segments = wireId.split("/");
const provider = segments.length >= 2 ? opencodeProviderToCopilot(segments[0]) : null;
const last = segments[segments.length - 1];
if (segments.length >= 3 && KNOWN_OPENCODE_EFFORTS.has(last)) {
return {
selection: { baseModelId: segments.slice(0, -1).join("/"), effort: last },
provider,
};
}
return { selection: { baseModelId: wireId, effort: null }, provider };
},
};
/**
* Resolve the lazy `OpencodeBinaryManager` instance owned by this descriptor.
* The plugin no longer holds a top-level reference ownership lives next to
* the backend that uses it.
*/
export function getOpencodeBinaryManager(plugin: CopilotPlugin): OpencodeBinaryManager {
if (!managerRef) managerRef = new OpencodeBinaryManager(plugin);
return managerRef;
}
/**
* Descriptor for the OpenCode backend. This is the contract `session/` and
* `ui/` consume the rest of Agent Mode never imports `OpencodeBackend`,
* `OpencodeBinaryManager`, or `OpencodeInstallModal` directly.
*/
export const OpencodeBackendDescriptor: BackendDescriptor = {
id: "opencode",
displayName: "opencode",
Icon: OpencodeLogo,
skillsProjectDir: ".opencode/skills",
crossDiscoveredAgents: ["claude", "codex"],
restartOnManagedSkillsChange: true,
wire: opencodeWire,
getInstallState(settings: CopilotSettings): InstallState {
const raw = computeInstallState(settings.agentMode?.backends?.opencode);
if (raw.kind === "absent") return { kind: "absent" };
return { kind: "ready", source: raw.source };
},
subscribeInstallState(_plugin: CopilotPlugin, cb: () => void): () => void {
return subscribeToSettingsChange((prev, next) => {
if (prev.agentMode?.backends?.opencode !== next.agentMode?.backends?.opencode) {
cb();
}
});
},
openInstallUI(plugin: CopilotPlugin): void {
new OpencodeInstallModal(plugin.app, getOpencodeBinaryManager(plugin), {
platform: mapNodePlatform(process.platform) ?? process.platform,
arch: mapNodeArch(process.arch) ?? process.arch,
}).open();
},
async applySelection(session: AgentSession, selection: ModelSelection): Promise<void> {
await session.setModel(opencodeWire.encode(selection));
},
createBackendProcess(args): BackendProcess {
return simpleBinaryBackendProcess(args, new OpencodeBackend());
},
SettingsPanel: OpencodeSettingsPanel,
async onPluginLoad(plugin: CopilotPlugin): Promise<void> {
await getOpencodeBinaryManager(plugin).refreshInstallState();
},
isModelEnabledByDefault(model) {
// Default-enable only "Big Pickle"; users widen the catalog via the
// per-model toggles in the Agents tab.
const re = /big[\s_-]*pickle/i;
return re.test(model.name) || re.test(model.modelId);
},
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> {
updateAgentModeBackendFields("opencode", { probeSessionId: sessionId });
},
/**
* OpenCode doesn't use ACP `availableModes` its "modes" are agents,
* switched at runtime via `session/set_config_option` with `configId:
* "mode"`. The `copilot-build` agent is provisioned in the spawn-time
* config (see `OpencodeBackend.buildOpencodeConfig`); `build` is the
* OpenCode built-in we surface as canonical `auto`. Plan mode is not
* exposed for opencode (no ACP-visible plan finalization tool).
*/
getModeMapping(_modeState, configOptions): ModeMapping | null {
if (!configOptions) return null;
const opt = configOptions.find((o) => o.id === OPENCODE_MODE_CONFIG_OPTION_ID);
if (!opt) return null;
return {
kind: "configOption",
configId: OPENCODE_MODE_CONFIG_OPTION_ID,
canonical: { ...OPENCODE_CANONICAL_MODE_AGENT_IDS },
};
},
async persistModeSelection(value: CopilotMode, _plugin: CopilotPlugin): Promise<void> {
updateAgentModeBackendFields("opencode", { selectedMode: value });
},
/**
* Defense-in-depth replay of the persisted mode on a freshly created
* session. The primary path is `default_agent` baked into the spawn-time
* `OPENCODE_CONFIG_CONTENT` (see `OpencodeBackend.buildOpencodeConfig`),
* which guarantees the very first turn runs in the right agent. This
* runtime call only fires if the spawn-time default didn't take and the
* `mode` configOption is already registered.
*/
async applyInitialSessionConfig(session: AgentSession, settings: CopilotSettings): Promise<void> {
const persistedMode = settings.agentMode?.backends?.opencode?.selectedMode ?? "default";
await applyPersistedMode(session, persistedMode);
},
};
/**
* Map an OpenCode provider id (the leading segment of a wire-form modelId)
* back to its Copilot `ChatModelProviders` value, or `null` for OpenCode-
* native providers that don't correspond to any Copilot provider.
*/
function opencodeProviderToCopilot(opencodeProviderId: string): string | null {
for (const [copilotProvider, oId] of Object.entries(OPENCODE_PROVIDER_MAP)) {
if (oId === opencodeProviderId) return copilotProvider;
}
return null;
}

View file

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

View file

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

View file

@ -0,0 +1,110 @@
import {
buildAssetCandidates,
expectedBinaryName,
mapNodeArch,
mapNodePlatform,
} from "./platformResolver";
describe("buildAssetCandidates", () => {
it("darwin arm64 → single candidate", () => {
expect(buildAssetCandidates({ platform: "darwin", arch: "arm64" })).toEqual([
"opencode-darwin-arm64",
]);
});
it("darwin x64 with avx2 → single candidate (no baseline)", () => {
expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: true })).toEqual([
"opencode-darwin-x64",
]);
});
it("darwin x64 without avx2 → baseline first, regular as fallback", () => {
expect(buildAssetCandidates({ platform: "darwin", arch: "x64", hasAvx2: false })).toEqual([
"opencode-darwin-x64-baseline",
"opencode-darwin-x64",
]);
});
it("linux x64 glibc with avx2 → single candidate", () => {
expect(
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: true })
).toEqual(["opencode-linux-x64"]);
});
it("linux x64 glibc without avx2 → baseline first, regular as fallback", () => {
expect(
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "glibc", hasAvx2: false })
).toEqual(["opencode-linux-x64-baseline", "opencode-linux-x64"]);
});
it("linux x64 musl with avx2 → musl first, regular as fallback", () => {
expect(
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: true })
).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64"]);
});
it("linux x64 musl without avx2 → musl, baseline, regular", () => {
expect(
buildAssetCandidates({ platform: "linux", arch: "x64", libc: "musl", hasAvx2: false })
).toEqual(["opencode-linux-x64-musl", "opencode-linux-x64-baseline", "opencode-linux-x64"]);
});
it("linux arm64 → single candidate", () => {
expect(buildAssetCandidates({ platform: "linux", arch: "arm64" })).toEqual([
"opencode-linux-arm64",
]);
});
it("windows x64 → single candidate", () => {
expect(buildAssetCandidates({ platform: "windows", arch: "x64", hasAvx2: true })).toEqual([
"opencode-windows-x64",
]);
});
it("does not duplicate candidates", () => {
const candidates = buildAssetCandidates({
platform: "linux",
arch: "arm64",
libc: "glibc",
});
expect(candidates.length).toBe(new Set(candidates).size);
});
});
describe("mapNodePlatform", () => {
it.each([
["darwin", "darwin"],
["linux", "linux"],
["win32", "windows"],
])("%s → %s", (input, expected) => {
expect(mapNodePlatform(input as NodeJS.Platform)).toBe(expected);
});
it("returns undefined for unsupported platforms", () => {
expect(mapNodePlatform("freebsd")).toBeUndefined();
});
});
describe("mapNodeArch", () => {
it.each([
["x64", "x64"],
["arm64", "arm64"],
["arm", "arm"],
])("%s → %s", (input, expected) => {
expect(mapNodeArch(input)).toBe(expected);
});
it("returns undefined for unsupported archs", () => {
expect(mapNodeArch("ia32")).toBeUndefined();
});
});
describe("expectedBinaryName", () => {
it("appends .exe on windows", () => {
expect(expectedBinaryName("windows")).toBe("opencode.exe");
});
it("plain on darwin/linux", () => {
expect(expectedBinaryName("darwin")).toBe("opencode");
expect(expectedBinaryName("linux")).toBe("opencode");
});
});

View file

@ -0,0 +1,140 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs";
import { promisify } from "node:util";
const execFile = promisify(execFileCb);
export type OpencodePlatform = "darwin" | "linux" | "windows";
export type OpencodeArch = "x64" | "arm64" | "arm";
export type OpencodeLibc = "glibc" | "musl";
export interface AssetTarget {
platform: OpencodePlatform;
arch: OpencodeArch;
/** Only set on linux. */
libc?: OpencodeLibc;
/** Only set on x64. `undefined` ⇒ assume modern (AVX2 present). */
hasAvx2?: boolean;
}
/**
* Build the prioritized list of opencode release asset stems (no extension)
* for the given target. The first match wins; later entries are fallbacks
* when the preferred variant is not published for a release.
*
* Mirrors the fallback order in opencode's own launcher script
* (`bin/opencode` in sst/opencode).
*/
export function buildAssetCandidates(target: AssetTarget): string[] {
const base = `opencode-${target.platform}-${target.arch}`;
const out: string[] = [];
if (target.platform === "linux" && target.libc === "musl") {
out.push(`${base}-musl`);
}
if (target.arch === "x64" && target.hasAvx2 === false) {
out.push(`${base}-baseline`);
}
out.push(base);
return [...new Set(out)];
}
export function mapNodePlatform(nodePlatform: NodeJS.Platform): OpencodePlatform | undefined {
if (nodePlatform === "darwin") return "darwin";
if (nodePlatform === "linux") return "linux";
if (nodePlatform === "win32") return "windows";
return undefined;
}
export function mapNodeArch(nodeArch: string): OpencodeArch | undefined {
if (nodeArch === "x64") return "x64";
if (nodeArch === "arm64") return "arm64";
if (nodeArch === "arm") return "arm";
return undefined;
}
/**
* Best-effort musl libc detection. Linux only; returns false on other OSes.
* Falls back to false if probes fail (glibc is the safer default).
*/
export async function detectMusl(): Promise<boolean> {
if (process.platform !== "linux") return false;
try {
await fs.promises.access("/etc/alpine-release");
return true;
} catch {
// not alpine; try ldd
}
try {
const { stdout, stderr } = await execFile("ldd", ["--version"]);
return /musl/i.test(`${stdout}\n${stderr}`);
} catch {
return false;
}
}
/**
* Best-effort AVX2 detection on x64 hosts. Returns `true` when the probe
* fails modern hardware is the safer default and the manager already
* falls back to the non-baseline asset if the baseline asset is missing.
*/
export async function detectAvx2(): Promise<boolean> {
if (process.arch !== "x64") return false;
try {
if (process.platform === "darwin") {
const { stdout } = await execFile("sysctl", ["-n", "hw.optional.avx2_0"]);
return stdout.trim() === "1";
}
if (process.platform === "linux") {
const cpuinfo = await fs.promises.readFile("/proc/cpuinfo", "utf-8");
return /\bavx2\b/.test(cpuinfo);
}
if (process.platform === "win32") {
const { stdout } = await execFile("powershell.exe", [
"-NoProfile",
"-Command",
"[System.Runtime.Intrinsics.X86.Avx2]::IsSupported",
]);
return /true/i.test(stdout);
}
} catch {
// probe failed → assume modern
}
return true;
}
export interface ResolvedTarget {
target: AssetTarget;
candidates: string[];
}
/**
* Resolve the current host's opencode asset target by probing the system,
* and return the prioritized asset-stem candidate list.
*/
export async function resolveOpencodeTarget(): Promise<ResolvedTarget> {
const platform = mapNodePlatform(process.platform);
const arch = mapNodeArch(process.arch);
if (!platform || !arch) {
throw new Error(
`Unsupported platform/arch: ${process.platform}/${process.arch}. Agent Mode requires darwin/linux/windows on x64/arm64.`
);
}
const [muslIsh, avx2] = await Promise.all([
platform === "linux" ? detectMusl() : Promise.resolve(false),
arch === "x64" ? detectAvx2() : Promise.resolve(undefined),
]);
const libc: OpencodeLibc | undefined =
platform === "linux" ? (muslIsh ? "musl" : "glibc") : undefined;
const hasAvx2 = arch === "x64" ? avx2 : undefined;
const target: AssetTarget = { platform, arch, libc, hasAvx2 };
return { target, candidates: buildAssetCandidates(target) };
}
/** Expected binary file name inside the extracted archive. */
export function expectedBinaryName(platform: OpencodePlatform): string {
return platform === "windows" ? "opencode.exe" : "opencode";
}

View file

@ -0,0 +1,77 @@
/**
* Copilot Agent Mode system prompts for opencode.
*
* Why this exists: opencode picks a provider-default system prompt by
* substring-matching `model.api.id` (see `opencode/packages/opencode/src/
* session/system.ts:19-33`). For Copilot Plus models — registered under
* names like `copilot-plus-flash` that match falls through every branch
* to `default.txt`, opencode's terse CLI-coding-agent prompt. The
* resulting "you are a CLI software engineering tool" framing is wrong
* for an Obsidian vault assistant and degrades behavior badly on smaller
* fast models like Gemini 2.5 Flash.
*
* The fix: inject our own prompt via `cfg.agent.<id>.prompt` at spawn
* time so opencode's substring picker is bypassed regardless of model.
*
* Content is curated, not invented. Two existing Copilot prompts cover
* most of what's needed:
*
* - `DEFAULT_SYSTEM_PROMPT` (`src/constants.ts`) the chat-mode
* identity and formatting rules. Most rules port directly; chat-only
* hooks (`@vault`, `getCurrentTime`, YouTube auto-transcribe) are
* dropped because that infrastructure does not exist in opencode.
* - `AGENT_LOOP_GUIDANCE` (`src/LLMProviders/chainRunner/
* AutonomousAgentChainRunner.ts`) — the in-process autonomous
* agent's loop bullets. Ported verbatim the agent shape is the
* same.
*/
export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise.
## Grounding
- The user's vault contains markdown notes. When the user says "note", they mean an Obsidian note in this vault.
- When the user mentions "tags", they usually mean tags in Obsidian note properties.
- Never claim you do not have access to something. Rely on the user's provided context and the tools available to you.
- If you are unsure, say so and ask for more context don't guess.
- Always respond in the language of the user's query.
## Tool Behavior
- Prefer evidence from \`read\`, \`grep\`, and \`glob\` over assumption. Don't infer what a note contains from its title — read it.
- NEVER search for the same or very similar query twice. If results were insufficient, try substantially different terms.
- After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient.
- If you have enough information to answer, respond directly without calling any more tools.
## Response Style
- Respond at length appropriate to note-taking and knowledge work. Do NOT default to 1-3 line CLI cadence give the user enough context to understand and act on your answer.
- Be direct and concrete. Don't pad with preamble or postamble.
## Markdown Formatting
- Use \`$...$\` for LaTeX equations, never \`\\[...\\]\` or \`\\(...\\)\`.
- For markdown lists, always use \`- \` (hyphen followed by exactly one space) for bullet points. Never use \`*\` for bullets.
- For tables, use GitHub-flavored markdown.
- When referring to an Obsidian note in your written reply, use \`[[title]]\` format (no backticks around it). To actually read or modify a note, call the \`read\` or \`edit\` tool — don't infer note contents from a wikilink title alone.
- For Obsidian-internal image links, use \`![[link]]\` format. For web image links, use \`![alt](url)\` format.`;
/**
* Pick the Copilot system prompt for a given model.
*
* Today returns `COPILOT_PROMPT_BASE` for every model. The function exists
* so per-provider variants for prompt-style differences across model
* families (Anthropic vs Gemini vs GPT, larger vs smaller) can be added
* by branching on `modelApiId`, mirroring opencode's own substring picker
* at `session/system.ts:19-33` but with our own content.
*
* Limitation worth knowing for future per-provider work: opencode reads
* `cfg.agent.<id>.prompt` at spawn time, so the chosen prompt is fixed
* for the session lifetime. Per-model prompts that follow mid-session
* model switches would require either spawn-time recycling or an
* opencode `experimental.chat.system.transform` plugin. For v1 the
* `modelApiId` argument carries the user's sticky default at spawn.
*/
export function selectCopilotPrompt(modelApiId: string | undefined): string {
// Future per-provider branching goes here, e.g.:
// if (modelApiId?.includes("gemini")) return COPILOT_PROMPT_GEMINI;
// if (modelApiId?.includes("claude")) return COPILOT_PROMPT_ANTHROPIC;
void modelApiId;
return COPILOT_PROMPT_BASE;
}

View file

@ -0,0 +1,42 @@
import type { CopilotSettings } from "@/settings/model";
import { getActiveBackendDescriptor, listBackendDescriptors } from "./registry";
import { OpencodeBackendDescriptor } from "./opencode/descriptor";
jest.mock("@/agentMode/backends/opencode/OpencodeInstallModal", () => ({
OpencodeInstallModal: class {},
}));
jest.mock("@/components/modals/ConfirmModal", () => ({ ConfirmModal: class {} }));
jest.mock("@/components/ui/setting-item", () => ({ SettingItem: () => null }));
jest.mock("@/components/ui/button", () => ({ Button: () => null }));
jest.mock("@/components/ui/input", () => ({ Input: () => null }));
jest.mock("@/logger", () => ({ logInfo: jest.fn(), logWarn: jest.fn(), logError: jest.fn() }));
jest.mock("obsidian", () => ({
Modal: class {},
Notice: class {},
Platform: { isMobile: false },
}));
describe("backendRegistry", () => {
const baseSettings = (activeBackend?: string): CopilotSettings =>
({
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: activeBackend ?? "opencode",
backends: { opencode: {} },
},
}) as unknown as CopilotSettings;
it("returns the OpenCode descriptor by default", () => {
expect(getActiveBackendDescriptor(baseSettings())).toBe(OpencodeBackendDescriptor);
});
it("falls back to OpenCode when an unknown backend is selected", () => {
expect(getActiveBackendDescriptor(baseSettings("nonexistent"))).toBe(OpencodeBackendDescriptor);
});
it("listBackendDescriptors includes OpenCode", () => {
expect(listBackendDescriptors()).toContain(OpencodeBackendDescriptor);
});
});

View file

@ -0,0 +1,32 @@
import type { CopilotSettings } from "@/settings/model";
import { ClaudeBackendDescriptor } from "./claude";
import { CodexBackendDescriptor } from "./codex/descriptor";
import { OpencodeBackendDescriptor } from "./opencode/descriptor";
import type { BackendDescriptor, BackendId } from "@/agentMode/session/types";
/**
* Registry of all known backends. Adding a new backend is exactly:
*
* 1. Implement `AcpBackend` and export a `BackendDescriptor` from
* `backends/<id>/`.
* 2. Add the entry below.
* 3. Persist the backend's per-id slice in `agentMode.backends.<id>`.
*
* No edits to `acp/`, `session/`, or `ui/` should be required.
*/
export const backendRegistry: Record<BackendId, BackendDescriptor> = {
opencode: OpencodeBackendDescriptor,
claude: ClaudeBackendDescriptor,
codex: CodexBackendDescriptor,
};
/** Resolve the active backend descriptor from settings. Falls back to `opencode`. */
export function getActiveBackendDescriptor(settings: CopilotSettings): BackendDescriptor {
const id = settings.agentMode?.activeBackend ?? "opencode";
return backendRegistry[id] ?? OpencodeBackendDescriptor;
}
/** List all registered backend descriptors (e.g. for a backend picker). */
export function listBackendDescriptors(): BackendDescriptor[] {
return Object.values(backendRegistry);
}

View file

@ -0,0 +1,106 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { logError } from "@/logger";
import { validateExecutableFile } from "@/utils/detectBinary";
import { App, Notice } from "obsidian";
import React from "react";
export interface BinaryInstallContentProps {
/** Modal title (e.g. "Configure Claude Code (Agent backend)"). */
modalTitle: string;
/** Lower-case display label for save toast (e.g. "Claude Code"). */
binaryDisplayName: string;
/** Binary lookup name (e.g. "claude-agent-acp"). */
binaryName: string;
/** Shell command to install the binary. */
installCommand: string;
/** Placeholder shown in the binary-path input. */
pathPlaceholder: string;
/** Initial path read from settings. */
initialPath: string;
/** Body paragraph above the install command. */
description: React.ReactNode;
/** Persist the validated path back to settings. */
onPersist: (path: string) => void;
onClose: () => void;
}
const BinaryInstallContent: React.FC<BinaryInstallContentProps> = ({
binaryDisplayName,
binaryName,
installCommand,
pathPlaceholder,
initialPath,
description,
onPersist,
onClose,
}) => {
const onSave = React.useCallback(
async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
onPersist(path);
new Notice(`${binaryDisplayName} binary path saved.`);
onClose();
return null;
},
[binaryDisplayName, onPersist, onClose]
);
const copy = React.useCallback((): void => {
navigator.clipboard.writeText(installCommand).catch((e) => {
logError("[AgentMode] copy install command failed", e);
});
new Notice("Copied to clipboard.");
}, [installCommand]);
return (
<div className="tw-flex tw-flex-col tw-gap-4">
<p>{description}</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">
{installCommand}
</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>
<BinaryPathSetting
binaryName={binaryName}
placeholder={pathPlaceholder}
initialPath={initialPath}
notFoundHint={`${binaryName} not found on PATH. Run the install command above, then click Auto-detect again.`}
onSave={onSave}
/>
</div>
<div className="tw-flex tw-justify-end">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</div>
);
};
/**
* Generic install modal for backends whose configuration is "point us at a
* binary." Backends provide identifying strings + a persist callback.
*/
export class BinaryInstallModal extends ReactModal {
constructor(
app: App,
private readonly props: Omit<BinaryInstallContentProps, "onClose">
) {
super(app, props.modalTitle);
}
protected renderContent(close: () => void): React.ReactElement {
return <BinaryInstallContent {...this.props} onClose={close} />;
}
}

View file

@ -0,0 +1,107 @@
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { useSettingsValue } from "@/settings/model";
import { validateExecutableFile } from "@/utils/detectBinary";
import { Notice } from "obsidian";
import React from "react";
export interface SimpleBackendSettingsPanelProps {
/** Lower-case display name (e.g. "Claude Code"). */
displayName: string;
/** Binary lookup name (e.g. "claude-agent-acp"). */
binaryName: string;
/** Shell command users can run to install the binary. */
installCommand: string;
/** Placeholder for the binary-path input. */
pathPlaceholder: string;
/** Title of the custom-path SettingItem. */
customPathTitle: string;
/** Description of the custom-path SettingItem. */
customPathDescription: string;
/** Read the configured binary path from current settings. */
readStoredPath: () => string;
/** Persist `path` (or clear when `undefined`) back to settings. */
persistPath: (path: string | undefined) => void;
/** Open the matching install modal. */
openInstallModal: () => void;
}
/**
* Settings panel for backends whose configuration is a single binary path
* (Claude Code, Codex). Manages the "Configure / Clear path" SettingItem
* plus a custom-path entry.
*/
export const SimpleBackendSettingsPanel: React.FC<SimpleBackendSettingsPanelProps> = ({
displayName,
binaryName,
installCommand,
pathPlaceholder,
customPathTitle,
customPathDescription,
readStoredPath,
persistPath,
openInstallModal,
}) => {
// Re-render whenever settings change.
useSettingsValue();
const stored = readStoredPath();
const onSave = React.useCallback(
async (path: string): Promise<string | null> => {
const err = await validateExecutableFile(path);
if (err) return err;
persistPath(path);
new Notice(`${displayName} binary path saved.`);
return null;
},
[displayName, persistPath]
);
const clear = React.useCallback((): void => {
persistPath(undefined);
}, [persistPath]);
const description = stored ? (
<>
<div>
Ready <code>{binaryName}</code> (custom path)
</div>
<div className="tw-break-all tw-font-mono tw-text-xs">{stored}</div>
</>
) : (
<span className="tw-text-warning">
Setup required {displayName} binary path not configured.
</span>
);
return (
<>
<SettingItem type="custom" title={`${displayName} 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={customPathTitle} description={customPathDescription}>
<BinaryPathSetting
binaryName={binaryName}
placeholder={pathPlaceholder}
initialPath={stored}
notFoundHint={`${binaryName} not found on PATH. Install with \`${installCommand}\` and try again.`}
onSave={onSave}
persistOnAutoDetect
/>
</SettingItem>
</>
);
};

View file

@ -0,0 +1,52 @@
import type { App } from "obsidian";
import type CopilotPlugin from "@/main";
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
import type { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import { augmentPathForNodeShebang } from "@/agentMode/acp/nodeShebangPath";
import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMode/session/types";
/**
* Build a spawn descriptor for a backend whose only configuration is a
* user-provided binary path (no managed install, no extra args). Auth is
* inherited from the user's environment / login state no API key
* injection.
*/
export function buildSimpleSpawnDescriptor(
binaryPath: string | undefined,
configErrorMessage: string
): AcpSpawnDescriptor {
if (!binaryPath) throw new Error(configErrorMessage);
return {
command: binaryPath,
args: [],
env: {
...process.env,
PATH: augmentPathForNodeShebang(binaryPath, process.env.PATH),
},
};
}
/**
* `InstallState` for the same shape: a binaryPath either is set
* (`ready/custom`) or it is not (`absent`).
*/
export function binaryPathInstallState(binaryPath: string | undefined): InstallState {
return binaryPath ? { kind: "ready", source: "custom" } : { kind: "absent" };
}
/**
* Wrap an `AcpBackend` in `AcpBackendProcess` to satisfy the descriptor's
* `createBackendProcess` factory. Centralizes the "ACP-track plumbing" so
* subprocess backends (codex, opencode) don't repeat the construction.
*/
export function simpleBinaryBackendProcess(
args: {
plugin: CopilotPlugin;
app: App;
clientVersion: string;
descriptor: BackendDescriptor;
},
backend: AcpBackend
): BackendProcess {
return new AcpBackendProcess(args.app, backend, args.clientVersion, args.descriptor);
}

136
src/agentMode/index.ts Normal file
View file

@ -0,0 +1,136 @@
import type { App } from "obsidian";
import type CopilotPlugin from "@/main";
import { logError } from "@/logger";
import { getSettings } from "@/settings/model";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
import { AgentModelPreloader } from "./session/AgentModelPreloader";
import { AgentSessionManager } from "./session/AgentSessionManager";
import { SkillManager } from "./skills";
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager";
export { AgentModeChat } from "./ui/AgentModeChat";
export { default as CopilotAgentView } from "./ui/CopilotAgentView";
export {
useActiveBackendDescriptor,
useBackendInstallState,
useSessionBackendDescriptor,
} from "./ui/useBackendDescriptor";
export { useAgentModelPicker } from "./ui/useAgentModelPicker";
export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker";
export { useAgentModePicker } from "./ui/useAgentModePicker";
export type { AgentModePickerOverride } from "./ui/useAgentModePicker";
export type { AgentSessionManager } from "./session/AgentSessionManager";
export type { AgentBrand, BackendDescriptor, BackendId, InstallState } from "./session/types";
export { isAgentModelEnabled, writeAgentModelOverride } from "./session/modelEnable";
export { getBackendModelOverrides } from "./session/backendSettingsAccess";
export type {
BackendState,
CopilotMode,
EffortOption,
ModelEntry,
ModelSelection,
ModelState,
} from "./session/types";
export type { StoredMcpServer, McpTransport } from "./session/mcpResolver";
export { sanitizeStoredMcpServers } from "./session/mcpResolver";
export { McpServersPanel } from "./ui/McpServersPanel";
export { SelectedModelsList } from "./ui/SelectedModelsList";
export { PlanPreviewView, PLAN_PREVIEW_VIEW_TYPE } from "./ui/PlanPreviewView";
export type { PlanPreviewViewState } from "./ui/PlanPreviewView";
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
export { frameSink as acpFrameSink } from "./session/debugSink";
export { SkillManager, SkillsSettings, useManagedSkills } from "./skills";
export type { Skill } from "./skills";
/**
* Collect each registered backend's project-relative skills directory into
* a `BackendId → path` map. The skills layer is forbidden by
* `boundaries/dependencies` from importing the registry, so this lives in
* the host-side barrel and is injected into `SkillManager.initialize`.
*/
function collectAgentSkillsDirsProjectRel(): Record<string, string> {
const out: Record<string, string> = {};
for (const descriptor of listBackendDescriptors()) {
out[descriptor.id] = descriptor.skillsProjectDir;
}
return out;
}
/**
* Single seam between the plugin host (`main.ts`) and Agent Mode. Initialises
* the SkillManager singleton, wires the default permission prompter into a
* fresh `AgentSessionManager`, kicks off every registered backend
* descriptor's load-time reconcile (e.g. clear stale managed install), and
* starts the model-catalog preload probes. The manager itself is
* backend-agnostic backends are spawned lazily on first session creation.
*
* SkillManager must be initialised before the preload probes fire: the
* Claude descriptor's `getSkillCreationDirective` reads
* `SkillManager.getInstance()` synchronously inside `newSession()`, which
* the probe calls. Doing it in this function (rather than from `main.ts`
* via a separate call) keeps the dependency order obvious and prevents the
* preload race that would otherwise throw "called before initialize".
*
* `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 skillManager = SkillManager.initialize(app, collectAgentSkillsDirsProjectRel());
const preloader = new AgentModelPreloader(app, plugin, (id) => backendRegistry[id]);
const persistenceManager = new AgentChatPersistenceManager(app);
// Mutable ref breaks the construction cycle: the prompter needs the
// manager, but handlers only fire after a session exists, which can't
// happen before assignment below.
let managerRef: AgentSessionManager | null = null;
const prompter = createDefaultPermissionPrompter(
app,
(id) => managerRef?.getSessionByBackendId(id) ?? null
);
const manager = new AgentSessionManager(app, plugin, {
permissionPrompter: prompter,
resolveDescriptor: (id) => backendRegistry[id],
modelPreloader: preloader,
persistenceManager,
});
managerRef = manager;
// Skill-set changes restart the affected backend when its descriptor
// opts in via `restartOnManagedSkillsChange`, so native skill command
// caches stay fresh.
skillManager.subscribeToSkillSetChange((backendId) => {
const descriptor = backendRegistry[backendId];
if (!descriptor?.restartOnManagedSkillsChange) return;
void manager
.restartBackend(backendId, "managed skills changed")
.catch((error) =>
logError(`[Skills] Failed to refresh backend after skill change: ${backendId}`, error)
);
});
void skillManager.refresh().catch((error) => {
logError("[Skills] Initial discovery pass failed", error);
});
// Non-blocking — plugin load should not wait on disk reconcile.
for (const descriptor of listBackendDescriptors()) {
descriptor
.onPluginLoad?.(plugin)
.catch((e) => logError(`[AgentMode] backend ${descriptor.id} onPluginLoad failed`, e));
}
const settings = getSettings();
if (!settings.agentMode?.enabled) return manager;
const preloads: Promise<void>[] = [];
for (const descriptor of listBackendDescriptors()) {
if (descriptor.getInstallState(settings).kind !== "ready") continue;
preloads.push(
manager
.preloadModels(descriptor.id)
.catch((e) => logError(`[AgentMode] preload ${descriptor.id} failed`, e))
);
}
// Aggregate so the chat UI can gate its first render until every
// backend's catalog has settled — the model picker should never flash
// empty before the cache populates.
manager.setPreloadPromise(Promise.allSettled(preloads).then(() => undefined));
return manager;
}

View file

@ -0,0 +1,419 @@
import type { ModelInfo, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { BackendDescriptor, SessionEvent } from "@/agentMode/session/types";
const queryMock = jest.fn();
const createSdkMcpServerMock = jest.fn((opts: unknown) => ({ type: "sdk", instance: opts }));
jest.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: (...args: unknown[]) => queryMock(...args),
createSdkMcpServer: (opts: unknown) => createSdkMcpServerMock(opts),
tool: (name: string, description: string, inputSchema: unknown, handler: unknown) => ({
name,
description,
inputSchema,
handler,
}),
}));
const FAKE_CATALOG: ModelInfo[] = [
{
value: "claude-fake-pro",
displayName: "Claude Fake Pro",
description: "test",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "claude-fake-mini",
displayName: "Claude Fake Mini",
description: "test",
supportsEffort: false,
},
];
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
jest.mock("@/settings/model", () => ({
getSettings: () => ({ agentMode: { debugFullFrames: false } }),
}));
jest.mock("@/agentMode/session/debugSink", () => ({
frameSink: { append: jest.fn() },
formatPayload: () => "",
}));
jest.mock("./effortOption", () => ({
...jest.requireActual("./effortOption"),
getCachedSdkCatalog: jest.fn(),
}));
import { ClaudeSdkBackendProcess } from "./ClaudeSdkBackendProcess";
import { getCachedSdkCatalog } from "./effortOption";
beforeEach(() => {
(getCachedSdkCatalog as jest.Mock).mockReturnValue(FAKE_CATALOG);
});
function fakeDescriptor(): BackendDescriptor {
return {
id: "claude",
displayName: "Claude",
wire: {
encode: (sel: { baseModelId: string; effort: string | null }) => sel.baseModelId,
decode: (id: string) => ({
selection: { baseModelId: id, effort: null },
provider: "anthropic",
}),
effortConfigFor: (baseModelId: string) => {
const m = FAKE_CATALOG.find((x) => x.value === baseModelId);
if (!m?.supportsEffort) return null;
const levels = m.supportedEffortLevels ?? [];
if (levels.length === 0) return null;
return {
id: "effort",
type: "select",
category: "thought_level",
name: "Effort",
currentValue: levels[0],
options: levels.map((v) => ({ value: v, name: v })),
};
},
},
} as unknown as BackendDescriptor;
}
function makeQuery(messages: SDKMessage[]) {
const iter = (async function* () {
for (const m of messages) yield m;
})();
return Object.assign(iter, {
interrupt: jest.fn().mockResolvedValue(undefined),
setModel: jest.fn().mockResolvedValue(undefined),
setPermissionMode: jest.fn().mockResolvedValue(undefined),
});
}
function streamEvent(event: object): SDKMessage {
return {
type: "stream_event",
event,
parent_tool_use_id: null,
uuid: "uuid-x" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "irrelevant",
} as SDKMessage;
}
function resultMessage(): SDKMessage {
return {
type: "result",
subtype: "success",
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
num_turns: 1,
result: "ok",
stop_reason: "end_turn",
total_cost_usd: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
usage: {} as any,
modelUsage: {},
permission_denials: [],
uuid: "uuid-r" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "irrelevant",
};
}
function getPromptQueryCalls(): unknown[][] {
return queryMock.mock.calls.filter((c) => {
const opts = (c[0] as { options?: { cwd?: unknown } } | undefined)?.options;
return opts?.cwd !== undefined;
});
}
describe("ClaudeSdkBackendProcess.prompt happy path", () => {
beforeEach(() => {
queryMock.mockReset();
createSdkMcpServerMock.mockClear();
});
it("translates SDK text deltas to agent_message_chunk and resolves with end_turn", async () => {
queryMock.mockImplementation(() =>
makeQuery([
streamEvent({ type: "message_start", message: {} }),
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "hello" },
}),
resultMessage(),
])
);
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const { sessionId, state } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
expect(sessionId).toBeTruthy();
expect(state.model?.current.baseModelId).toBe("claude-fake-pro");
const events: SessionEvent[] = [];
proc.registerSessionHandler(sessionId, (e) => events.push(e));
const resp = await proc.prompt({
sessionId,
prompt: [{ type: "text", text: "hi" }],
});
expect(resp.stopReason).toBe("end_turn");
const chunks = events.filter((u) => u.update.sessionUpdate === "agent_message_chunk");
expect(chunks).toHaveLength(1);
const chunk = chunks[0].update;
if (chunk.sessionUpdate === "agent_message_chunk" && chunk.content.type === "text") {
expect(chunk.content.text).toBe("hello");
} else {
throw new Error("expected agent_message_chunk text update");
}
const promptCalls = getPromptQueryCalls();
expect(promptCalls).toHaveLength(1);
const call = promptCalls[0][0] as { options: Record<string, unknown> };
expect(call.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude");
expect(Object.keys(call.options.mcpServers as object)).not.toContain("obsidian-vault");
expect(call.options.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "LS"]);
expect(call.options.disallowedTools).toBeUndefined();
// First turn → sessionId is seeded, no resume.
expect(call.options.sessionId).toBe(sessionId);
expect(call.options.resume).toBeUndefined();
// No skill-creation directive opt passed → no systemPrompt override.
expect(call.options.systemPrompt).toBeUndefined();
});
it("forwards the spawn-time skill-creation directive via systemPrompt append", async () => {
queryMock.mockImplementation(() =>
makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()])
);
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
getSkillCreationDirective: () => "DO THIS THING WITH SKILLS",
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
const calls = getPromptQueryCalls();
const opts = (calls[0][0] as { options: Record<string, unknown> }).options;
expect(opts.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
append: "DO THIS THING WITH SKILLS",
});
});
it("captures the directive at newSession time and ignores later setting changes mid-session", async () => {
queryMock.mockImplementation(() =>
makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()])
);
let current = "FIRST DIRECTIVE";
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
getSkillCreationDirective: () => current,
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
// Mutate the "setting" after newSession → the session's first turn must
// still use the original directive, proving capture-at-spawn semantics.
current = "SECOND DIRECTIVE";
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
const opts = (getPromptQueryCalls()[0][0] as { options: Record<string, unknown> }).options;
expect(opts.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
append: "FIRST DIRECTIVE",
});
});
it("buffers events emitted before a session handler is registered and replays them", async () => {
queryMock.mockImplementation(() =>
makeQuery([
streamEvent({ type: "message_start", message: {} }),
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "buffered" },
}),
resultMessage(),
])
);
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
// Kick off prompt without a handler — events are buffered.
const promptPromise = proc.prompt({
sessionId,
prompt: [{ type: "text", text: "hi" }],
});
const seen: SessionEvent[] = [];
proc.registerSessionHandler(sessionId, (e) => seen.push(e));
await promptPromise;
const chunks = seen.filter((u) => u.update.sessionUpdate === "agent_message_chunk");
expect(chunks.length).toBeGreaterThan(0);
});
it("passes resume on the second prompt for the same session", async () => {
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
proc.registerSessionHandler(sessionId, () => {});
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "1" }] });
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "2" }] });
const promptCalls = getPromptQueryCalls();
expect(promptCalls).toHaveLength(2);
const second = promptCalls[1][0] as { options: Record<string, unknown> };
expect(second.options.resume).toBe(sessionId);
expect(second.options.sessionId).toBeUndefined();
});
});
describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => {
beforeEach(() => {
queryMock.mockReset();
createSdkMcpServerMock.mockClear();
});
it("returns BackendState with current model + effort options from the cached catalog", async () => {
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro");
const ids = resp.state.model?.availableModels.map((m) => m.baseModelId);
expect(ids).toContain("claude-fake-pro");
expect(ids).toContain("claude-fake-mini");
const proEffort = resp.state.model?.availableModels
.find((m) => m.baseModelId === "claude-fake-pro")
?.effortOptions.map((o) => o.value);
expect(proEffort).toEqual(["low", "medium", "high"]);
});
it("honors persisted default model when it appears in the catalog", async () => {
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
getDefaultModelId: () => "claude-fake-mini",
});
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-mini");
const miniEffort = resp.state.model?.availableModels.find(
(m) => m.baseModelId === "claude-fake-mini"
)?.effortOptions;
expect(miniEffort).toEqual([]);
});
it("falls back to catalog default when the default model is gone", async () => {
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
getDefaultModelId: () => "claude-removed-by-cli-upgrade",
});
const resp = await proc.newSession({ cwd: "/vault", mcpServers: [] });
expect(resp.state.model?.current.baseModelId).toBe("claude-fake-pro");
});
it("seeds session.model so prompt() sends options.model", async () => {
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
proc.registerSessionHandler(sessionId, () => {});
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
const promptCalls = getPromptQueryCalls();
expect(promptCalls).toHaveLength(1);
const call = promptCalls[0][0] as { options: { model?: string } };
expect(call.options.model).toBe("claude-fake-pro");
});
it("setSessionConfigOption('effort', …) clamps + persists the level on the session", async () => {
queryMock.mockImplementation(() => makeQuery([resultMessage()]));
const proc = new ClaudeSdkBackendProcess({
pathToClaudeCodeExecutable: "/usr/local/bin/claude",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: { vault: {} } as any,
clientVersion: "1.2.3",
descriptor: fakeDescriptor(),
});
const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] });
proc.registerSessionHandler(sessionId, () => {});
const stateAfter = await proc.setSessionConfigOption({
sessionId,
configId: "effort",
value: "high",
});
expect(stateAfter.model?.current.effort).toBe("high");
await proc.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
const promptCalls = getPromptQueryCalls();
expect(promptCalls).toHaveLength(1);
const call = promptCalls[0][0] as { options: { effort?: string } };
expect(call.options.effort).toBe("high");
});
});

View file

@ -0,0 +1,622 @@
/**
* In-process driver for the Claude Agent SDK that implements `BackendProcess`,
* the same interface `AgentSession` consumes for ACP backends. Every SDK
* message is translated to a session-domain `SessionEvent` and dispatched to
* the per-session handler. From `AgentSession`'s perspective there's no
* difference between this adapter and `AcpBackendProcess`.
*
* Lifecycle differs from ACP: there's no long-lived subprocess. Each
* `prompt()` call starts a fresh `query()` (with `resume: <sessionId>` after
* the first turn so the SDK loads prior conversation state).
*/
import { logError, logInfo, logWarn } from "@/logger";
import { err2String } from "@/utils";
import {
query,
type EffortLevel,
type McpServerConfig,
type ModelInfo,
type Options,
type PermissionMode,
type Query,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { App } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { translateBackendState } from "@/agentMode/session/translateBackendState";
import type {
BackendConfigOption,
BackendDescriptor,
BackendProcess,
RawModelState,
RawModeState,
BackendState,
CancelInput,
ListSessionsInput,
ListSessionsOutput,
LoadSessionInput,
LoadSessionOutput,
McpServerSpec,
OpenSessionInput,
OpenSessionOutput,
PermissionDecision,
PermissionPrompt,
PromptInput,
PromptOutput,
ResumeSessionInput,
ResumeSessionOutput,
SessionEvent,
SessionId,
SessionUpdateHandler,
StopReason,
} from "@/agentMode/session/types";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
import { PermissionBridge, type AskUserQuestionHandler } from "./permissionBridge";
import {
getCachedSdkCatalog,
probeClaudeSdkCatalog,
resolveSeedModelId,
synthesizeEffortConfigOption,
} from "./effortOption";
import {
describeSdkMessage,
logSdkError,
logSdkInbound,
logSdkOutbound,
logSdkOutboundResult,
} from "./sdkDebugTap";
interface SessionState {
cwd: string | null;
/**
* Drives whether the next `query()` passes `resume: <sessionId>` (continue
* the persisted conversation) or `sessionId: <ourId>` (mint a new SDK-side
* session with our pre-allocated id).
*/
firstPromptStarted: boolean;
model?: string;
permissionMode?: PermissionMode;
/**
* Effort tier passed to `query()`'s `options.effort` on the next prompt.
* The vocabulary is per-model the runtime catalog
* (`ModelInfo.supportedEffortLevels`) is the source of truth and is
* pulled via `ensureModelCatalog()`.
*/
effort?: EffortLevel;
mcpServers: Record<string, McpServerConfig>;
active?: Query;
/**
* Snapshot of the skill-creation system-prompt directive captured at
* `newSession()` time so the setting change takes effect on the next
* session rather than mid-conversation. Empty string = no directive.
* Appended to Claude's default `claude_code` preset via
* `options.systemPrompt.append`.
*/
systemPromptAppend: string;
}
export interface ClaudeSdkBackendProcessOptions {
pathToClaudeCodeExecutable: string;
app: App;
clientVersion: string;
descriptor: BackendDescriptor;
askUserQuestion?: AskUserQuestionHandler;
/**
* Read at the start of every `prompt()` so a settings change live-applies on
* the next turn.
*/
getEnableThinking?: () => boolean;
/**
* Predicate identifying plan-mode plan files (e.g. `~/.claude/plans/*.md`).
* When set, `Write` calls targeting these paths are auto-allowed via
* `canUseTool`; every other `Write` is routed through the permission
* prompter like any other tool.
*/
isPlanModePlanFilePath?: (absolutePath: string) => boolean;
/**
* Returns the user's persisted model preference. Read at session start
* to seed `session.model` from the live catalog (so the SDK uses what
* the picker shows, instead of falling back to its own internal default).
*/
getDefaultModelId?: () => string | undefined;
/**
* Returns the spawn-time skill-creation directive to append to Claude's
* default `claude_code` system prompt. Read once per `newSession()` so
* a settings change applies to the next session rather than mid-turn.
* Empty string / undefined disables the append.
*/
getSkillCreationDirective?: () => string | undefined;
}
/**
* Static mode catalog for the Claude SDK. `acceptEdits` and `dontAsk`
* are intentionally excluded from the picker.
*/
const STATIC_SDK_MODES: RawModeState = {
currentModeId: "default",
availableModes: [
{ id: "default", name: "Default" },
{ id: "plan", name: "Plan" },
{ id: "bypassPermissions", name: "Auto" },
],
};
export class ClaudeSdkBackendProcess implements BackendProcess {
private readonly sessionHandlers = new Map<SessionId, SessionUpdateHandler>();
private readonly pendingUpdates = new Map<SessionId, SessionEvent[]>();
private static readonly PENDING_UPDATE_LIMIT = 32;
private readonly sessions = new Map<SessionId, SessionState>();
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
null;
private exitListeners = new Set<() => void>();
private shuttingDown = false;
private readonly bridge: PermissionBridge;
/**
* Process-scoped cache of the SDK's model catalog. Populated lazily by
* `ensureModelCatalog()` so we only spawn one extra `claude` subprocess
* per backend lifetime.
*/
private cachedModels: ModelInfo[] | null = null;
private cachedModelsProbe: Promise<ModelInfo[]> | null = null;
constructor(private readonly opts: ClaudeSdkBackendProcessOptions) {
this.bridge = new PermissionBridge({
getPrompter: () => this.permissionPrompter,
askUserQuestion: opts.askUserQuestion,
isPlanModePlanFilePath: opts.isPlanModePlanFilePath,
});
logInfo(
`[AgentMode] ClaudeSdkBackendProcess constructed (claude=${opts.pathToClaudeCodeExecutable})`
);
}
isRunning(): boolean {
return !this.shuttingDown;
}
onExit(listener: () => void): () => void {
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
setPermissionPrompter(fn: (req: PermissionPrompt) => Promise<PermissionDecision>): void {
this.permissionPrompter = fn;
}
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void {
this.sessionHandlers.set(sessionId, handler);
const buffered = this.pendingUpdates.get(sessionId);
if (buffered) {
this.pendingUpdates.delete(sessionId);
for (const event of buffered) {
try {
handler(event);
} catch (e) {
logWarn(`[AgentMode] replay of buffered SDK event threw for ${sessionId}`, e);
}
}
}
return () => {
if (this.sessionHandlers.get(sessionId) === handler) {
this.sessionHandlers.delete(sessionId);
}
};
}
async newSession(params: OpenSessionInput): Promise<OpenSessionOutput> {
logSdkOutbound("newSession", { cwd: params.cwd, mcpServers: params.mcpServers });
const sessionId = uuidv4();
const cwd = params.cwd ?? null;
const mcp: Record<string, McpServerConfig> = {};
for (const server of params.mcpServers ?? []) {
const cfg = mcpServerSpecToSdkConfig(server);
if (cfg) mcp[server.name] = cfg;
}
// Resolve the catalog before returning so the picker never sees an
// empty model list. On a probe miss, at most one subprocess is
// spawned (deduped via cachedModelsProbe).
const catalog = await this.ensureModelCatalog();
const defaultId = this.opts.getDefaultModelId?.();
const seedModelId = resolveSeedModelId(catalog, defaultId);
this.sessions.set(sessionId, {
cwd,
firstPromptStarted: false,
mcpServers: mcp,
model: seedModelId,
systemPromptAppend: this.opts.getSkillCreationDirective?.() ?? "",
});
const state = this.computeState(sessionId);
logSdkOutboundResult(
"newSession",
{ sessionId, currentModelId: seedModelId ?? null, hasEffort: state.model !== null },
sessionId
);
return { sessionId, state };
}
async prompt(params: PromptInput): Promise<PromptOutput> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Unknown session ${params.sessionId}`);
}
// Streaming-input mode (AsyncIterable) is required to expose
// interrupt/setModel/setPermissionMode on the returned Query — without it
// those control calls reject with "only available in streaming input mode".
const promptText = extractPromptText(params);
const promptStream = makePromptStream(promptText, params.sessionId);
this.bridge.setSessionContext(params.sessionId);
const options: Options = {
pathToClaudeCodeExecutable: this.opts.pathToClaudeCodeExecutable,
cwd: session.cwd ?? undefined,
includePartialMessages: true,
mcpServers: session.mcpServers,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "LS"],
canUseTool: this.bridge.canUseTool,
};
// Append the skill-creation directive (captured at newSession time) to
// Claude's default `claude_code` preset. The SDK's preset+append form
// preserves the full default system prompt; we only add the directive.
if (session.systemPromptAppend) {
options.systemPrompt = {
type: "preset",
preset: "claude_code",
append: session.systemPromptAppend,
};
}
if (session.firstPromptStarted) {
options.resume = params.sessionId;
} else {
// First turn: tell the SDK to use *our* pre-allocated session id so
// future `resume` calls match.
options.sessionId = params.sessionId;
}
if (session.model) options.model = session.model;
if (session.permissionMode) options.permissionMode = session.permissionMode;
if (session.effort) options.effort = session.effort;
if (this.opts.getEnableThinking?.()) {
options.thinking = { type: "adaptive" };
}
logSdkOutbound(
"prompt",
{
prompt: promptText,
resume: options.resume ?? null,
sessionIdSeed: options.sessionId ?? null,
model: options.model ?? null,
permissionMode: options.permissionMode ?? null,
effort: options.effort ?? null,
mcpServers: Object.keys(options.mcpServers ?? {}),
allowedTools: options.allowedTools,
},
params.sessionId
);
const q = query({ prompt: promptStream, options });
session.active = q;
session.firstPromptStarted = true;
const translatorState = createTranslatorState();
let stopReason: StopReason = "end_turn";
let resultErrorMessage: string | null = null;
try {
for await (const sdkMsg of q) {
if (this.shuttingDown) break;
logSdkInbound(describeSdkMessage(sdkMsg), sdkMsg, params.sessionId);
const events = translateSdkMessage(sdkMsg, params.sessionId, translatorState);
for (const e of events) this.dispatchEvent(e);
if (sdkMsg.type === "result") {
stopReason = mapStopReason(sdkMsg);
if (stopReason !== "end_turn" && sdkMsg.subtype !== "success") {
const errs = "errors" in sdkMsg ? sdkMsg.errors : undefined;
if (errs && errs.length > 0) {
resultErrorMessage = errs.join("; ");
}
}
break;
}
}
} finally {
if (session.active === q) session.active = undefined;
this.bridge.clearSessionContext();
}
if (resultErrorMessage) {
logSdkError("→", "prompt", { error: resultErrorMessage }, params.sessionId);
throw new Error(resultErrorMessage);
}
logSdkOutboundResult("prompt", { stopReason }, params.sessionId);
return { stopReason };
}
async cancel(params: CancelInput): Promise<void> {
logSdkOutbound("cancel", {}, params.sessionId);
const session = this.sessions.get(params.sessionId);
if (!session?.active) return;
try {
await session.active.interrupt();
} catch (e) {
logWarn("[AgentMode] SDK query.interrupt() threw", e);
logSdkError("→", "interrupt", { error: err2String(e) }, params.sessionId);
}
}
async setSessionModel(params: { sessionId: SessionId; modelId: string }): Promise<BackendState> {
logSdkOutbound("setSessionModel", { modelId: params.modelId }, params.sessionId);
const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
session.model = params.modelId;
if (this.cachedModels && session.effort) {
const info = this.cachedModels.find((m) => m.value === params.modelId);
const levels = info?.supportedEffortLevels ?? [];
if (!levels.includes(session.effort)) {
session.effort = levels[0];
}
}
if (session.active) {
try {
await session.active.setModel(params.modelId);
} catch (e) {
logWarn("[AgentMode] SDK query.setModel() threw (will apply on next turn)", e);
logSdkError("→", "setModel", { error: err2String(e) }, params.sessionId);
}
}
const state = this.computeState(params.sessionId);
this.dispatchStateChanged(params.sessionId, state);
return state;
}
isSetSessionModelSupported(): boolean | null {
return true;
}
async setSessionMode(params: { sessionId: SessionId; modeId: string }): Promise<BackendState> {
logSdkOutbound("setSessionMode", { modeId: params.modeId }, params.sessionId);
const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
const mode = canonicalModeToSdk(params.modeId);
if (!mode) {
throw new Error(`Unsupported mode ${params.modeId}`);
}
session.permissionMode = mode;
if (session.active) {
try {
await session.active.setPermissionMode(mode);
} catch (e) {
logWarn("[AgentMode] SDK query.setPermissionMode() threw (will apply on next turn)", e);
logSdkError("→", "setPermissionMode", { error: err2String(e) }, params.sessionId);
}
}
const state = this.computeState(params.sessionId);
this.dispatchStateChanged(params.sessionId, state);
return state;
}
isSetSessionModeSupported(): boolean | null {
return true;
}
/**
* Only `effort` is exposed as a session config option for this backend.
* We synthesize the option from the SDK's per-model
* `ModelInfo.supportedEffortLevels`, store the pick on the session, and
* apply it as `options.effort` on the next `query()` the SDK has no
* runtime RPC for changing effort mid-turn.
*/
async setSessionConfigOption(params: {
sessionId: SessionId;
configId: string;
value: string;
}): Promise<BackendState> {
logSdkOutbound(
"setSessionConfigOption",
{ configId: params.configId, value: params.value },
params.sessionId
);
if (params.configId !== "effort") {
throw new MethodUnsupportedError("session/set_config_option");
}
const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Unknown session ${params.sessionId}`);
const models = await this.ensureModelCatalog();
const modelInfo = models.find((m) => m.value === session.model);
const levels = modelInfo?.supportedEffortLevels ?? [];
if (!levels.includes(params.value as EffortLevel)) {
throw new Error(
`Effort '${params.value}' not supported by ${session.model ?? "default model"}`
);
}
session.effort = params.value as EffortLevel;
const state = this.computeState(params.sessionId);
this.dispatchStateChanged(params.sessionId, state);
return state;
}
isSetSessionConfigOptionSupported(): boolean | null {
return true;
}
async listSessions(_params: ListSessionsInput): Promise<ListSessionsOutput> {
throw new MethodUnsupportedError("session/list");
}
async resumeSession(_params: ResumeSessionInput): Promise<ResumeSessionOutput> {
throw new MethodUnsupportedError("session/resume");
}
async loadSession(_params: LoadSessionInput): Promise<LoadSessionOutput> {
throw new MethodUnsupportedError("session/load");
}
supportsMcpTransport(_transport: "http" | "sse"): boolean {
return true;
}
async shutdown(): Promise<void> {
this.shuttingDown = true;
for (const session of this.sessions.values()) {
const q = session.active;
if (!q) continue;
try {
await q.interrupt();
} catch (e) {
logWarn("[AgentMode] interrupt during shutdown threw", e);
}
}
this.sessions.clear();
this.sessionHandlers.clear();
this.pendingUpdates.clear();
for (const fn of this.exitListeners) {
try {
fn();
} catch (e) {
logWarn("[AgentMode] SDK exit listener threw", e);
}
}
this.exitListeners.clear();
}
/**
* Resolve the SDK's model catalog. Falls back to an on-demand probe
* only when the shared cache is cold; at most one subprocess is
* spawned per backend lifetime (deduped via `cachedModelsProbe`).
* Failures resolve to `[]` so callers degrade gracefully.
*/
private ensureModelCatalog(): Promise<ModelInfo[]> {
if (this.cachedModels) return Promise.resolve(this.cachedModels);
const fromCache = getCachedSdkCatalog();
if (fromCache && fromCache.length > 0) {
this.cachedModels = fromCache;
return Promise.resolve(fromCache);
}
if (this.cachedModelsProbe) return this.cachedModelsProbe;
const probePromise = probeClaudeSdkCatalog(this.opts.pathToClaudeCodeExecutable).then(
(models) => {
if (models.length > 0) this.cachedModels = models;
else this.cachedModelsProbe = null;
return models;
}
);
this.cachedModelsProbe = probePromise;
return probePromise;
}
private computeState(sessionId: SessionId): BackendState {
const session = this.sessions.get(sessionId);
const catalog = this.cachedModels ?? [];
const seedModel = session?.model;
const models: RawModelState | null =
catalog.length > 0 && seedModel
? {
currentModelId: seedModel,
availableModels: catalog.map((m) => ({ modelId: m.value, name: m.displayName })),
}
: null;
const modes: RawModeState = {
...STATIC_SDK_MODES,
currentModeId: session?.permissionMode ?? STATIC_SDK_MODES.currentModeId,
availableModes: [...STATIC_SDK_MODES.availableModes],
};
const modelInfo = seedModel ? catalog.find((m) => m.value === seedModel) : undefined;
const effortOpt = synthesizeEffortConfigOption(modelInfo, session?.effort);
const configOptions: BackendConfigOption[] | null = effortOpt ? [effortOpt] : null;
return translateBackendState({ models, modes, configOptions }, this.opts.descriptor);
}
private dispatchStateChanged(sessionId: SessionId, state: BackendState): void {
this.dispatchEvent({
sessionId,
update: { sessionUpdate: "state_changed", state },
});
}
private dispatchEvent(event: SessionEvent): void {
const handler = this.sessionHandlers.get(event.sessionId);
if (!handler) {
let queue = this.pendingUpdates.get(event.sessionId);
if (!queue) {
queue = [];
this.pendingUpdates.set(event.sessionId, queue);
}
if (queue.length >= ClaudeSdkBackendProcess.PENDING_UPDATE_LIMIT) {
const kind = event.update.sessionUpdate;
logWarn(
`[AgentMode] dropping SDK event for ${event.sessionId}: pending buffer full (${queue.length}, kind=${kind})`
);
return;
}
queue.push(event);
return;
}
try {
handler(event);
} catch (e) {
logError(`[AgentMode] SDK event handler threw for ${event.sessionId}`, e);
}
}
}
function extractPromptText(req: PromptInput): string {
const parts: string[] = [];
for (const block of req.prompt) {
if (block.type === "text" && block.text.length > 0) parts.push(block.text);
}
return parts.join("\n");
}
async function* makePromptStream(
text: string,
sessionId: SessionId
): AsyncIterable<SDKUserMessage> {
yield {
type: "user",
message: { role: "user", content: text },
parent_tool_use_id: null,
session_id: sessionId,
};
}
function mcpServerSpecToSdkConfig(server: McpServerSpec): McpServerConfig | null {
if ("type" in server && server.type === "http") {
return { type: "http", url: server.url, headers: kvListToRecord(server.headers) };
}
if ("type" in server && server.type === "sse") {
return { type: "sse", url: server.url, headers: kvListToRecord(server.headers) };
}
if ("command" in server) {
return {
type: "stdio",
command: server.command,
args: server.args ?? [],
env: kvListToRecord(server.env),
};
}
return null;
}
function kvListToRecord(
list: Array<{ name: string; value: string }> | undefined
): Record<string, string> | undefined {
if (!list || list.length === 0) return undefined;
const out: Record<string, string> = {};
for (const { name, value } of list) out[name] = value;
return out;
}
function canonicalModeToSdk(modeId: string): PermissionMode | null {
switch (modeId) {
case "default":
case "acceptEdits":
case "bypassPermissions":
case "plan":
return modeId;
default:
return null;
}
}

View file

@ -0,0 +1,105 @@
import type { BackendConfigOption } from "@/agentMode/session/types";
import {
query,
type EffortLevel,
type ModelInfo,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { logWarn } from "@/logger";
/**
* Build a single-select effort `BackendConfigOption` from a model's
* advertised `supportedEffortLevels`. Returns `null` when the model
* doesn't support effort or the SDK reports an empty list.
*
* The category is `"thought_level"` (the spec-conformant
* `SessionConfigOptionCategory` reserved name) so future ACP-aware UIs
* recognize it.
*/
export function synthesizeEffortConfigOption(
modelInfo: ModelInfo | undefined,
currentEffort: EffortLevel | undefined
): BackendConfigOption | null {
const levels = modelInfo?.supportsEffort ? (modelInfo.supportedEffortLevels ?? []) : [];
if (levels.length === 0) return null;
const value = currentEffort && levels.includes(currentEffort) ? currentEffort : levels[0];
return {
id: "effort",
type: "select",
category: "thought_level",
name: "Effort",
currentValue: value,
options: levels.map((v) => ({ value: v, name: v })),
};
}
/**
* Pick the model id to seed a session with. Honors a persisted preference
* when it still appears in the live catalog (CLI revs can drop/rename
* models); falls back to the first catalog entry. Returns `undefined` when
* the catalog is empty callers then send no `options.model` and the SDK
* uses its default.
*/
export function resolveSeedModelId(
catalog: ModelInfo[],
defaultId: string | undefined
): string | undefined {
if (defaultId && catalog.some((m) => m.value === defaultId)) return defaultId;
if (defaultId) {
logWarn(
`[AgentMode] persisted Claude model "${defaultId}" not in live catalog; falling back to default`
);
}
return catalog[0]?.value;
}
/**
* Plugin-lifetime cache of the SDK's model catalog, shared across every
* `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn
* the `claude` CLI to read the model list.
*/
let cachedSdkCatalog: ModelInfo[] | null = null;
export function getCachedSdkCatalog(): ModelInfo[] | null {
return cachedSdkCatalog;
}
/**
* Spawn a one-shot SDK `query()` solely to read its initialization
* handshake which carries the catalog of models the bundled `claude`
* CLI advertises (per-model `supportsEffort` + `supportedEffortLevels`).
*
* The SDK requires streaming-input mode to expose `initializationResult()`,
* so we feed it a generator that never yields and tear the query down
* via `interrupt()` once the handshake completes. Failures resolve to
* an empty array (logged) so callers can degrade gracefully.
*
* Successful, non-empty probes update the module-level cache so a later
* `getCachedSdkCatalog()` returns hot data without re-probing.
*/
export async function probeClaudeSdkCatalog(
pathToClaudeCodeExecutable: string
): Promise<ModelInfo[]> {
// eslint-disable-next-line require-yield
const noopPrompt = (async function* (): AsyncIterable<SDKUserMessage> {
await new Promise<void>(() => {});
})();
const probe = query({
prompt: noopPrompt,
options: { pathToClaudeCodeExecutable },
});
try {
const init = await probe.initializationResult();
if (init.models.length > 0) cachedSdkCatalog = init.models;
return init.models;
} catch (e) {
logWarn("[AgentMode] Claude SDK init probe failed", e);
return [];
} finally {
try {
await probe.interrupt();
} catch {
// Probe is being torn down; swallow.
}
}
}

View file

@ -0,0 +1,279 @@
import type { PermissionDecision, PermissionPrompt } from "@/agentMode/session/types";
import { PermissionBridge } from "./permissionBridge";
describe("PermissionBridge.canUseTool", () => {
function makeBridge(
prompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null,
askUserQuestion?: (
questions: Array<{ question: string; options: Array<{ label: string }> }>
) => Promise<{ [q: string]: string }>
) {
const bridge = new PermissionBridge({
getPrompter: () => prompter,
askUserQuestion: askUserQuestion,
});
bridge.setSessionContext("session-1");
return bridge;
}
const ctx = {
signal: new AbortController().signal,
toolUseID: "toolu_test_id",
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
it("denies when no prompter is registered", async () => {
const bridge = new PermissionBridge({ getPrompter: () => null });
bridge.setSessionContext("session-1");
const result = await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx);
expect(result.behavior).toBe("deny");
});
it("synthesizes a PermissionPrompt with kind from toolName", async () => {
let captured: PermissionPrompt | null = null;
const bridge = makeBridge(async (req) => {
captured = req;
return { outcome: { outcome: "selected", optionId: "allow_once" } };
});
await bridge.canUseTool("Edit", { file_path: "a.md" }, ctx);
expect(captured).not.toBeNull();
expect(captured!.toolCall.kind).toBe("edit");
expect(captured!.toolCall.rawInput).toEqual({ file_path: "a.md" });
expect(captured!.options.map((o) => o.kind)).toEqual([
"allow_once",
"allow_always",
"reject_once",
"reject_always",
]);
});
it("propagates ctx.toolUseID as PermissionPrompt.toolCall.toolCallId", async () => {
// The trail UI pairs each permission prompt with the corresponding
// `tool_call` notification by id. If the bridge mints a fresh uuid
// here instead of reusing the SDK's `tool_use_id`, the prompt and the
// notification disagree and the action card cannot be resolved.
let captured: PermissionPrompt | null = null;
const bridge = makeBridge(async (req) => {
captured = req;
return { outcome: { outcome: "selected", optionId: "reject_once" } };
});
const ctxWithToolUse = {
signal: new AbortController().signal,
toolUseID: "toolu_abc123",
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
await bridge.canUseTool("Edit", { file_path: "a.md" }, ctxWithToolUse);
expect(captured).not.toBeNull();
expect(captured!.toolCall.toolCallId).toBe("toolu_abc123");
});
it("maps allow_once to allow with updatedInput echoing the original input", async () => {
const bridge = makeBridge(async () => ({
outcome: { outcome: "selected", optionId: "allow_once" },
}));
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
expect(result).toEqual({ behavior: "allow", updatedInput: { command: "ls" } });
});
it("maps allow_always with suggestions to allow + updatedInput + updatedPermissions", async () => {
const bridge = makeBridge(async () => ({
outcome: { outcome: "selected", optionId: "allow_always" },
}));
const ctxWithSuggestions = {
signal: new AbortController().signal,
suggestions: [
{
type: "addRules",
rules: [{ toolName: "Bash" }],
behavior: "allow",
destination: "session",
} as unknown,
],
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctxWithSuggestions);
expect(result.behavior).toBe("allow");
if (result.behavior === "allow") {
expect(result.updatedInput).toEqual({ command: "ls" });
expect(result.updatedPermissions).toHaveLength(1);
}
});
it("maps reject_once to deny with a message", async () => {
const bridge = makeBridge(async () => ({
outcome: { outcome: "selected", optionId: "reject_once" },
}));
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
expect(result.behavior).toBe("deny");
if (result.behavior === "deny") expect(result.message).toContain("declined");
});
it("forwards decision.denyMessage as the deny message on reject", async () => {
const bridge = makeBridge(async () => ({
outcome: { outcome: "selected", optionId: "reject_once" },
denyMessage: "Please drop the second step and only do step 1.",
}));
const result = await bridge.canUseTool("ExitPlanMode", { plan: "# x" }, ctx);
expect(result.behavior).toBe("deny");
if (result.behavior === "deny") {
expect(result.message).toBe("Please drop the second step and only do step 1.");
}
});
it("ignores denyMessage when the decision is allow", async () => {
const bridge = makeBridge(async () => ({
outcome: { outcome: "selected", optionId: "allow_once" },
denyMessage: "this should be ignored",
}));
const result = await bridge.canUseTool("Bash", { command: "ls" }, ctx);
expect(result.behavior).toBe("allow");
});
it("maps cancelled outcome to deny", async () => {
const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } }));
const result = await bridge.canUseTool("Bash", {}, ctx);
expect(result.behavior).toBe("deny");
});
it("routes AskUserQuestion to the dedicated handler with answers", async () => {
const handler = jest.fn(async () => ({ "What's your favorite color?": "Blue" }));
const bridge = makeBridge(null, handler);
const result = await bridge.canUseTool(
"AskUserQuestion",
{
questions: [{ question: "What's your favorite color?", options: [{ label: "Blue" }] }],
},
ctx
);
expect(handler).toHaveBeenCalled();
expect(result.behavior).toBe("allow");
if (result.behavior === "allow") {
expect(result.updatedInput).toMatchObject({
answers: { "What's your favorite color?": "Blue" },
});
}
});
it("denies AskUserQuestion when no handler is configured", async () => {
const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } }));
const result = await bridge.canUseTool(
"AskUserQuestion",
{ questions: [{ question: "Q", options: [{ label: "A" }] }] },
ctx
);
expect(result.behavior).toBe("deny");
});
it("treats empty AskUserQuestion answers as cancelled", async () => {
const handler = jest.fn(async () => ({}));
const bridge = makeBridge(null, handler);
const result = await bridge.canUseTool(
"AskUserQuestion",
{ questions: [{ question: "Q", options: [{ label: "A" }] }] },
ctx
);
expect(result.behavior).toBe("deny");
});
describe("Write tool gating", () => {
function makeBridgeWithPlanMatcher(
isPlanModePlanFilePath: (p: string) => boolean,
prompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null = null
) {
const bridge = new PermissionBridge({
getPrompter: () => prompter,
isPlanModePlanFilePath,
});
bridge.setSessionContext("session-1");
return bridge;
}
it("auto-allows Write when file_path matches the plan-mode predicate", async () => {
const prompter = jest.fn();
const bridge = makeBridgeWithPlanMatcher(
(p) => p.endsWith("/.claude/plans/foo.md"),
prompter
);
const result = await bridge.canUseTool(
"Write",
{ file_path: "/Users/x/.claude/plans/foo.md", content: "# plan" },
ctx
);
expect(result.behavior).toBe("allow");
if (result.behavior === "allow") {
expect(result.updatedInput).toEqual({
file_path: "/Users/x/.claude/plans/foo.md",
content: "# plan",
});
}
expect(prompter).not.toHaveBeenCalled();
});
it("routes non-plan Write through the permission prompter", async () => {
let captured: PermissionPrompt | null = null;
const bridge = makeBridgeWithPlanMatcher(
() => false,
async (req) => {
captured = req;
return { outcome: { outcome: "selected", optionId: "allow_once" } };
}
);
const result = await bridge.canUseTool(
"Write",
{ file_path: "/tmp/foo.md", content: "x" },
ctx
);
expect(captured).not.toBeNull();
expect(captured!.toolCall.kind).toBe("edit");
expect(captured!.toolCall.vendorToolName).toBe("Write");
expect(result).toEqual({
behavior: "allow",
updatedInput: { file_path: "/tmp/foo.md", content: "x" },
});
});
it("routes Write through the prompter even with no plan predicate configured", async () => {
const prompter = jest.fn(async () => ({
outcome: { outcome: "selected" as const, optionId: "reject_once" as const },
}));
const bridge = new PermissionBridge({ getPrompter: () => prompter });
bridge.setSessionContext("session-1");
const result = await bridge.canUseTool(
"Write",
{ file_path: "/Users/x/.claude/plans/foo.md", content: "x" },
ctx
);
expect(prompter).toHaveBeenCalled();
expect(result.behavior).toBe("deny");
});
});
describe("ExitPlanMode handling", () => {
it("synthesizes a prompt with switch_mode kind and isPlanProposal=true", async () => {
let captured: PermissionPrompt | null = null;
const bridge = makeBridge(async (req) => {
captured = req;
return { outcome: { outcome: "selected", optionId: "allow_once" } };
});
const ctxWithToolUse = {
signal: new AbortController().signal,
toolUseID: "toolu_plan_xyz",
} as unknown as Parameters<PermissionBridge["canUseTool"]>[2];
await bridge.canUseTool("ExitPlanMode", { plan: "# do thing" }, ctxWithToolUse);
expect(captured).not.toBeNull();
expect(captured!.toolCall.kind).toBe("switch_mode");
expect(captured!.toolCall.toolCallId).toBe("toolu_plan_xyz");
expect(captured!.toolCall.rawInput).toEqual({ plan: "# do thing" });
expect(captured!.toolCall.vendorToolName).toBe("ExitPlanMode");
expect(captured!.toolCall.isPlanProposal).toBe(true);
});
it("does not set isPlanProposal for non-ExitPlanMode tools", async () => {
let captured: PermissionPrompt | null = null;
const bridge = makeBridge(async (req) => {
captured = req;
return { outcome: { outcome: "selected", optionId: "allow_once" } };
});
await bridge.canUseTool("Bash", { command: "ls" }, ctx);
expect(captured!.toolCall.isPlanProposal).toBeUndefined();
});
});
});

View file

@ -0,0 +1,206 @@
/**
* Bridge between the Claude SDK's `canUseTool` callback and Agent Mode's
* session-domain permission prompter. Each `canUseTool` invocation is
* translated to a `PermissionPrompt`, dispatched through the prompter, then
* translated back to a SDK `PermissionResult`. AskUserQuestion gets a
* separate branch that opens a dedicated multi-choice modal.
*/
import type {
CanUseTool,
PermissionResult,
PermissionUpdate,
} from "@anthropic-ai/claude-agent-sdk";
import type {
PermissionDecision,
PermissionOption,
PermissionOptionKind,
PermissionPrompt,
SessionId,
} from "@/agentMode/session/types";
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
import { err2String } from "@/utils";
import { logSdkInbound, logSdkOutbound } from "./sdkDebugTap";
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
export type Prompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
export type AskUserQuestionHandler = (
questions: AskUserQuestionInput["questions"]
) => Promise<{ [questionText: string]: string }>;
export interface AskUserQuestionInput {
questions: Array<{
question: string;
header?: string;
options: Array<{ label: string; description?: string }>;
multiSelect?: boolean;
}>;
}
export interface PermissionBridgeOptions {
getPrompter: () => Prompter | null;
askUserQuestion?: AskUserQuestionHandler;
/**
* Predicate identifying plan-mode plan files. When provided, the bridge
* auto-allows `Write` calls whose `file_path` satisfies the predicate so
* plan mode can finalize its proposal at `~/.claude/plans/*.md` without
* a prompt. Every other `Write` is routed through the permission
* prompter like any other tool.
*/
isPlanModePlanFilePath?: (absolutePath: string) => boolean;
}
export class PermissionBridge {
constructor(private readonly opts: PermissionBridgeOptions) {}
/**
* Single-field rather than keyed-by-toolCallId because each backend has
* exactly one in-flight `query()` at a time. If we ever support concurrent
* prompts on the same backend instance, key this by toolCallId.
*/
private currentSessionId: SessionId | null = null;
setSessionContext(sessionId: SessionId): void {
this.currentSessionId = sessionId;
}
clearSessionContext(): void {
this.currentSessionId = null;
}
canUseTool: CanUseTool = async (toolName, input, ctx) => {
if (toolName === "AskUserQuestion") {
return this.handleAskUserQuestion(input as unknown as AskUserQuestionInput);
}
const sessionId = this.currentSessionId;
logSdkInbound(
`canUseTool:request`,
{ toolName, input, suggestions: ctx.suggestions },
sessionId
);
if (toolName === "Write") {
const filePath = typeof input.file_path === "string" ? input.file_path : null;
if (filePath && this.opts.isPlanModePlanFilePath?.(filePath)) {
const result: PermissionResult = { behavior: "allow", updatedInput: input };
logSdkOutbound("canUseTool:response:auto-allow-plan", result, sessionId);
return result;
}
}
const prompter = this.opts.getPrompter();
if (!prompter) {
return this.deny("canUseTool:response", "No permission prompter available", sessionId);
}
if (!sessionId) {
return this.deny("canUseTool:response", "Permission requested outside a session", sessionId);
}
const prompt = synthesizePermissionPrompt(toolName, input, sessionId, ctx);
const decision = await prompter(prompt);
const result = mapDecisionToSdk(decision, ctx.suggestions, input);
logSdkOutbound("canUseTool:response", result, sessionId);
return result;
};
private async handleAskUserQuestion(input: AskUserQuestionInput): Promise<PermissionResult> {
const sessionId = this.currentSessionId;
logSdkInbound("askUserQuestion:request", input, sessionId);
if (!this.opts.askUserQuestion) {
return this.deny(
"askUserQuestion:response",
"AskUserQuestion is not yet supported",
sessionId
);
}
try {
const answers = await this.opts.askUserQuestion(input.questions);
if (Object.keys(answers).length === 0) {
return this.deny("askUserQuestion:response", "User cancelled the question", sessionId);
}
const result: PermissionResult = {
behavior: "allow",
updatedInput: { questions: input.questions, answers },
};
logSdkOutbound("askUserQuestion:response", result, sessionId);
return result;
} catch (e) {
return this.deny(
"askUserQuestion:response",
`AskUserQuestion failed: ${err2String(e)}`,
sessionId
);
}
}
private deny(method: string, message: string, sessionId: SessionId | null): PermissionResult {
const result: PermissionResult = { behavior: "deny", message };
logSdkOutbound(method, result, sessionId);
return result;
}
}
const STANDARD_OPTION_NAMES: Record<PermissionOptionKind, string> = {
allow_once: "Allow once",
allow_always: "Allow always",
reject_once: "Deny once",
reject_always: "Deny always",
};
const STANDARD_OPTIONS: PermissionOption[] = PERMISSION_OPTION_KINDS.map((kind) => ({
optionId: kind,
name: STANDARD_OPTION_NAMES[kind],
kind,
}));
const STANDARD_OPTION_IDS = new Set<string>(PERMISSION_OPTION_KINDS);
function synthesizePermissionPrompt(
toolName: string,
input: Record<string, unknown>,
sessionId: SessionId,
ctx: Parameters<CanUseTool>[2]
): PermissionPrompt {
return {
sessionId,
toolCall: {
// Reuse the SDK's `tool_use_id` so prompt and `tool_call` notification
// share an id — the trail UI and plan-card resolver pair them by id.
toolCallId: ctx.toolUseID,
kind: deriveToolKind(toolName),
status: "pending",
title: deriveToolTitle(
toolName,
input,
typeof ctx.title === "string" ? ctx.title : undefined
),
rawInput: input,
...vendorMetaFields(toolName),
},
options: STANDARD_OPTIONS,
};
}
function mapDecisionToSdk(
decision: PermissionDecision,
suggestions: PermissionUpdate[] | undefined,
input: Record<string, unknown>
): PermissionResult {
if (decision.outcome.outcome === "cancelled") {
return { behavior: "deny", message: "User cancelled" };
}
// Defensive default: unknown ids collapse to deny so they don't silently allow.
const optionKind = STANDARD_OPTION_IDS.has(decision.outcome.optionId)
? (decision.outcome.optionId as PermissionOptionKind)
: "reject_once";
switch (optionKind) {
case "allow_once":
// SDK runtime schema requires `updatedInput` even though the type marks
// it optional. Echo the original — we don't modify tool args from the prompt.
return { behavior: "allow", updatedInput: input };
case "allow_always":
return { behavior: "allow", updatedInput: input, updatedPermissions: suggestions ?? [] };
case "reject_once":
case "reject_always":
return { behavior: "deny", message: decision.denyMessage ?? "User declined" };
}
}

View file

@ -0,0 +1,117 @@
import {
describeSdkMessage,
logSdkInbound,
logSdkOutbound,
SDK_FRAME_TAG,
type SdkFrameSinkLike,
} from "./sdkDebugTap";
const mockLogInfo = jest.fn();
jest.mock("@/logger", () => ({
logInfo: (...args: unknown[]) => mockLogInfo(...args),
}));
let debugFullFrames = false;
jest.mock("@/settings/model", () => ({
getSettings: () => ({ agentMode: { debugFullFrames } }),
}));
function fakeSink(): { sink: SdkFrameSinkLike; records: unknown[] } {
const records: unknown[] = [];
return {
records,
sink: { append: (r) => records.push(r) },
};
}
beforeEach(() => {
mockLogInfo.mockClear();
debugFullFrames = false;
});
describe("sdkDebugTap", () => {
it("always emits a console line with the claude-sdk tag", () => {
const { sink, records } = fakeSink();
logSdkOutbound("prompt", { hi: "there" }, "session-1", sink);
expect(mockLogInfo).toHaveBeenCalledTimes(1);
const line = mockLogInfo.mock.calls[0][0] as string;
expect(line).toContain(`[ACP →][${SDK_FRAME_TAG}]`);
expect(line).toContain("prompt");
expect(line).toContain("#session-1");
expect(line).toContain('{"hi":"there"}');
expect(records).toHaveLength(0);
});
it("does not write to disk when debugFullFrames is off", () => {
const { sink, records } = fakeSink();
debugFullFrames = false;
logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink);
expect(records).toHaveLength(0);
});
it("writes a full FrameRecord to disk when debugFullFrames is on", () => {
const { sink, records } = fakeSink();
debugFullFrames = true;
logSdkInbound("stream_event:content_block_delta", { x: 1 }, "s", sink);
expect(records).toHaveLength(1);
const rec = records[0] as Record<string, unknown>;
expect(rec.dir).toBe("←");
expect(rec.tag).toBe(SDK_FRAME_TAG);
expect(rec.kind).toBe("notif");
expect(rec.method).toBe("stream_event:content_block_delta");
expect(rec.id).toBe("s");
expect(rec.payload).toEqual({ x: 1 });
expect(typeof rec.ts).toBe("string");
});
it("truncates the console payload past the 400-char limit", () => {
const { sink } = fakeSink();
const big = "x".repeat(800);
logSdkOutbound("prompt", { big }, "s", sink);
const line = mockLogInfo.mock.calls[0][0] as string;
expect(line).toContain("…(+");
expect(line.length).toBeLessThan(800);
});
it("survives unserializable payloads on the console path", () => {
const { sink } = fakeSink();
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(() => logSdkOutbound("prompt", cyclic, null, sink)).not.toThrow();
expect(mockLogInfo).toHaveBeenCalledTimes(1);
});
it("uses (no-id) label when id is omitted on outbound, (notif) on inbound", () => {
const { sink } = fakeSink();
logSdkOutbound("cancel", {}, null, sink);
logSdkInbound("acp_notify:plan", {}, null, sink);
const lines = mockLogInfo.mock.calls.map((c) => c[0] as string);
expect(lines[0]).toContain("(no-id)");
expect(lines[1]).toContain("(notif)");
});
});
describe("describeSdkMessage", () => {
it("annotates stream_event with inner event type", () => {
expect(
describeSdkMessage({
type: "stream_event",
event: { type: "content_block_delta" },
})
).toBe("stream_event:content_block_delta");
});
it("annotates result with subtype", () => {
expect(describeSdkMessage({ type: "result", subtype: "success" })).toBe("result:success");
});
it("returns the bare type for assistant/user messages", () => {
expect(describeSdkMessage({ type: "assistant" })).toBe("assistant");
expect(describeSdkMessage({ type: "user" })).toBe("user");
});
it("returns (unknown) for malformed inputs", () => {
expect(describeSdkMessage(null)).toBe("(unknown)");
expect(describeSdkMessage({})).toBe("(unknown)");
});
});

View file

@ -0,0 +1,112 @@
/**
* Debug tap for the Claude Agent SDK adapter. Each call logs one frame to the
* console (truncated) and, when `agentMode.debugFullFrames` is on, appends
* the full payload to the shared `acp-frames.ndjson` sink. Frames carry
* `tag: "claude-sdk"` to distinguish from ACP frames in the same log.
* @see ../acp/debugTap.ts for the JSON-RPC stream variant.
*/
import { logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import { formatPayload, frameSink, type FrameRecord } from "@/agentMode/session/debugSink";
export const SDK_FRAME_TAG = "claude-sdk";
export type SdkFrameKind = FrameRecord["kind"];
export type SdkFrameDir = "→" | "←";
export interface SdkFrameSinkLike {
append(record: FrameRecord): void;
}
export interface LogSdkFrameArgs {
dir: SdkFrameDir;
method: string;
/** SDK session id, request id, or any correlator. Null/undefined for un-keyed frames. */
id?: string | null;
payload?: unknown;
/** Defaults to "request" for outbound, "notif" for inbound. */
kind?: SdkFrameKind;
}
/**
* Internal entry point. Exposed for tests; production callers should use
* the convenience wrappers below.
*/
export function logSdkFrame(args: LogSdkFrameArgs, sink: SdkFrameSinkLike = frameSink): void {
const id = args.id ?? null;
const idLabel = id !== null ? `#${id}` : args.kind === "notif" ? "(notif)" : "(no-id)";
logInfo(
`[ACP ${args.dir}][${SDK_FRAME_TAG}] ${args.method} ${idLabel} ${formatPayload(args.payload)}`
);
if (!getSettings().agentMode?.debugFullFrames) return;
sink.append({
ts: new Date().toISOString(),
dir: args.dir,
tag: SDK_FRAME_TAG,
kind: args.kind ?? (args.dir === "→" ? "request" : "notif"),
method: args.method,
id,
payload: args.payload,
});
}
/** Outbound RPC or control call (we → SDK). */
export function logSdkOutbound(
method: string,
payload: unknown,
id?: string | null,
sink?: SdkFrameSinkLike
): void {
logSdkFrame({ dir: "→", method, id, payload, kind: "request" }, sink);
}
/** Outbound RPC result (we → caller / response we are about to return). */
export function logSdkOutboundResult(
method: string,
payload: unknown,
id?: string | null,
sink?: SdkFrameSinkLike
): void {
logSdkFrame({ dir: "→", method, id, payload, kind: "result" }, sink);
}
/** Inbound SDK message or translated ACP notification (SDK → us). */
export function logSdkInbound(
method: string,
payload: unknown,
id?: string | null,
sink?: SdkFrameSinkLike
): void {
logSdkFrame({ dir: "←", method, id, payload, kind: "notif" }, sink);
}
/** Inbound or outbound error frame. */
export function logSdkError(
dir: SdkFrameDir,
method: string,
payload: unknown,
id?: string | null,
sink?: SdkFrameSinkLike
): void {
logSdkFrame({ dir, method, id, payload, kind: "error" }, sink);
}
/**
* Synthesize a stable "method" label for an SDK message so the trace reads
* similarly to ACP JSON-RPC method names. `stream_event` carries the inner
* event type to make the high-frequency stream readable.
*/
export function describeSdkMessage(msg: unknown): string {
const m = msg as { type?: unknown; event?: { type?: unknown }; subtype?: unknown };
if (!m || typeof m.type !== "string") return "(unknown)";
if (m.type === "stream_event") {
const ev = m.event && typeof m.event === "object" ? m.event : null;
const evType = ev && typeof ev.type === "string" ? ev.type : "?";
return `stream_event:${evType}`;
}
if (m.type === "result" && typeof m.subtype === "string") {
return `result:${m.subtype}`;
}
return m.type;
}

View file

@ -0,0 +1,537 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
const SESSION_ID = "session-test-1";
function streamEvent(event: object): SDKMessage {
return {
type: "stream_event",
event,
parent_tool_use_id: null,
uuid: "uuid-1" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
} as SDKMessage;
}
describe("translateSdkMessage", () => {
it("emits agent_message_chunk for text deltas", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello, world" },
}),
SESSION_ID,
state
);
expect(out).toEqual([
{
sessionId: SESSION_ID,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hello, world" },
},
},
]);
});
it("emits agent_thought_chunk for thinking deltas", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "Let me think..." },
}),
SESSION_ID,
state
);
expect(out).toEqual([
{
sessionId: SESSION_ID,
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Let me think..." },
},
},
]);
});
it("ignores non-text deltas (input_json, signature, citations)", () => {
const state = createTranslatorState();
expect(
translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"a":1}' },
}),
SESSION_ID,
state
)
).toEqual([]);
expect(
translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "sig" },
}),
SESSION_ID,
state
)
).toEqual([]);
});
it("emits nothing for message_start/stop and content_block_start/stop in Chunk 1", () => {
const state = createTranslatorState();
expect(
translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state)
).toEqual([]);
expect(translateSdkMessage(streamEvent({ type: "message_stop" }), SESSION_ID, state)).toEqual(
[]
);
expect(
translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
}),
SESSION_ID,
state
)
).toEqual([]);
expect(
translateSdkMessage(streamEvent({ type: "content_block_stop", index: 0 }), SESSION_ID, state)
).toEqual([]);
});
it("clears toolUseBlocks state on message_start", () => {
const state = createTranslatorState();
state.toolUseBlocks.set(0, {
id: "t1",
name: "Tool",
inputJsonAcc: "",
lastParsedInput: {},
emittedToolCall: false,
});
translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state);
expect(state.toolUseBlocks.size).toBe(0);
});
it("returns [] for `result` (caller resolves the prompt promise separately)", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
{
type: "result",
subtype: "success",
duration_ms: 0,
duration_api_ms: 0,
is_error: false,
num_turns: 1,
result: "ok",
stop_reason: "end_turn",
total_cost_usd: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
usage: {} as any,
modelUsage: {},
permission_denials: [],
uuid: "uuid-2" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out).toEqual([]);
});
it("ignores assistant messages whose tool_use blocks were already streamed", () => {
const state = createTranslatorState();
// Pretend the streaming path already saw this tool_use.
state.emittedToolUseIds.add("tool-1");
expect(
translateSdkMessage(
{
type: "assistant",
message: {
content: [
{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "a.md" } },
],
} as never,
parent_tool_use_id: null,
uuid: "uuid-a" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
)
).toEqual([]);
});
it("emits tool_call on content_block_start for tool_use blocks", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-1", name: "Read", input: {} },
}),
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tu-1",
kind: "read",
vendorToolName: "Read",
});
});
it("emits tool_call_update with parsed rawInput on input_json_delta", () => {
const state = createTranslatorState();
translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-2", name: "Read", input: {} },
}),
SESSION_ID,
state
);
const out = translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"file_path":"a.md"}' },
}),
SESSION_ID,
state
);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tu-2",
rawInput: { file_path: "a.md" },
});
});
it("emits tool_call_update with status in_progress on content_block_stop", () => {
const state = createTranslatorState();
translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-3", name: "ExitPlanMode", input: {} },
}),
SESSION_ID,
state
);
translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"plan":"Step 1"}' },
}),
SESSION_ID,
state
);
const out = translateSdkMessage(
streamEvent({ type: "content_block_stop", index: 0 }),
SESSION_ID,
state
);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tu-3",
rawInput: { plan: "Step 1" },
status: "in_progress",
});
});
it("emits tool_call_update with status completed for tool_result (success)", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tu-4",
content: "vault contents",
is_error: false,
},
],
} as never,
parent_tool_use_id: null,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tu-4",
status: "completed",
});
});
it("emits tool_call_update with status failed when tool_result.is_error is true", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
{
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "tu-5", content: "boom", is_error: true }],
} as never,
parent_tool_use_id: null,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out[0].update).toMatchObject({ status: "failed" });
});
it("synthesizes current_mode_update on EnterPlanMode tool_use", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-plan", name: "EnterPlanMode", input: {} },
}),
SESSION_ID,
state
);
expect(out).toHaveLength(2);
expect(out[0].update).toMatchObject({ sessionUpdate: "tool_call", toolCallId: "tu-plan" });
expect(out[1].update).toMatchObject({
sessionUpdate: "current_mode_update",
currentModeId: "plan",
});
});
it("strips the mcp__<server>__ prefix when the server name itself contains underscores", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tu-mcp-underscored",
name: "mcp__my_server__do_thing",
input: {},
},
}),
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tu-mcp-underscored",
vendorToolName: "do_thing",
});
});
it("strips the mcp__<server>__ prefix from MCP tool names so kind/title/meta see the bare name", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "tu-mcp",
name: "mcp__custom-server__do_thing",
input: { path: "Daily/2026-05-01.md" },
},
}),
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tu-mcp",
title: "do_thing Daily/2026-05-01.md",
vendorToolName: "do_thing",
});
});
it("emits ExitPlanMode tool_call with kind=switch_mode (routes through plan-proposal flow)", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-exit", name: "ExitPlanMode", input: {} },
}),
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tu-exit",
kind: "switch_mode",
vendorToolName: "ExitPlanMode",
isPlanProposal: true,
});
});
it("threads parent_tool_use_id into parentToolCallId on streamed tool_use", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
{
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "child-1", name: "Read", input: {} },
},
parent_tool_use_id: "task-parent-1",
uuid: "uuid-p" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "child-1",
vendorToolName: "Read",
parentToolCallId: "task-parent-1",
});
});
it("threads parent_tool_use_id through tool_call_update on input_json_delta", () => {
const state = createTranslatorState();
translateSdkMessage(
{
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "child-2", name: "Read", input: {} },
},
parent_tool_use_id: "task-parent-2",
uuid: "uuid-p2" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
const out = translateSdkMessage(
{
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"path":"a.md"}' },
},
parent_tool_use_id: "task-parent-2",
uuid: "uuid-p3" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "child-2",
vendorToolName: "Read",
parentToolCallId: "task-parent-2",
});
});
it("omits parentToolCallId when parent_tool_use_id is null", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-top", name: "Read", input: {} },
}),
SESSION_ID,
state
);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
vendorToolName: "Read",
});
expect(out[0].update).not.toHaveProperty("parentToolCallId");
});
it("threads parent_tool_use_id on assistant-message fallback path", () => {
const state = createTranslatorState();
const out = translateSdkMessage(
{
type: "assistant",
message: {
content: [{ type: "tool_use", id: "child-3", name: "Read", input: { path: "a.md" } }],
} as never,
parent_tool_use_id: "task-parent-3",
uuid: "uuid-p4" as `${string}-${string}-${string}-${string}-${string}`,
session_id: SESSION_ID,
},
SESSION_ID,
state
);
expect(out).toHaveLength(1);
expect(out[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "child-3",
vendorToolName: "Read",
parentToolCallId: "task-parent-3",
});
});
it("ignores partial input_json that doesn't parse yet", () => {
const state = createTranslatorState();
translateSdkMessage(
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "tu-6", name: "Read", input: {} },
}),
SESSION_ID,
state
);
const out = translateSdkMessage(
streamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"path":"a.md' },
}),
SESSION_ID,
state
);
expect(out).toEqual([]);
});
});
describe("mapStopReason", () => {
it("maps success → end_turn", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(mapStopReason({ type: "result", subtype: "success" } as any)).toBe("end_turn");
});
it("maps error variants → cancelled", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(mapStopReason({ type: "result", subtype: "error_during_execution" } as any)).toBe(
"cancelled"
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled");
});
});

View file

@ -0,0 +1,323 @@
/** Pure translator: Claude Agent SDK `SDKMessage` → session-domain `SessionUpdate`. */
import type {
SDKAssistantMessage,
SDKMessage,
SDKPartialAssistantMessage,
SDKResultMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type {
AgentToolStatus,
SessionEvent,
SessionId,
SessionUpdate,
ToolCallContent,
} from "@/agentMode/session/types";
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
/**
* Mutable per-query translator state. One instance lives for the duration of
* a single `query()` call; reset whenever a new turn starts.
*/
export interface TranslatorState {
toolUseBlocks: Map<
number,
{
id: string;
name: string;
inputJsonAcc: string;
lastParsedInput: unknown;
emittedToolCall: boolean;
}
>;
/** Tool-use ids already emitted in this turn — used to dedupe in the assistant-message fallback path. */
emittedToolUseIds: Set<string>;
}
export function createTranslatorState(): TranslatorState {
return { toolUseBlocks: new Map(), emittedToolUseIds: new Set() };
}
function event(sessionId: SessionId, update: SessionUpdate): SessionEvent {
return { sessionId, update };
}
/**
* Translate one SDK message to zero or more session-domain events. Returning
* an array (rather than firing a callback) keeps the function pure and
* trivially testable; the caller decides what to do with the events and when
* to terminate the prompt promise.
*/
export function translateSdkMessage(
msg: SDKMessage,
sessionId: SessionId,
state: TranslatorState
): SessionEvent[] {
switch (msg.type) {
case "stream_event":
return translateStreamEvent(msg, sessionId, state);
case "assistant":
return translateAssistantMessage(msg, sessionId, state);
case "user":
return translateUserMessage(msg, sessionId, state);
case "result":
default:
return [];
}
}
export function mapStopReason(msg: SDKResultMessage): "end_turn" | "cancelled" | "refusal" {
if (msg.subtype === "success") return "end_turn";
return "cancelled";
}
function translateStreamEvent(
msg: SDKPartialAssistantMessage,
sessionId: SessionId,
state: TranslatorState
): SessionEvent[] {
const parentToolUseId = msg.parent_tool_use_id ?? undefined;
const sdkEvent = msg.event as
| { type: "message_start"; message?: unknown }
| { type: "message_stop" }
| { type: "message_delta"; delta?: unknown; usage?: unknown }
| {
type: "content_block_start";
index: number;
content_block:
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: "thinking"; thinking: string }
| { type: "redacted_thinking" };
}
| {
type: "content_block_delta";
index: number;
delta:
| { type: "text_delta"; text: string }
| { type: "thinking_delta"; thinking: string }
| { type: "input_json_delta"; partial_json: string }
| { type: "signature_delta"; signature: string }
| { type: "citations_delta"; citation: unknown };
}
| { type: "content_block_stop"; index: number };
switch (sdkEvent.type) {
case "message_start":
state.toolUseBlocks.clear();
return [];
case "content_block_start": {
const block = sdkEvent.content_block;
if (block.type === "tool_use") {
const name = normalizeToolName(block.name);
state.toolUseBlocks.set(sdkEvent.index, {
id: block.id,
name,
inputJsonAcc: "",
lastParsedInput: block.input ?? {},
emittedToolCall: true,
});
state.emittedToolUseIds.add(block.id);
const out: SessionEvent[] = [
event(sessionId, makeToolCallUpdate(block.id, name, block.input ?? {}, parentToolUseId)),
];
if (name === "EnterPlanMode") {
out.push(
event(sessionId, {
sessionUpdate: "current_mode_update",
currentModeId: "plan",
})
);
}
return out;
}
return [];
}
case "content_block_delta": {
const delta = sdkEvent.delta;
if (delta.type === "text_delta") {
return [
event(sessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta.text },
}),
];
}
if (delta.type === "thinking_delta") {
return [
event(sessionId, {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: delta.thinking },
}),
];
}
if (delta.type === "input_json_delta") {
const block = state.toolUseBlocks.get(sdkEvent.index);
if (!block) return [];
block.inputJsonAcc += delta.partial_json;
// Cheap pre-check: a complete JSON value's last non-whitespace byte
// is `}`, `]`, `"`, a digit, or one of the literals' last letters.
// Skipping JSON.parse on obviously-incomplete buffers (mid-key,
// mid-string) avoids O(N) work per delta when a large tool input
// streams across many small chunks.
if (!couldBeCompleteJson(block.inputJsonAcc)) return [];
const parsed = tryParseJson(block.inputJsonAcc);
if (!parsed.ok) return [];
block.lastParsedInput = parsed.value;
return [
event(sessionId, {
sessionUpdate: "tool_call_update",
toolCallId: block.id,
rawInput: parsed.value,
...vendorMetaFields(block.name, parentToolUseId),
}),
];
}
return [];
}
case "content_block_stop": {
const block = state.toolUseBlocks.get(sdkEvent.index);
if (!block) return [];
const parsed = tryParseJson(block.inputJsonAcc);
const finalInput = parsed.ok ? parsed.value : block.lastParsedInput;
block.lastParsedInput = finalInput;
return [
event(sessionId, {
sessionUpdate: "tool_call_update",
toolCallId: block.id,
rawInput: finalInput,
status: "in_progress" as AgentToolStatus,
...vendorMetaFields(block.name, parentToolUseId),
}),
];
}
case "message_delta":
case "message_stop":
default:
return [];
}
}
function translateAssistantMessage(
msg: SDKAssistantMessage,
sessionId: SessionId,
state: TranslatorState
): SessionEvent[] {
const out: SessionEvent[] = [];
const content = (msg.message as { content?: unknown }).content;
if (!Array.isArray(content)) return out;
const parentToolUseId = msg.parent_tool_use_id ?? undefined;
for (const block of content) {
const b = block as { type?: string; id?: string; name?: string; input?: unknown };
if (b.type !== "tool_use" || !b.id || !b.name) continue;
if (state.emittedToolUseIds.has(b.id)) continue;
state.emittedToolUseIds.add(b.id);
out.push(event(sessionId, makeToolCallUpdate(b.id, b.name, b.input ?? {}, parentToolUseId)));
}
return out;
}
function translateUserMessage(
msg: SDKUserMessage,
sessionId: SessionId,
_state: TranslatorState
): SessionEvent[] {
const content = (msg.message as { content?: unknown }).content;
if (!Array.isArray(content)) return [];
const out: SessionEvent[] = [];
for (const block of content) {
const b = block as {
type?: string;
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
};
if (b.type !== "tool_result" || !b.tool_use_id) continue;
const status: AgentToolStatus = b.is_error ? "failed" : "completed";
const outputs = toolResultContent(b.content);
out.push(
event(sessionId, {
sessionUpdate: "tool_call_update",
toolCallId: b.tool_use_id,
status,
content: outputs,
})
);
}
return out;
}
function makeToolCallUpdate(
toolCallId: string,
normalizedName: string,
rawInput: unknown,
parentToolUseId?: string
): SessionUpdate {
return {
sessionUpdate: "tool_call",
toolCallId,
title: deriveToolTitle(normalizedName, rawInput),
kind: deriveToolKind(normalizedName),
status: "in_progress" as AgentToolStatus,
rawInput,
...vendorMetaFields(normalizedName, parentToolUseId),
};
}
/**
* Strip the SDK's `mcp__<server>__` prefix on MCP tool names so downstream UI
* mapping (kind / title / vendorToolName) sees the bare tool name. The
* non-greedy middle segment tolerates server names containing underscores.
*/
function normalizeToolName(name: string): string {
const m = /^mcp__.+?__(.+)$/.exec(name);
return m ? m[1] : name;
}
function toolResultContent(content: unknown): ToolCallContent[] | undefined {
if (typeof content === "string") {
return [{ type: "content", content: { type: "text", text: content } }];
}
if (!Array.isArray(content)) return undefined;
const out: ToolCallContent[] = [];
for (const block of content) {
const b = block as { type?: string; text?: unknown };
if (b.type === "text" && typeof b.text === "string") {
out.push({ type: "content", content: { type: "text", text: b.text } });
}
}
return out.length > 0 ? out : undefined;
}
type ParseResult = { ok: true; value: unknown } | { ok: false };
function tryParseJson(raw: string): ParseResult {
if (raw.trim().length === 0) return { ok: true, value: {} };
try {
return { ok: true, value: JSON.parse(raw) };
} catch {
return { ok: false };
}
}
function couldBeCompleteJson(raw: string): boolean {
let i = raw.length - 1;
while (i >= 0) {
const c = raw.charCodeAt(i);
// Skip ASCII whitespace.
if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
i--;
continue;
}
// }, ], ", e (true/false), l (null), or any digit can end a JSON value.
return (
c === 0x7d || // }
c === 0x5d || // ]
c === 0x22 || // "
c === 0x65 || // e
c === 0x6c || // l
(c >= 0x30 && c <= 0x39) // 0-9
);
}
return false;
}

View file

@ -0,0 +1,63 @@
import type { AgentToolKind } from "@/agentMode/session/types";
export interface VendorMetaFields {
vendorToolName: string;
parentToolCallId?: string;
isPlanProposal?: boolean;
}
/**
* Caller passes the normalized tool name (any `mcp__server__` prefix
* already stripped). `isPlanProposal` is omitted unless true so the flag
* doesn't leak onto unrelated tool calls.
*/
export function vendorMetaFields(
normalizedName: string,
parentToolCallId?: string
): VendorMetaFields {
const fields: VendorMetaFields = { vendorToolName: normalizedName };
if (parentToolCallId) fields.parentToolCallId = parentToolCallId;
if (normalizedName === "ExitPlanMode") fields.isPlanProposal = true;
return fields;
}
export function deriveToolKind(toolName: string): AgentToolKind {
if (toolName === "ExitPlanMode" || toolName === "EnterPlanMode") return "switch_mode";
const lower = toolName.toLowerCase();
if (lower === "read" || lower === "glob" || lower === "grep" || lower === "ls") {
return "read";
}
if (lower === "write" || lower === "edit" || lower === "multiedit") {
return "edit";
}
if (lower === "bash") return "execute";
if (lower === "websearch" || lower === "webfetch") return "fetch";
if (lower === "todowrite" || lower === "task" || lower === "agent") return "think";
return "other";
}
/**
* Build a one-line "what is the agent doing" title surfaced on the action
* card. `titleOverride` short-circuits when the SDK already supplied one
* (e.g. via `canUseTool` ctx).
*/
export function deriveToolTitle(
toolName: string,
rawInput: unknown,
titleOverride?: string
): string {
if (typeof titleOverride === "string" && titleOverride.length > 0) return titleOverride;
const input = rawInput as Record<string, unknown> | null | undefined;
if (input && typeof input === "object") {
if (typeof input.path === "string") return `${toolName} ${input.path}`;
if (typeof input.file_path === "string") return `${toolName} ${input.file_path}`;
if (typeof input.command === "string") return `${toolName}: ${truncate(input.command, 60)}`;
if (typeof input.pattern === "string") return `${toolName} ${truncate(input.pattern, 60)}`;
if (typeof input.url === "string") return `${toolName} ${input.url}`;
}
return toolName;
}
export function truncate(s: string, n: number): string {
return s.length > n ? `${s.slice(0, n - 1)}` : s;
}

View file

@ -0,0 +1,76 @@
import type { MessageContext } from "@/types/message";
import type { AgentChatMessage, BackendState, CurrentPlan, PlanDecisionAction } from "./types";
/**
* Narrow interface the Agent Mode UI tree consumes. Implemented by
* `AgentChatUIState`. Distinct from the legacy `ChatUIState` because Agent
* Mode has no edit/regenerate/persistence flow and no chain-type or
* include-active-note plumbing ACP owns those concerns server-side.
*
* `sendMessage` returns `{ id, turn }` so the caller can synchronously read
* the new user message id (for input history) and separately await the full
* turn for loading-state management.
*/
export interface AgentChatBackend {
subscribe(listener: () => void): () => void;
sendMessage(
text: string,
context?: MessageContext,
content?: unknown[]
): { id: string; turn: Promise<void> };
cancel(): Promise<void>;
deleteMessage(id: string): Promise<boolean>;
clearMessages(): void;
getMessages(): AgentChatMessage[];
/** True while ACP `session/new` is still in flight. Send is gated on this. */
isStarting(): boolean;
/** Latest unified picker state, or `null` while the backend session is still starting. */
getBackendState(): BackendState | null;
/**
* Intent-level capability probes. Tri-state: null = not yet probed,
* true/false = result. The session encapsulates wire routing
* (descriptor-style vs suffix-style effort, `setMode` vs
* `setConfigOption` mode dispatch) UI consumers ask intent only.
*/
canSwitchModel(): boolean | null;
canSwitchEffort(): boolean | null;
canSwitchMode(): boolean | null;
/**
* Resolve the current plan proposal the user has decided on. Branches on
* `currentPlan.permissionGated`:
* - gated (Claude Code ExitPlanMode): resolves the underlying ACP
* permission as allow/deny. Approve auto-continues the agent's turn;
* Reject ends the turn; Feedback denies with `feedbackText` as the
* agent-visible deny reason.
* - non-gated (OpenCode end-of-turn, or backends whose plan-exit signal
* carries no permission): Approve switches to canonical `build` mode
* (when the descriptor advertises one) and sends a `Proceed with the
* plan.` follow-up; Reject is informational; Feedback sends
* `feedbackText` as the next user turn (mode stays in plan).
*
* `proposalId` must match the current `getCurrentPlan().id` stale
* resolutions (the user clicked a card that has since been replaced)
* are silently ignored.
*/
resolvePlanProposal(
proposalId: string,
decision: PlanDecisionAction,
feedbackText?: string
): Promise<void>;
/**
* Singleton plan-mode review state, or `null` when there's nothing to
* surface. The floating plan card and the editor preview tab read this.
*/
getCurrentPlan(): CurrentPlan | null;
/**
* True when an ExitPlanMode permission is currently pending. The chat input
* disables itself while one is outstanding so the user is funneled to the
* proposal card's actions.
*/
hasPendingPlanPermission(): boolean;
}

View file

@ -0,0 +1,182 @@
/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */
import { AI_SENDER, USER_SENDER } from "@/constants";
import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import type { AgentChatMessage } from "./types";
import type { App, TFile } from "obsidian";
jest.mock("obsidian", () => ({
Notice: jest.fn(),
TFile: jest.fn(),
}));
jest.mock("@/logger");
jest.mock("@/settings/model", () => ({
getSettings: jest.fn().mockReturnValue({
defaultSaveFolder: "test-folder",
defaultConversationTag: "copilot-conversation",
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
}),
}));
jest.mock("@/utils", () => ({
ensureFolderExists: jest.fn(async () => {}),
formatDateTime: jest.fn(() => ({
fileName: "20260101_120000",
display: "2026/01/01 12:00:00",
})),
getUtf8ByteLength: jest.fn((s: string) => new TextEncoder().encode(s).length),
truncateToByteLimit: jest.fn((s: string, n: number) => {
const bytes = new TextEncoder().encode(s);
if (bytes.length <= n) return s;
return new TextDecoder().decode(bytes.slice(0, n));
}),
}));
jest.mock("@/utils/vaultAdapterUtils", () => ({
isInVaultCache: jest.fn(() => false),
listMarkdownFiles: jest.fn().mockResolvedValue([]),
readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null),
}));
interface FakeFile {
path: string;
basename: string;
contents?: string;
}
/**
* Build a minimal in-memory `app` mock that records files written via
* `vault.create` / `vault.adapter.write` so a round-trip save/load test can
* read what the previous step wrote without wiring real disk I/O.
*/
function makeApp() {
const files = new Map<string, FakeFile>();
return {
files,
vault: {
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
create: jest.fn(async (path: string, content: string) => {
const basename = path.split("/").pop()!.replace(/\.md$/, "");
const file = { path, basename, contents: content };
files.set(path, file);
return file;
}),
modify: jest.fn(async (file: FakeFile, content: string) => {
file.contents = content;
}),
read: jest.fn(async (file: FakeFile) => file.contents ?? ""),
delete: jest.fn(async (file: FakeFile) => {
files.delete(file.path);
}),
adapter: {
exists: jest.fn(async (path: string) => files.has(path)),
read: jest.fn(async (path: string) => files.get(path)?.contents ?? ""),
write: jest.fn(async (path: string, content: string) => {
const existing = files.get(path);
if (existing) {
existing.contents = content;
} else {
const basename = path.split("/").pop()!.replace(/\.md$/, "");
files.set(path, { path, basename, contents: content });
}
}),
remove: jest.fn(async (path: string) => {
files.delete(path);
}),
},
},
metadataCache: {
getFileCache: jest.fn(() => undefined),
},
fileManager: {
processFrontMatter: jest.fn(),
},
};
}
function makeMessage(sender: string, message: string, epoch = 1735732800000): AgentChatMessage {
return {
id: `msg-${epoch}`,
sender,
message,
isVisible: true,
timestamp: { epoch, display: "2026/01/01 12:00:00", fileName: "20260101_120000" },
};
}
describe("AgentChatPersistenceManager", () => {
let app: ReturnType<typeof makeApp>;
let manager: AgentChatPersistenceManager;
beforeEach(() => {
app = makeApp();
manager = new AgentChatPersistenceManager(app as unknown as App);
});
it("round-trips messages, backendId, and label", async () => {
const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")];
const saved = await manager.saveSession(messages, "claude-code", { label: "My chat" });
expect(saved).not.toBeNull();
const file = app.files.get(saved!.path)!;
const loaded = await manager.loadFile(file as unknown as TFile);
expect(loaded.backendId).toBe("claude-code");
expect(loaded.label).toBe("My chat");
expect(loaded.messages).toHaveLength(2);
expect(loaded.messages[0].sender).toBe(USER_SENDER);
expect(loaded.messages[0].message).toBe("hello world");
expect(loaded.messages[1].sender).toBe(AI_SENDER);
expect(loaded.messages[1].message).toBe("hi back");
});
it("escapes and round-trips a label containing quotes and backslashes", async () => {
const tricky = 'has "quotes" and \\backslashes\\';
const messages = [makeMessage(USER_SENDER, "hi")];
const saved = await manager.saveSession(messages, "opencode", { label: tricky });
expect(saved).not.toBeNull();
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
expect(loaded.label).toBe(tricky);
});
it("strips control characters from labels so they can't break frontmatter", async () => {
const messages = [makeMessage(USER_SENDER, "hi")];
const saved = await manager.saveSession(messages, "opencode", {
label: "first\nsecond\rthird",
});
const raw = app.files.get(saved!.path)!.contents!;
// The label line must remain a single key:value entry.
const labelLines = raw.split("\n").filter((l) => l.startsWith("agentLabel:"));
expect(labelLines).toHaveLength(1);
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
expect(loaded.label).toBe("first second third");
});
it("throws on missing backendId instead of silently defaulting", async () => {
const path = "test-folder/agent__broken.md";
await app.vault.adapter.write(
path,
["---", "epoch: 1735732800000", "mode: agent", "---", "", "**user**: hi"].join("\n")
);
await expect(
manager.loadFile({ path, basename: "agent__broken" } as unknown as TFile)
).rejects.toThrow(/Missing backendId/);
});
it("assigns deterministic ids that depend only on message timestamp", async () => {
const messages = [
makeMessage(USER_SENDER, "first", 1700000000000),
makeMessage(AI_SENDER, "second", 1700000000001),
];
const saved = await manager.saveSession(messages, "claude-code");
const file = app.files.get(saved!.path)!;
const loadedA = await manager.loadFile(file as unknown as TFile);
const loadedB = await manager.loadFile(file as unknown as TFile);
// The key contract: same file + same content → same ids across reloads.
expect(loadedA.messages.map((m) => m.id)).toEqual(loadedB.messages.map((m) => m.id));
expect(loadedA.messages[0].id.startsWith("loaded-0-")).toBe(true);
});
it("returns null when given zero messages instead of writing an empty file", async () => {
const result = await manager.saveSession([], "opencode");
expect(result).toBeNull();
expect(app.files.size).toBe(0);
});
});

View file

@ -0,0 +1,453 @@
import { AI_SENDER, USER_SENDER } from "@/constants";
import { logError, logInfo, logWarn } from "@/logger";
import { getSettings } from "@/settings/model";
import { FormattedDateTime } from "@/types/message";
import {
ensureFolderExists,
formatDateTime,
getUtf8ByteLength,
truncateToByteLimit,
} from "@/utils";
import {
isFileAlreadyExistsError,
isInVaultCache,
isNameTooLongError,
listMarkdownFiles,
patchFrontmatter,
readFrontmatterViaAdapter,
trashFile,
} from "@/utils/vaultAdapterUtils";
import { TFile, type App } from "obsidian";
import { Notice } from "obsidian";
import type { AgentChatMessage, BackendId } from "./types";
const SAFE_FILENAME_BYTE_LIMIT = 100;
export const AGENT_FILENAME_PREFIX = "agent__";
export const AGENT_CHAT_MODE = "agent";
/**
* Result of `loadFile` restores display-only Agent Mode messages plus
* routing info needed to spawn the right backend session.
*/
export interface LoadedAgentChat {
messages: AgentChatMessage[];
backendId: BackendId;
topic?: string;
label?: string;
}
interface ExistingMeta {
topic?: string;
label?: string;
lastAccessedAt?: number;
}
/**
* Escape a string for safe YAML double-quoted string value. Strips control
* chars (including newlines) up front a stray `\n` in the user's topic
* would otherwise terminate the line and corrupt the rest of the frontmatter.
*/
function escapeYamlString(str: string): string {
return (
str
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, " ")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
);
}
/**
* Inverse of `escapeYamlString` for the values our hand-rolled frontmatter
* parser extracts. Only handles the two escapes we emit (`\\` and `\"`).
*/
function unescapeYamlString(str: string): string {
let out = "";
for (let i = 0; i < str.length; i++) {
const c = str[i];
if (c === "\\" && i + 1 < str.length) {
const next = str[i + 1];
if (next === "\\" || next === '"') {
out += next;
i++;
continue;
}
}
out += c;
}
return out;
}
/**
* Backend-agnostic on-disk persistence for Agent Mode sessions. Mirrors the
* legacy `ChatPersistenceManager` shape so a single `ChatHistoryPopover` can
* render both lists, but with three differences:
*
* 1. Files are prefixed with `agent__` so they never collide with legacy
* project-prefixed (`{projectId}__`) or unprefixed chats.
* 2. Frontmatter records `mode: agent` and `backendId: <id>` so the loader
* can route a history click to the right backend.
* 3. No project / AI-topic generation Agent Mode has no project concept
* and no chain manager to generate titles with.
*
* Sessions with zero visible messages are never written.
*/
export class AgentChatPersistenceManager {
constructor(private readonly app: App) {}
/**
* Save the supplied messages to disk. Returns the resulting file (or the
* existing path on hidden-directory writes), or `null` when the session has
* nothing user-visible to persist.
*
* `existingPath` lets the caller pin updates to a previously-saved file so
* they're applied even if the messages list shrinks below the original
* `firstMessageEpoch`. When omitted, the file is matched by epoch.
*/
async saveSession(
messages: AgentChatMessage[],
backendId: BackendId,
options?: { label?: string | null; modelKey?: string; existingPath?: string }
): Promise<{ path: string } | null> {
if (messages.length === 0) return null;
try {
const settings = getSettings();
const chatContent = this.formatChatContent(messages);
const firstMessageEpoch = messages[0].timestamp?.epoch ?? Date.now();
await ensureFolderExists(settings.defaultSaveFolder);
const existingFile = options?.existingPath
? this.resolveExistingFile(options.existingPath)
: null;
const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {};
const preferredFileName = existingFile
? existingFile.path
: this.generateFileName(messages, firstMessageEpoch, existingMeta.topic);
const noteContent = this.generateNoteContent({
chatContent,
firstMessageEpoch,
backendId,
topic: existingMeta.topic,
label: options?.label ?? existingMeta.label,
modelKey: options?.modelKey,
lastAccessedAt: existingMeta.lastAccessedAt,
});
if (existingFile && isInVaultCache(this.app, existingFile.path)) {
await this.app.vault.modify(existingFile, noteContent);
return { path: existingFile.path };
}
if (
!isInVaultCache(this.app, preferredFileName) &&
(await this.app.vault.adapter.exists(preferredFileName))
) {
await this.app.vault.adapter.write(preferredFileName, noteContent);
return { path: preferredFileName };
}
try {
const created = await this.app.vault.create(preferredFileName, noteContent);
return { path: created.path };
} catch (err) {
if (isFileAlreadyExistsError(err)) {
await this.app.vault.adapter.write(preferredFileName, noteContent);
return { path: preferredFileName };
}
if (isNameTooLongError(err)) {
logWarn("[AgentChatPersistenceManager] Filename too long, falling back to minimal name");
const fallback = `${settings.defaultSaveFolder}/${AGENT_FILENAME_PREFIX}chat-${firstMessageEpoch}.md`;
try {
const created = await this.app.vault.create(fallback, noteContent);
return { path: created.path };
} catch (fallbackErr) {
if (isFileAlreadyExistsError(fallbackErr)) {
await this.app.vault.adapter.write(fallback, noteContent);
return { path: fallback };
}
throw fallbackErr;
}
}
throw err;
}
} catch (error) {
logError("[AgentChatPersistenceManager] Error saving session:", error);
return null;
}
}
/**
* Parse a saved agent chat file back into `AgentChatMessage`s and routing
* info. Tool/plan/thought parts are not restored the markdown format only
* preserves sender + text (display-only history, mirroring legacy mode).
*/
async loadFile(file: TFile): Promise<LoadedAgentChat> {
let content: string;
try {
content = await this.app.vault.read(file);
} catch {
content = await this.app.vault.adapter.read(file.path);
}
const { frontmatter, body } = this.splitFrontmatter(content);
const backendId = (frontmatter.backendId ?? "").trim();
if (!backendId) {
throw new Error(`Missing backendId in agent chat frontmatter: ${file.path}`);
}
const topic = frontmatter.topic?.trim() || undefined;
const label = frontmatter.agentLabel?.trim() || undefined;
const messages = this.parseChatBody(body);
logInfo(
`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId})`
);
return { messages, backendId, topic, label };
}
/**
* List every persisted Agent Mode chat file (across all backends). Filters
* by the `agent__` filename prefix so the result is backend-agnostic and
* never collides with legacy or project chats.
*/
async getAgentChatHistoryFiles(): Promise<TFile[]> {
const settings = getSettings();
const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder);
return files.filter((file) => file.basename.startsWith(AGENT_FILENAME_PREFIX));
}
/** Update the user-visible topic in frontmatter. */
async updateTopic(fileId: string, newTopic: string): Promise<void> {
await patchFrontmatter(this.app, fileId, { topic: newTopic.trim() });
}
async deleteFile(fileId: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file) {
await trashFile(this.app, file);
new Notice("Chat moved to trash.");
return;
}
if (await this.app.vault.adapter.exists(fileId)) {
await this.app.vault.adapter.remove(fileId);
new Notice("Chat deleted.");
return;
}
throw new Error("Chat file not found.");
}
private resolveExistingFile(path: string): TFile | null {
const file = this.app.vault.getAbstractFileByPath(path);
return file instanceof TFile ? file : null;
}
private async readExistingMeta(file: TFile): Promise<ExistingMeta> {
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (cached) {
return {
topic: cached.topic,
label: cached.agentLabel,
lastAccessedAt:
typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined,
};
}
try {
const fm = await readFrontmatterViaAdapter(this.app, file.path);
if (!fm) return {};
const lastAccessed = fm.lastAccessedAt ? Number(fm.lastAccessedAt) : undefined;
return {
topic: fm.topic,
label: fm.agentLabel,
lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined,
};
} catch {
return {};
}
}
private formatChatContent(messages: AgentChatMessage[]): string {
return messages
.map((m) => {
const ts = m.timestamp ? m.timestamp.display : "Unknown time";
return `**${m.sender}**: ${m.message}\n[Timestamp: ${ts}]`;
})
.join("\n\n");
}
private parseChatBody(body: string): AgentChatMessage[] {
const messages: AgentChatMessage[] = [];
const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(body)) !== null) {
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
const fullContent = match[2].trim();
const lines = fullContent.split("\n");
let endIndex = lines.length;
let timestampStr = "Unknown time";
if (lines[endIndex - 1]?.startsWith("[Timestamp: ")) {
const tsMatch = lines[endIndex - 1].match(/\[Timestamp: (.*?)\]/);
if (tsMatch) {
timestampStr = tsMatch[1];
endIndex--;
}
}
const messageText = lines.slice(0, endIndex).join("\n").trim();
let timestamp: FormattedDateTime | null = null;
if (timestampStr !== "Unknown time") {
const date = new Date(timestampStr);
if (!isNaN(date.getTime())) {
timestamp = {
epoch: date.getTime(),
display: timestampStr,
fileName: "",
};
}
}
// Deterministic id: stable across reloads so React keeps message
// identity when the UI re-renders the same loaded chat. Uses the
// message's own epoch when present, falling back to the index.
const id = timestamp
? `loaded-${messages.length}-${timestamp.epoch}`
: `loaded-${messages.length}`;
messages.push({
id,
message: messageText,
sender,
isVisible: true,
timestamp,
});
}
return messages;
}
private splitFrontmatter(content: string): {
frontmatter: Record<string, string>;
body: string;
} {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^(\w+):\s*(.+)/);
if (!m) continue;
const raw = m[2].trim();
// Unquote and unescape: only double-quoted values were escaped on save,
// so single-quoted / unquoted values are returned verbatim.
let value: string;
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
value = unescapeYamlString(raw.slice(1, -1));
} else if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
value = raw.slice(1, -1);
} else {
value = raw;
}
frontmatter[m[1]] = value;
}
return { frontmatter, body: content.slice(match[0].length).trim() };
}
private generateFileName(
messages: AgentChatMessage[],
firstMessageEpoch: number,
topic?: string
): string {
const settings = getSettings();
const formatted = formatDateTime(new Date(firstMessageEpoch));
const timestampFileName = formatted.fileName;
let topicForFilename: string;
if (topic) {
topicForFilename = topic;
} else {
const firstUser = messages.find((m) => m.sender === USER_SENDER);
topicForFilename = firstUser
? firstUser.message
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "")
.split(/\s+/)
.slice(0, 10)
.join(" ")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
.trim() || "Untitled Agent Chat"
: "Untitled Agent Chat";
}
let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
const filePrefix = AGENT_FILENAME_PREFIX;
const extensionBytes = getUtf8ByteLength(".md");
const filePrefixBytes = getUtf8ByteLength(filePrefix);
const formatOverhead = customFileName
.replace("{$topic}", "")
.replace("{$date}", timestampFileName.split("_")[0])
.replace("{$time}", timestampFileName.split("_")[1]);
const formatOverheadBytes = getUtf8ByteLength(formatOverhead);
const topicByteBudget = Math.max(
20,
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - formatOverheadBytes
);
const topicWithUnderscores = topicForFilename.replace(/\s+/g, "_");
const truncatedTopic = truncateToByteLimit(topicWithUnderscores, topicByteBudget);
customFileName = customFileName
.replace("{$topic}", truncatedTopic)
.replace("{$date}", timestampFileName.split("_")[0])
.replace("{$time}", timestampFileName.split("_")[1]);
const sanitizedFileName = customFileName
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "_")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
const baseNameWithPrefix = `${filePrefix}${sanitizedFileName}.md`;
if (getUtf8ByteLength(baseNameWithPrefix) > SAFE_FILENAME_BYTE_LIMIT) {
const availableForBasename = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
const truncatedBasename = truncateToByteLimit(sanitizedFileName, availableForBasename);
return `${settings.defaultSaveFolder}/${filePrefix}${truncatedBasename}.md`;
}
return `${settings.defaultSaveFolder}/${baseNameWithPrefix}`;
}
private generateNoteContent(args: {
chatContent: string;
firstMessageEpoch: number;
backendId: BackendId;
topic?: string;
label?: string | null;
modelKey?: string;
lastAccessedAt?: number;
}): string {
const settings = getSettings();
const lines: string[] = [
"---",
`epoch: ${args.firstMessageEpoch}`,
`mode: ${AGENT_CHAT_MODE}`,
`backendId: ${args.backendId}`,
];
if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`);
if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`);
if (args.modelKey) lines.push(`modelKey: "${escapeYamlString(args.modelKey)}"`);
if (args.lastAccessedAt) lines.push(`lastAccessedAt: ${args.lastAccessedAt}`);
lines.push("tags:");
lines.push(` - ${settings.defaultConversationTag}`);
lines.push("---");
lines.push("");
lines.push(args.chatContent);
return lines.join("\n");
}
}

View file

@ -0,0 +1,151 @@
import { logError, logWarn } from "@/logger";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import type {
AgentChatMessage,
BackendState,
CurrentPlan,
PlanDecisionAction,
} from "@/agentMode/session/types";
import type { MessageContext } from "@/types/message";
/**
* `AgentChatBackend` implementation backed by an `AgentSession`. The Agent
* Mode UI tree consumes this exclusively it knows nothing about the legacy
* `ChatUIState` / `ChatManager` stack.
*
* Edit, regenerate, and persistence operations are intentionally absent
* they don't have ACP semantics and Agent Mode chat persistence is deferred.
*/
export class AgentChatUIState implements AgentChatBackend {
private listeners = new Set<() => void>();
constructor(private readonly session: AgentSession) {
// Forward message, status, and model changes. The chat UI gates the
// send button on `isStarting()`, so it needs to re-render when status
// transitions out of `"starting"`.
this.session.subscribe({
onMessagesChanged: () => this.notifyListeners(),
onStatusChanged: () => this.notifyListeners(),
onModelChanged: () => this.notifyListeners(),
onCurrentPlanChanged: () => this.notifyListeners(),
});
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(): void {
for (const l of this.listeners) {
try {
l();
} catch (e) {
logWarn("[AgentChatUIState] listener threw", e);
}
}
}
/**
* Append a user message and kick off the ACP turn. Returns the new user
* message id synchronously plus a `turn` promise the caller can await for
* loading-state lifecycle (Stop button, input lock).
*/
sendMessage(
text: string,
context?: MessageContext,
content?: unknown[]
): { id: string; turn: Promise<void> } {
const { userMessageId, turn } = this.session.sendPrompt(text, context, content);
this.notifyListeners();
const wrapped = turn.then(
() => undefined,
(err) => {
logError("[AgentMode] turn failed", err);
}
);
return { id: userMessageId, turn: wrapped };
}
async cancel(): Promise<void> {
await this.session.cancel();
}
async deleteMessage(id: string): Promise<boolean> {
// Refuse delete during an in-flight turn: the placeholder assistant
// message is what streaming notifications target, and removing it would
// leave the session writing into a vanished id.
const status = this.session.getStatus();
if (status === "running" || status === "awaiting_permission") {
logWarn("[AgentChatUIState] delete refused while turn is in flight");
return false;
}
const ok = this.session.store.deleteMessage(id);
if (ok) this.notifyListeners();
return ok;
}
clearMessages(): void {
this.session.store.clear();
this.notifyListeners();
}
getMessages(): AgentChatMessage[] {
return this.session.store.getDisplayMessages();
}
isStarting(): boolean {
return this.session.getStatus() === "starting";
}
getBackendState(): BackendState | null {
return this.session.getState();
}
canSwitchModel(): boolean | null {
return this.session.canSwitchModel();
}
canSwitchEffort(): boolean | null {
return this.session.canSwitchEffort();
}
canSwitchMode(): boolean | null {
return this.session.canSwitchMode();
}
hasPendingPlanPermission(): boolean {
return this.session.hasPendingPlanPermission();
}
getCurrentPlan(): CurrentPlan | null {
return this.session.getCurrentPlan();
}
async resolvePlanProposal(
proposalId: string,
decision: PlanDecisionAction,
feedbackText?: string
): Promise<void> {
const plan = this.session.getCurrentPlan();
if (!plan || plan.id !== proposalId || plan.decision !== "pending") return;
if (!plan.permissionGated || !plan.pendingToolCallId) {
logWarn("[AgentChatUIState] non-gated plan card has no resolution path");
return;
}
const trimmedFeedback = decision === "feedback" ? feedbackText?.trim() : undefined;
// Resolve the underlying ACP permission. Approve unblocks the agent
// and continues the same turn; reject denies with `"User declined"`;
// feedback rides the typed text through the same deny `message` so
// the agent revises in-turn instead of receiving a separate
// follow-up prompt.
this.session.resolvePlanProposalPermission(
plan.pendingToolCallId,
decision === "approve",
trimmedFeedback
);
this.session.finalizePlanDecision(plan.id);
this.notifyListeners();
}
}

View file

@ -0,0 +1,223 @@
import { AI_SENDER } from "@/constants";
import { AgentMessagePart } from "@/agentMode/session/types";
import { formatDateTime } from "@/utils";
import { AgentMessageStore } from "./AgentMessageStore";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
describe("AgentMessageStore", () => {
const placeholder = () => ({
message: "",
sender: AI_SENDER,
timestamp: formatDateTime(new Date()),
isVisible: true as const,
parts: [] as AgentMessagePart[],
});
it("appendDisplayText accumulates streaming chunks", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.appendDisplayText(id, "Hello, ");
store.appendDisplayText(id, "world.");
expect(store.getMessage(id)?.message).toBe("Hello, world.");
});
it("appendDisplayText returns false for unknown message", () => {
const store = new AgentMessageStore();
expect(store.appendDisplayText("missing", "x")).toBe(false);
});
it("appendAgentText folds successive chunks into one trailing text part", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.appendAgentText(id, "Hello, ");
store.appendAgentText(id, "world.");
const msg = store.getMessage(id);
const parts = msg?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({ kind: "text", text: "Hello, world." });
// Flat body stays in sync for persistence / search / error append.
expect(msg?.message).toBe("Hello, world.");
});
it("appendAgentText starts a new text part when interrupted by a tool call", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.appendAgentText(id, "before");
store.upsertAgentPart(id, {
kind: "tool_call",
id: "tc1",
title: "Read README",
status: "completed",
});
store.appendAgentText(id, "after");
const parts = store.getMessage(id)?.parts ?? [];
expect(parts.map((p) => p.kind)).toEqual(["text", "tool_call", "text"]);
expect(parts[0]).toEqual({ kind: "text", text: "before" });
expect(parts[2]).toEqual({ kind: "text", text: "after" });
});
it("appendAgentText returns false for unknown message", () => {
const store = new AgentMessageStore();
expect(store.appendAgentText("missing", "x")).toBe(false);
});
it("appendAgentThought folds successive chunks into one part", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.appendAgentThought(id, "Thinking");
store.appendAgentThought(id, " harder");
const parts = store.getMessage(id)?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({ kind: "thought", text: "Thinking harder" });
});
it("upsertAgentPart appends new tool_call by toolCallId", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.upsertAgentPart(id, {
kind: "tool_call",
id: "tc1",
title: "Read README",
status: "pending",
});
const parts = store.getMessage(id)?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ kind: "tool_call", id: "tc1", title: "Read README" });
});
it("upsertAgentPart replaces existing tool_call when ids match", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.upsertAgentPart(id, {
kind: "tool_call",
id: "tc1",
title: "Read README",
status: "pending",
});
store.upsertAgentPart(id, {
kind: "tool_call",
id: "tc1",
title: "Read README",
status: "completed",
output: [{ type: "text", text: "ok" }],
});
const parts = store.getMessage(id)?.parts ?? [];
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({
kind: "tool_call",
id: "tc1",
status: "completed",
output: [{ type: "text", text: "ok" }],
});
});
it("upsertAgentPart returns false when re-applying an identical snapshot", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
const part: AgentMessagePart = {
kind: "tool_call",
id: "tc1",
title: "Read README",
status: "pending",
};
expect(store.upsertAgentPart(id, part)).toBe(true);
expect(store.upsertAgentPart(id, { ...part })).toBe(false);
});
it("upsertAgentPart compares large repeated tool outputs without duplicating", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
const part: AgentMessagePart = {
kind: "tool_call",
id: "tc1",
title: "Search",
status: "completed",
input: { query: "communication drill", nested: { text: "x".repeat(20_000) } },
output: [
{
type: "text",
text:
"a".repeat(12_000) +
"\n\n[Tool output truncated in Copilot UI: 8,000 characters omitted.]",
truncated: true,
originalLength: 20_000,
omittedLength: 8_000,
},
],
};
expect(store.upsertAgentPart(id, part)).toBe(true);
expect(
store.upsertAgentPart(id, { ...part, output: part.output?.map((o) => ({ ...o })) })
).toBe(false);
expect(store.getMessage(id)?.parts).toHaveLength(1);
});
it("upsertAgentPart treats plan as singleton (replace, not duplicate)", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.upsertAgentPart(id, {
kind: "plan",
entries: [{ content: "step 1", priority: "high", status: "pending" }],
});
store.upsertAgentPart(id, {
kind: "plan",
entries: [
{ content: "step 1", priority: "high", status: "completed" },
{ content: "step 2", priority: "medium", status: "pending" },
],
});
const parts = store.getMessage(id)?.parts ?? [];
const planParts = parts.filter((p) => p.kind === "plan");
expect(planParts).toHaveLength(1);
expect(planParts[0]).toMatchObject({
kind: "plan",
entries: expect.arrayContaining([
expect.objectContaining({ content: "step 1", status: "completed" }),
expect.objectContaining({ content: "step 2" }),
]),
});
});
it("getDisplayMessages includes parts", () => {
const store = new AgentMessageStore();
const id = store.addMessage(placeholder());
store.upsertAgentPart(id, {
kind: "tool_call",
id: "tc1",
title: "x",
status: "pending",
});
const msg = store.getDisplayMessages().find((m) => m.id === id);
expect(msg?.parts).toHaveLength(1);
});
it("markMessageError flags the message and appends formatted error text", () => {
const store = new AgentMessageStore();
const id = store.addMessage({
message: "partial reply",
sender: AI_SENDER,
timestamp: formatDateTime(new Date()),
isVisible: true,
});
store.markMessageError(id, "boom");
const msg = store.getMessage(id);
expect(msg?.isErrorMessage).toBe(true);
expect(msg?.message).toContain("partial reply");
expect(msg?.message).toContain("**Error:** boom");
});
it("truncateAfterMessageId drops everything after the target", () => {
const store = new AgentMessageStore();
const a = store.addMessage(placeholder());
store.addMessage(placeholder());
store.addMessage(placeholder());
store.truncateAfterMessageId(a);
expect(store.getDisplayMessages()).toHaveLength(1);
});
});

View file

@ -0,0 +1,394 @@
import { logInfo } from "@/logger";
import {
AgentChatMessage,
AgentMessagePart,
AgentToolCallOutput,
NewAgentChatMessage,
StopReason,
} from "@/agentMode/session/types";
import { FormattedDateTime, MessageContext } from "@/types/message";
import { formatDateTime } from "@/utils";
/**
* Internal storage shape for one Agent Mode message. Mirrors `AgentChatMessage`
* but uses `displayText` internally to keep the streaming append APIs explicit
* and to leave room for future fields without changing the public type.
*/
interface StoredAgentMessage {
id: string;
displayText: string;
sender: string;
timestamp: FormattedDateTime;
isVisible: boolean;
isErrorMessage?: boolean;
parts?: AgentMessagePart[];
context?: MessageContext;
content?: unknown[];
turnStopReason?: StopReason;
turnDurationMs?: number;
}
const MAX_COMPARE_JSON_CHARS = 8_000;
const MAX_COMPARE_EDGE_CHARS = 512;
const MAX_COMPARE_TEXT_EDGE_CHARS = 128;
/**
* Stable identity for an agent part. Tool calls key on `tool:<toolCallId>` so
* a `tool_call_update` notification can find and replace the right entry.
* `plan` parts are singletons per message so they key on the literal `"plan"`.
* Thoughts have no key they're folded by `appendAgentThought`.
*/
function agentPartId(part: AgentMessagePart): string | undefined {
if (part.kind === "tool_call") return `tool:${part.id}`;
if (part.kind === "plan") return "plan";
return undefined;
}
/**
* Structural equality for agent parts. Lets `upsertAgentPart` skip a
* notify/re-render when the ACP transport resends a tool-call snapshot whose
* fields haven't actually changed.
*/
function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean {
if (a === b) return true;
if (a.kind !== b.kind) return false;
switch (a.kind) {
case "text":
if (b.kind !== "text") return false;
return a.text === b.text;
case "thought":
if (b.kind !== "thought") return false;
return a.text === b.text;
case "plan":
if (b.kind !== "plan") return false;
return planEntriesEqual(a.entries, b.entries);
case "tool_call":
if (b.kind !== "tool_call") return false;
return (
a.id === b.id &&
a.title === b.title &&
a.toolKind === b.toolKind &&
a.status === b.status &&
a.vendorToolName === b.vendorToolName &&
a.parentToolCallId === b.parentToolCallId &&
boundedValueEqual(a.input, b.input) &&
locationsEqual(a.locations, b.locations) &&
toolOutputsEqual(a.output, b.output)
);
}
}
/** Compare plan entries without stringifying the whole part object. */
function planEntriesEqual(
a: Extract<AgentMessagePart, { kind: "plan" }>["entries"],
b: Extract<AgentMessagePart, { kind: "plan" }>["entries"]
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every(
(entry, index) =>
entry.content === b[index].content &&
entry.priority === b[index].priority &&
entry.status === b[index].status
);
}
/** Compare tool locations by their scalar fields. */
function locationsEqual(
a: Extract<AgentMessagePart, { kind: "tool_call" }>["locations"],
b: Extract<AgentMessagePart, { kind: "tool_call" }>["locations"]
): boolean {
if (a === b) return true;
if (!a || !b) return a === b;
if (a.length !== b.length) return false;
return a.every((loc, index) => loc.path === b[index].path && loc.line === b[index].line);
}
/** Compare rendered tool outputs with bounded string work. */
function toolOutputsEqual(
a: AgentToolCallOutput[] | undefined,
b: AgentToolCallOutput[] | undefined
): boolean {
if (a === b) return true;
if (!a || !b) return a === b;
if (a.length !== b.length) return false;
return a.every((output, index) => {
const other = b[index];
if (output.type !== other.type) return false;
if (output.type === "diff" && other.type === "diff") {
return (
output.path === other.path &&
output.oldText === other.oldText &&
output.newText === other.newText
);
}
if (output.type === "text" && other.type === "text") {
return (
output.truncated === other.truncated &&
output.originalLength === other.originalLength &&
output.omittedLength === other.omittedLength &&
textFingerprint(output.text) === textFingerprint(other.text)
);
}
return false;
});
}
/** Compare arbitrary tool input with bounded stringify work. */
function boundedValueEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
return valueFingerprint(a) === valueFingerprint(b);
}
/** Create a stable, bounded comparison key for arbitrary JSON-like values. */
function valueFingerprint(value: unknown): string {
let json: string;
try {
json = JSON.stringify(value);
} catch {
json = String(value);
}
if (json.length <= MAX_COMPARE_JSON_CHARS) return json;
return `${json.length}:${json.slice(0, MAX_COMPARE_EDGE_CHARS)}:${json.slice(
-MAX_COMPARE_EDGE_CHARS
)}`;
}
/** Create a bounded comparison key for long text outputs. */
function textFingerprint(text: string): string {
if (text.length <= MAX_COMPARE_TEXT_EDGE_CHARS * 2) return text;
return `${text.length}:${text.slice(0, MAX_COMPARE_TEXT_EDGE_CHARS)}:${text.slice(
-MAX_COMPARE_TEXT_EDGE_CHARS
)}`;
}
/**
* Single source of truth for one Agent Mode chat session. The UI subscribes
* via the surrounding `AgentSession`, the session writes streamed updates
* here, and computed views feed React.
*
* Distinct from the legacy `MessageRepository` because Agent Mode messages
* have structured `parts` (tool calls, thoughts, plans) and Agent Mode has no
* concept of `processedText` / `contextEnvelope` ACP owns the model's view
* of the conversation.
*/
export class AgentMessageStore {
private messages: StoredAgentMessage[] = [];
private generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
/** Add a message; returns its assigned id. */
addMessage(message: NewAgentChatMessage): string {
const id = message.id || this.generateId();
const timestamp = message.timestamp || formatDateTime(new Date());
this.messages.push({
id,
displayText: message.message,
sender: message.sender,
timestamp,
context: message.context,
isVisible: message.isVisible !== false,
isErrorMessage: message.isErrorMessage,
content: message.content,
parts: message.parts,
turnStopReason: message.turnStopReason,
turnDurationMs: message.turnDurationMs,
});
return id;
}
/**
* Stamp a finished turn's `stopReason` and frozen `durationMs` onto its
* placeholder assistant message. Returns false if the message is missing or
* already marked complete the latter lets callers skip notifying.
*/
markTurnComplete(id: string, stopReason: StopReason, durationMs: number): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
if (msg.turnStopReason !== undefined) return false;
msg.turnStopReason = stopReason;
msg.turnDurationMs = durationMs;
return true;
}
/**
* Append text to a message body. Used to stream `agent_message_chunk`
* updates into the placeholder assistant message. Returns false if the
* target message is missing (the session was likely reset mid-turn).
*/
appendDisplayText(id: string, chunk: string): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
msg.displayText += chunk;
return true;
}
/**
* Append assistant prose to the trailing `text` part, creating one if the
* last part is a different kind. Mirrors `appendAgentThought` so streamed
* `agent_message_chunk`s interleave chronologically with tool calls and
* thoughts inside `parts[]`. Also keeps `displayText` in sync so callers
* that read the flattened body (persistence, search, error append) stay
* correct.
*/
appendAgentText(id: string, text: string): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
msg.displayText += text;
if (!msg.parts) msg.parts = [];
const last = msg.parts[msg.parts.length - 1];
if (last && last.kind === "text") {
last.text += text;
} else {
msg.parts.push({ kind: "text", text });
}
return true;
}
/**
* Append text to the trailing `thought` part, creating one if absent. Folds
* multiple `agent_thought_chunk` updates into a single collapsible block
* instead of one block per chunk.
*/
appendAgentThought(id: string, text: string): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
if (!msg.parts) msg.parts = [];
const last = msg.parts[msg.parts.length - 1];
if (last && last.kind === "thought") {
last.text += text;
} else {
msg.parts.push({ kind: "thought", text });
}
return true;
}
/**
* Replace an agent part by its stable identity (see `agentPartId`). Appends
* if no existing part matches. Returns false when the message is missing OR
* when the new part is structurally identical to the existing one callers
* use this to skip redundant React notifications.
*/
upsertAgentPart(id: string, part: AgentMessagePart): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
if (!msg.parts) msg.parts = [];
const partId = agentPartId(part);
if (partId !== undefined) {
const idx = msg.parts.findIndex((p) => agentPartId(p) === partId);
if (idx !== -1) {
if (partsEqual(msg.parts[idx], part)) return false;
msg.parts[idx] = part;
return true;
}
}
msg.parts.push(part);
return true;
}
/**
* Mark a message as an error and append the error text to its display body.
* Used when a turn rejects mid-stream so the partial placeholder gets a
* visible error instead of looking like a normal truncated reply.
*/
markMessageError(id: string, errorText: string): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
msg.isErrorMessage = true;
const suffix = msg.displayText.length > 0 ? "\n\n" : "";
msg.displayText += `${suffix}**Error:** ${errorText}`;
return true;
}
/**
* Whether an assistant placeholder has emitted any user-visible activity.
* Used to distinguish a legitimate completed turn from a blank backend
* response that would otherwise render as an empty assistant block.
*/
hasAssistantActivity(id: string): boolean {
const msg = this.messages.find((m) => m.id === id);
if (!msg) return false;
if (msg.displayText.trim().length > 0) return true;
return (msg.parts ?? []).some((part) => {
if (part.kind === "text" || part.kind === "thought") {
return part.text.trim().length > 0;
}
return true;
});
}
deleteMessage(id: string): boolean {
const idx = this.messages.findIndex((m) => m.id === id);
if (idx === -1) return false;
this.messages.splice(idx, 1);
return true;
}
clear(): void {
this.messages = [];
}
truncateAfterMessageId(messageId: string): void {
const idx = this.messages.findIndex((m) => m.id === messageId);
if (idx !== -1) {
this.messages = this.messages.slice(0, idx + 1);
}
}
/** Visible messages, shaped for the UI. */
getDisplayMessages(): AgentChatMessage[] {
return this.messages.filter((m) => m.isVisible).map((m) => this.toAgentChatMessage(m));
}
getMessage(id: string): AgentChatMessage | undefined {
const msg = this.messages.find((m) => m.id === id);
return msg ? this.toAgentChatMessage(msg) : undefined;
}
loadMessages(messages: AgentChatMessage[]): void {
this.clear();
for (const msg of messages) {
this.messages.push({
id: msg.id || this.generateId(),
displayText: msg.message,
sender: msg.sender,
timestamp: msg.timestamp || formatDateTime(new Date()),
context: msg.context,
isVisible: msg.isVisible !== false,
isErrorMessage: msg.isErrorMessage,
content: msg.content,
parts: msg.parts,
turnStopReason: msg.turnStopReason,
turnDurationMs: msg.turnDurationMs,
});
}
logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`);
}
getDebugInfo() {
return {
totalMessages: this.messages.length,
visibleMessages: this.messages.filter((m) => m.isVisible).length,
};
}
private toAgentChatMessage(m: StoredAgentMessage): AgentChatMessage {
return {
id: m.id,
message: m.displayText,
sender: m.sender,
timestamp: m.timestamp,
isVisible: m.isVisible,
context: m.context,
isErrorMessage: m.isErrorMessage,
content: m.content,
parts: m.parts,
turnStopReason: m.turnStopReason,
turnDurationMs: m.turnDurationMs,
};
}
}

View file

@ -0,0 +1,194 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { getSettings } from "@/settings/model";
import { App, FileSystemAdapter, Platform } from "obsidian";
import { MethodUnsupportedError } from "./errors";
import { backendStateSignature } from "./translateBackendState";
import type { BackendDescriptor, BackendId, BackendProcess, BackendState } from "./types";
/**
* Plugin-lifetime cache of per-backend session state. Backends expose
* `BackendState` only as a side-effect of session creation / resume / load,
* so without this preload the picker would show no entries for non-active
* backends and would blink empty during the round-trip on a fresh session.
*
* 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, BackendState>();
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
) {}
getCachedBackendState(backendId: BackendId): BackendState | null {
return this.cache.get(backendId) ?? null;
}
/**
* Replace the cached entry for `backendId`. No-op when the signature
* is unchanged, to avoid spurious picker rebuilds.
*/
setCached(backendId: BackendId, state: BackendState): void {
if (this.disposed) return;
const prev = this.cache.get(backendId) ?? null;
if (backendStateSignature(prev) === backendStateSignature(state)) return;
this.cache.set(backendId, state);
this.notify();
}
/** Remove the cached entry for `backendId` after its backend is restarted. */
clearCached(backendId: BackendId): void {
if (this.disposed) return;
if (!this.cache.delete(backendId)) return;
this.notify();
}
/** 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 = descriptor.createBackendProcess({
plugin: this.plugin,
app: this.app,
clientVersion: this.plugin.manifest.version,
descriptor,
});
try {
await proc.start?.();
const storedId = descriptor.getProbeSessionId?.(getSettings());
const state = await this.fetchInitialState(proc, descriptor, backendId, storedId, cwd);
if (this.disposed) return;
if (state.model || state.mode) {
this.setCached(backendId, state);
logProbeResult(backendId, "session probe", state);
} else {
logInfo(`[AgentMode] preload ${backendId}: agent did not report any initial state`);
}
} catch (err) {
logError(`[AgentMode] preload ${backendId} failed`, err);
} finally {
try {
await proc.shutdown();
} catch (e) {
logWarn(`[AgentMode] preload ${backendId}: shutdown failed`, e);
}
}
}
private async fetchInitialState(
proc: BackendProcess,
descriptor: BackendDescriptor,
backendId: BackendId,
storedId: string | undefined,
cwd: string
): Promise<BackendState> {
type Strategy = {
label: string;
sessionId: string;
run: () => Promise<{ sessionId: string; state: BackendState }>;
};
const strategies: Strategy[] = [];
if (storedId) {
strategies.push({
label: `resumed probe session ${storedId}`,
sessionId: storedId,
run: () => proc.resumeSession({ sessionId: storedId, cwd, mcpServers: [] }),
});
strategies.push({
label: `loaded probe session ${storedId}`,
sessionId: storedId,
run: () => proc.loadSession({ sessionId: storedId, cwd, mcpServers: [] }),
});
}
for (const { label, sessionId, run } of strategies) {
try {
proc.registerSessionHandler(sessionId, () => {});
const resp = await run();
logInfo(`[AgentMode] preload ${backendId}: ${label}`);
return resp.state;
} catch (err) {
if (!(err instanceof MethodUnsupportedError)) {
logWarn(`[AgentMode] preload ${backendId}: ${label} failed (will fall back)`, err);
}
}
}
const resp = await proc.newSession({ cwd, mcpServers: [] });
proc.registerSessionHandler(resp.sessionId, () => {});
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.state;
}
}
function logProbeResult(backendId: BackendId, label: string, state: BackendState): void {
const ids = state.model?.availableModels.map((m) => m.baseModelId).join(", ") ?? "";
const modeOpts = state.mode?.options.map((o) => o.value).join(", ") ?? "";
const currentBaseId = state.model?.current.baseModelId ?? "-";
const currentEntry = state.model?.availableModels.find((e) => e.baseModelId === currentBaseId);
const effortOpts = currentEntry?.effortOptions.map((o) => o.value ?? "default").join(", ") ?? "";
logInfo(
`[AgentMode] preload ${backendId} (${label}): models=[${ids}] (current=${currentBaseId}), ` +
`mode=[${modeOpts}] effort=[${effortOpts}]`
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,815 @@
/**
* Pool-semantics tests for AgentSessionManager. The shared backend
* subprocess and the AgentSession factory are mocked so we can exercise
* session-pool invariants without touching ACP or spawning a child process.
*/
import { FileSystemAdapter, App } from "obsidian";
import { AgentSession } from "./AgentSession";
import { AgentSessionManager } from "./AgentSessionManager";
import { setSettings as mockedSetSettings } from "@/settings/model";
import type { BackendDescriptor } from "./types";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
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);
const mockBackendExitListeners = new Set<() => void>();
const mockSetPermissionPrompter = jest.fn();
function makeMockBackendProcess() {
return {
start: mockBackendStart,
setPermissionPrompter: mockSetPermissionPrompter,
onExit: (fn: () => void) => {
mockBackendExitListeners.add(fn);
return () => mockBackendExitListeners.delete(fn);
},
isRunning: () => mockBackendIsRunning,
shutdown: mockBackendShutdown,
};
}
const mockSessionDispose = jest.fn(async () => undefined);
const mockSessionCancel = jest.fn(async () => undefined);
let nextBackendSessionId = 1;
interface MockSessionTestHandle {
/** Drive the mock session's status the way the real session does. */
setStatus(
status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed"
): void;
}
const sessionTestHandles = new Map<string, MockSessionTestHandle>();
function getSessionTestHandle(session: AgentSession): MockSessionTestHandle {
const handle = sessionTestHandles.get(session.internalId);
if (!handle) throw new Error(`No test handle for ${session.internalId}`);
return handle;
}
function makeMockSession(overrides: {
internalId: string;
backendSessionId?: string;
backendId: string;
ready?: Promise<void>;
}): AgentSession {
const sessionId = overrides.backendSessionId ?? `backend-${nextBackendSessionId++}`;
let status: "starting" | "idle" | "running" | "awaiting_permission" | "error" | "closed" = "idle";
let needsAttention = false;
const listeners = new Set<{
onStatusChanged?: (s: typeof status) => void;
onNeedsAttentionChanged?: (v: boolean) => void;
}>();
const session = {
internalId: overrides.internalId,
backendId: overrides.backendId,
ready: overrides.ready ?? Promise.resolve(),
getBackendSessionId: () => sessionId,
getStatus: () => status,
cancel: mockSessionCancel,
dispose: mockSessionDispose,
setModel: jest.fn(),
setMode: jest.fn(),
setConfigOption: jest.fn(),
getLabel: () => null,
setLabel: jest.fn(),
subscribe: (l: Parameters<typeof listeners.add>[0]) => {
listeners.add(l);
return () => listeners.delete(l);
},
hasUserVisibleMessages: () => false,
getState: () => null,
getRawSnapshot: () => ({ models: null, modes: null, configOptions: null }),
getNeedsAttention: () => needsAttention,
markNeedsAttention: () => {
if (needsAttention) return;
needsAttention = true;
for (const l of listeners) l.onNeedsAttentionChanged?.(true);
},
clearNeedsAttention: () => {
if (!needsAttention) return;
needsAttention = false;
for (const l of listeners) l.onNeedsAttentionChanged?.(false);
},
} as unknown as AgentSession;
sessionTestHandles.set(overrides.internalId, {
setStatus: (next) => {
if (status === next) return;
status = next;
for (const l of listeners) l.onStatusChanged?.(next);
},
});
return session;
}
const sessionCreateSpy = jest
.spyOn(AgentSession, "start")
.mockImplementation((opts) =>
makeMockSession({ internalId: opts.internalId, backendId: opts.backendId })
);
function buildApp(basePath = "/vault"): App {
const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)(basePath);
return { vault: { adapter } } as unknown as App;
}
function buildPlugin(): { manifest: { version: string } } {
return { manifest: { version: "1.0.0" } };
}
function buildDescriptor(): BackendDescriptor {
return {
id: "opencode",
displayName: "opencode",
getInstallState: jest.fn(),
subscribeInstallState: jest.fn(),
openInstallUI: jest.fn(),
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
} as unknown as BackendDescriptor;
}
function buildManager(): AgentSessionManager {
const descriptor = buildDescriptor();
const modelPreloader = {
getCachedBackendState: jest.fn(() => null),
preload: jest.fn(async () => undefined),
subscribe: jest.fn(() => () => {}),
shutdown: jest.fn(),
setCached: jest.fn(),
clearCached: jest.fn(),
};
return new AgentSessionManager(
buildApp(),
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
{
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
modelPreloader: modelPreloader as unknown as ConstructorParameters<
typeof AgentSessionManager
>[2]["modelPreloader"],
}
);
}
beforeEach(() => {
mockBackendIsRunning = true;
mockBackendStart.mockClear();
mockBackendShutdown.mockClear();
mockSetPermissionPrompter.mockClear();
mockBackendExitListeners.clear();
mockSessionCancel.mockClear();
mockSessionDispose.mockClear();
sessionCreateSpy.mockClear();
nextBackendSessionId = 1;
});
describe("AgentSessionManager.createSession", () => {
it("creates a session and sets it as the active one", async () => {
const mgr = buildManager();
const session = await mgr.createSession();
expect(mgr.getSessions()).toEqual([session]);
expect(mgr.getActiveSession()).toBe(session);
expect(mgr.getActiveChatUIState()).not.toBeNull();
expect(mgr.getChatUIState(session.internalId)).toBe(mgr.getActiveChatUIState());
});
it("creating a second session sets it as active but keeps the first in the pool", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
expect(mgr.getSessions()).toEqual([a, b]);
expect(mgr.getActiveSession()).toBe(b);
});
it("two concurrent createSession calls each spawn their own session", async () => {
const mgr = buildManager();
const [a, b] = await Promise.all([mgr.createSession(), mgr.createSession()]);
expect(a).not.toBe(b);
expect(sessionCreateSpy).toHaveBeenCalledTimes(2);
expect(mgr.getSessions()).toHaveLength(2);
});
it("only spawns the backend once across multiple createSession calls", async () => {
const mgr = buildManager();
await mgr.createSession();
await mgr.createSession();
await mgr.createSession();
expect(mockBackendStart).toHaveBeenCalledTimes(1);
});
it("mirrors the new session's unified state into the preloader cache", async () => {
const cache = new Map<string, unknown>();
const modelPreloader = {
getCachedBackendState: jest.fn((id: string) => cache.get(id) ?? null),
preload: jest.fn(async () => undefined),
subscribe: jest.fn(() => () => {}),
shutdown: jest.fn(),
setCached: jest.fn((id: string, state: unknown) => {
cache.set(id, state);
}),
clearCached: jest.fn((id: string) => {
cache.delete(id);
}),
};
const descriptor = buildDescriptor();
const mgr = new AgentSessionManager(
buildApp(),
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
{
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
modelPreloader: modelPreloader as unknown as ConstructorParameters<
typeof AgentSessionManager
>[2]["modelPreloader"],
}
);
const modelEntry = {
baseModelId: "anthropic/sonnet",
name: "Claude Sonnet",
provider: "anthropic",
effortOptions: [],
};
const unified = {
model: { current: { model: modelEntry, effort: null }, availableModels: [modelEntry] },
mode: null,
};
sessionCreateSpy.mockImplementationOnce((opts) => {
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
(s as unknown as { getState: () => unknown }).getState = () => unified;
return s;
});
await mgr.createSession();
expect(modelPreloader.setCached).toHaveBeenCalledWith("opencode", unified);
expect(mgr.getCachedBackendState("opencode")).toBe(unified);
// Spawning a second session before its session/new resolves must not
// overwrite the cached state with nulls.
sessionCreateSpy.mockImplementationOnce((opts) =>
makeMockSession({ internalId: opts.internalId, backendId: opts.backendId })
);
await mgr.createSession();
expect(mgr.getCachedBackendState("opencode")).toBe(unified);
});
it("a concurrent create that succeeds does not wipe a sibling create's lastError", async () => {
const mgr = buildManager();
// First call fails. Second call starts before first settles, so the
// pre-fix code would have cleared `lastError` at the second call's start
// and the failure surfaced by the first would be lost.
sessionCreateSpy
.mockImplementationOnce((opts) =>
makeMockSession({
internalId: opts.internalId,
backendId: opts.backendId,
// Failing session: ready rejects after a microtask. The second
// create's ready resolves immediately; with concurrent flushing,
// we still want the first failure to win in lastError.
ready: (async () => {
await Promise.resolve();
await Promise.resolve();
throw new Error("boom");
})(),
})
)
.mockImplementationOnce((opts) =>
makeMockSession({
internalId: opts.internalId,
backendSessionId: "backend-ok",
backendId: opts.backendId,
})
);
const failingSession = await mgr.createSession();
const succeedingSession = await mgr.createSession();
// Drain the ready continuations so lastError is populated.
await failingSession.ready.catch(() => undefined);
await succeedingSession.ready;
// Allow the manager's `.finally` continuation to run.
await Promise.resolve();
await Promise.resolve();
expect(mgr.getLastError()).toMatch(/boom/);
});
});
describe("AgentSessionManager.getOrCreateActiveSession", () => {
it("dedupes concurrent auto-spawn callers into a single session", async () => {
const mgr = buildManager();
const [a, b] = await Promise.all([
mgr.getOrCreateActiveSession(),
mgr.getOrCreateActiveSession(),
]);
expect(a).toBe(b);
expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
expect(mgr.getSessions()).toHaveLength(1);
});
it("returns the existing active session on subsequent calls", async () => {
const mgr = buildManager();
const a = await mgr.getOrCreateActiveSession();
const again = await mgr.getOrCreateActiveSession();
expect(again).toBe(a);
expect(sessionCreateSpy).toHaveBeenCalledTimes(1);
});
});
describe("AgentSessionManager.closeSession", () => {
it("removes the session from the pool and cancels + disposes it", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
await mgr.closeSession(a.internalId);
expect(mgr.getSessions()).toEqual([]);
expect(mgr.getActiveSession()).toBeNull();
expect(mockSessionCancel).toHaveBeenCalled();
expect(mockSessionDispose).toHaveBeenCalled();
});
it("when the active session is closed, picks the right neighbor as active", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
const c = await mgr.createSession();
mgr.setActiveSession(b.internalId);
await mgr.closeSession(b.internalId);
// [a, b, c] -> close b (idx 1) -> remaining [a, c] -> idx 1 -> c
expect(mgr.getActiveSession()).toBe(c);
expect(mgr.getSessions()).toEqual([a, c]);
});
it("when the rightmost active session is closed, falls back to the new last", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
expect(mgr.getActiveSession()).toBe(b);
await mgr.closeSession(b.internalId);
// [a, b] -> close b (idx 1) -> remaining [a] -> idx min(1, 0) = 0 -> a
expect(mgr.getActiveSession()).toBe(a);
});
it("closing a non-active session leaves the active pointer alone", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
await mgr.closeSession(a.internalId);
expect(mgr.getActiveSession()).toBe(b);
expect(mgr.getSessions()).toEqual([b]);
});
it("is a no-op for unknown ids", async () => {
const mgr = buildManager();
await mgr.closeSession("does-not-exist");
expect(mgr.getSessions()).toEqual([]);
});
});
describe("AgentSessionManager.setActiveSession", () => {
it("moves the active pointer to the given id", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
expect(mgr.getActiveSession()).toBe(b);
mgr.setActiveSession(a.internalId);
expect(mgr.getActiveSession()).toBe(a);
});
it("is a silent no-op on unknown id", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
expect(() => mgr.setActiveSession("nope")).not.toThrow();
expect(mgr.getActiveSession()).toBe(a);
});
});
describe("AgentSessionManager.restartBackend", () => {
it("returns false when the backend has not been started", async () => {
const mgr = buildManager();
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(false);
expect(mockBackendShutdown).not.toHaveBeenCalled();
});
it("restarts an idle backend and replaces the active affected session", async () => {
const mgr = buildManager();
const first = await mgr.createSession();
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true);
expect(mockSessionCancel).toHaveBeenCalledWith();
expect(mockSessionDispose).toHaveBeenCalledWith();
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
expect(mgr.getSessions()).toHaveLength(1);
expect(mgr.getActiveSession()).not.toBe(first);
expect(mgr.getActiveSession()?.backendId).toBe("opencode");
});
it("defers restart until an active turn leaves running", async () => {
const mgr = buildManager();
const first = await mgr.createSession();
getSessionTestHandle(first).setStatus("running");
await expect(mgr.restartBackend("opencode", "skills changed")).resolves.toBe(true);
expect(mockBackendShutdown).not.toHaveBeenCalled();
getSessionTestHandle(first).setStatus("idle");
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
expect(mgr.getActiveSession()).not.toBe(first);
expect(mgr.getActiveSession()?.backendId).toBe("opencode");
});
});
describe("AgentSessionManager attention tracking", () => {
it("flags a backgrounded session that finishes a turn", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
// b is active by default; switch to a so b runs in the background.
mgr.setActiveSession(a.internalId);
const bHandle = getSessionTestHandle(b);
bHandle.setStatus("running");
bHandle.setStatus("idle");
expect(b.getNeedsAttention()).toBe(true);
expect(a.getNeedsAttention()).toBe(false);
});
it("flags a backgrounded session that errors out", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const bHandle = getSessionTestHandle(b);
bHandle.setStatus("running");
bHandle.setStatus("error");
expect(b.getNeedsAttention()).toBe(true);
});
it("flags a backgrounded session that pauses for permission", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const bHandle = getSessionTestHandle(b);
bHandle.setStatus("running");
bHandle.setStatus("awaiting_permission");
expect(b.getNeedsAttention()).toBe(true);
});
it("does not flag the active session", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
expect(mgr.getActiveSession()).toBe(a);
const aHandle = getSessionTestHandle(a);
aHandle.setStatus("running");
aHandle.setStatus("idle");
expect(a.getNeedsAttention()).toBe(false);
});
it("does not flag the starting → idle transition", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const bHandle = getSessionTestHandle(b);
// simulate a fresh boot (mock starts at idle, force a starting → idle).
bHandle.setStatus("starting");
bHandle.setStatus("idle");
expect(b.getNeedsAttention()).toBe(false);
});
it("clears the flag when the user activates the tab", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const bHandle = getSessionTestHandle(b);
bHandle.setStatus("running");
bHandle.setStatus("idle");
expect(b.getNeedsAttention()).toBe(true);
mgr.setActiveSession(b.internalId);
expect(b.getNeedsAttention()).toBe(false);
});
});
describe("AgentSessionManager.replaceSessionInPlace", () => {
// Drains the fire-and-forget `closeSession` chain that
// replaceSessionInPlace kicks off, so assertions about pool removal
// and dispose can run synchronously after.
async function flushBackgroundClose(): Promise<void> {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
it("inserts the replacement at the old session's tab-strip index", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
const c = await mgr.createSession();
// Replace the middle tab — the regression case is "new chat hijacks
// a sibling slot" because the new session is appended at the end.
const replacement = await mgr.replaceSessionInPlace(b.internalId);
await flushBackgroundClose();
expect(mgr.getSessions()).toEqual([a, replacement, c]);
expect(mgr.getActiveSession()).toBe(replacement);
});
it("preserves the leftmost slot when replacing the first tab", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const replacement = await mgr.replaceSessionInPlace(a.internalId);
await flushBackgroundClose();
expect(mgr.getSessions()).toEqual([replacement, b]);
expect(mgr.getActiveSession()).toBe(replacement);
});
it("closes the old session in the background", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
await mgr.replaceSessionInPlace(a.internalId);
await flushBackgroundClose();
expect(mockSessionCancel).toHaveBeenCalled();
expect(mockSessionDispose).toHaveBeenCalled();
expect(mgr.getSessions().some((s) => s.internalId === a.internalId)).toBe(false);
});
it("forwards the explicit backendId to createSession", async () => {
const mgr = buildManager();
const a = await mgr.createSession();
await mgr.replaceSessionInPlace(a.internalId, "opencode");
// The mocked AgentSession.start records the backendId on the session,
// so we can assert it landed on the replacement.
const replacement = mgr.getActiveSession();
expect(replacement?.backendId).toBe("opencode");
});
it("falls back to plain create when the old id is unknown", async () => {
const mgr = buildManager();
const replacement = await mgr.replaceSessionInPlace("does-not-exist");
expect(mgr.getSessions()).toEqual([replacement]);
expect(mgr.getActiveSession()).toBe(replacement);
});
it("the replacement also takes the chatUIState slot at the same index", async () => {
// The chatUIStates map is parallel to sessions — if it isn't reordered
// alongside, getActiveChatUIState would point at the wrong session.
const mgr = buildManager();
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
const replacement = await mgr.replaceSessionInPlace(a.internalId);
await flushBackgroundClose();
expect(mgr.getActiveChatUIState()).toBe(mgr.getChatUIState(replacement.internalId));
expect(mgr.getChatUIState(b.internalId)).not.toBeNull();
});
});
describe("AgentSessionManager.subscribe / shutdown", () => {
it("notifies subscribers on session create / close / activate", async () => {
const mgr = buildManager();
const listener = jest.fn();
mgr.subscribe(listener);
const a = await mgr.createSession();
const b = await mgr.createSession();
mgr.setActiveSession(a.internalId);
await mgr.closeSession(b.internalId);
expect(listener.mock.calls.length).toBeGreaterThanOrEqual(4);
});
it("shutdown cancels and disposes every session and clears state", async () => {
const mgr = buildManager();
await mgr.createSession();
await mgr.createSession();
expect(mgr.getSessions()).toHaveLength(2);
await mgr.shutdown();
expect(mgr.getSessions()).toEqual([]);
expect(mgr.getActiveSession()).toBeNull();
expect(mockSessionCancel).toHaveBeenCalledTimes(2);
expect(mockSessionDispose).toHaveBeenCalledTimes(2);
expect(mockBackendShutdown).toHaveBeenCalledTimes(1);
});
it("backend exit drops every session and surfaces lastError", async () => {
const mgr = buildManager();
const listener = jest.fn();
mgr.subscribe(listener);
await mgr.createSession();
await mgr.createSession();
// Simulate the subprocess exiting.
for (const fn of mockBackendExitListeners) fn();
expect(mgr.getSessions()).toEqual([]);
expect(mgr.getActiveSession()).toBeNull();
expect(mgr.getLastError()).toMatch(/exited unexpectedly/);
expect(listener).toHaveBeenCalled();
});
});
describe("AgentSessionManager.applySelection", () => {
it("no-ops when no session is active", async () => {
const mgr = buildManager();
await expect(
mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" })
).resolves.toBeUndefined();
});
it("no-ops when the active session is on a different backend", async () => {
const mgr = buildManager();
await mgr.createSession();
// Active session is on `opencode`; asking for a different backend
// must refuse so a stray cross-backend apply can't slip through.
await expect(
mgr.applySelection({ effort: "high" }, { expectBackendId: "claude-code" })
).resolves.toBeUndefined();
});
it("delegates dispatch to descriptor.applySelection with the resolved selection", async () => {
const applySelectionMock = jest.fn(async () => {});
const descriptor = {
id: "opencode",
displayName: "opencode",
getInstallState: jest.fn(),
subscribeInstallState: jest.fn(),
openInstallUI: jest.fn(),
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
wire: {
encode: ({ baseModelId, effort }: { baseModelId: string; effort: string | null }) =>
effort ? `${baseModelId}/${effort}` : baseModelId,
decode: (id: string) => ({
selection: { baseModelId: id, effort: null },
provider: null,
}),
},
applySelection: applySelectionMock,
} as unknown as BackendDescriptor;
const modelPreloader = {
getCachedBackendState: jest.fn(() => null),
preload: jest.fn(async () => undefined),
subscribe: jest.fn(() => () => {}),
shutdown: jest.fn(),
setCached: jest.fn(),
clearCached: jest.fn(),
};
const mgr = new AgentSessionManager(
buildApp(),
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
{
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
modelPreloader: modelPreloader as unknown as ConstructorParameters<
typeof AgentSessionManager
>[2]["modelPreloader"],
}
);
const entry = {
baseModelId: "anthropic/sonnet",
name: "Sonnet",
provider: "anthropic",
effortOptions: [
{ value: null, label: "Default" },
{ value: "high", label: "High" },
],
};
sessionCreateSpy.mockImplementationOnce((opts) => {
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
(s as unknown as { getState: () => unknown }).getState = () => ({
model: {
current: { baseModelId: entry.baseModelId, effort: null },
availableModels: [entry],
},
mode: null,
});
return s;
});
const session = await mgr.createSession();
// Effort-only patch: baseModelId resolves from current state.
(mockedSetSettings as jest.Mock).mockClear();
await mgr.applySelection({ effort: "high" }, { expectBackendId: "opencode" });
expect(applySelectionMock).toHaveBeenCalledWith(session, {
baseModelId: "anthropic/sonnet",
effort: "high",
});
// Resolved selection is also persisted to settings.
const persistedAfterEffort = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
expect(persistedAfterEffort).toEqual({
baseModelId: "anthropic/sonnet",
effort: "high",
});
// Full patch: both fields land verbatim.
applySelectionMock.mockClear();
(mockedSetSettings as jest.Mock).mockClear();
await mgr.applySelection({ baseModelId: "anthropic/opus", effort: null });
expect(applySelectionMock).toHaveBeenCalledWith(session, {
baseModelId: "anthropic/opus",
effort: null,
});
const persistedAfterFull = readPersistedDefault(mockedSetSettings as jest.Mock, "opencode");
expect(persistedAfterFull).toEqual({
baseModelId: "anthropic/opus",
effort: null,
});
});
it("does not persist when the descriptor's applySelection throws", async () => {
const applySelectionMock = jest.fn(async () => {
throw new Error("nope");
});
const descriptor = {
id: "opencode",
displayName: "opencode",
getInstallState: jest.fn(),
subscribeInstallState: jest.fn(),
openInstallUI: jest.fn(),
createBackendProcess: jest.fn(() => makeMockBackendProcess()),
wire: {
encode: ({ baseModelId }: { baseModelId: string }) => baseModelId,
decode: (id: string) => ({
selection: { baseModelId: id, effort: null },
provider: null,
}),
},
applySelection: applySelectionMock,
} as unknown as BackendDescriptor;
const modelPreloader = {
getCachedBackendState: jest.fn(() => null),
preload: jest.fn(async () => undefined),
subscribe: jest.fn(() => () => {}),
shutdown: jest.fn(),
setCached: jest.fn(),
clearCached: jest.fn(),
};
const mgr = new AgentSessionManager(
buildApp(),
buildPlugin() as unknown as ConstructorParameters<typeof AgentSessionManager>[1],
{
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
modelPreloader: modelPreloader as unknown as ConstructorParameters<
typeof AgentSessionManager
>[2]["modelPreloader"],
}
);
sessionCreateSpy.mockImplementationOnce((opts) => {
const s = makeMockSession({ internalId: opts.internalId, backendId: opts.backendId });
(s as unknown as { getState: () => unknown }).getState = () => ({
model: {
current: { baseModelId: "anthropic/sonnet", effort: null },
availableModels: [
{ baseModelId: "anthropic/sonnet", name: "Sonnet", provider: null, effortOptions: [] },
],
},
mode: null,
});
return s;
});
await mgr.createSession();
(mockedSetSettings as jest.Mock).mockClear();
await expect(
mgr.applySelection({ baseModelId: "anthropic/opus", effort: "high" })
).rejects.toThrow("nope");
// No persistence after a failed apply.
expect(readPersistedDefault(mockedSetSettings as jest.Mock, "opencode")).toBeUndefined();
});
});
/**
* Walk through `setSettings` calls (each carries an updater function) and
* return the most recent `defaultModel` written for `backendId`.
*/
function readPersistedDefault(
setSettings: jest.Mock,
backendId: string
): { baseModelId: string; effort: string | null } | undefined {
let backends: Record<string, { defaultModel?: { baseModelId: string; effort: string | null } }> =
{};
for (const call of setSettings.mock.calls) {
const updater = call[0];
if (typeof updater !== "function") continue;
const patch = updater({ agentMode: { backends } });
if (patch?.agentMode?.backends) {
backends = { ...backends, ...patch.agentMode.backends };
}
}
return backends[backendId]?.defaultModel;
}

View file

@ -0,0 +1,941 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
import { getSettings, setSettings } from "@/settings/model";
import { err2String } from "@/utils";
import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import { fileToHistoryItem } from "@/utils/chatHistoryUtils";
import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { AgentSession, ATTENTION_TRIGGER_STATUSES } from "./AgentSession";
import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import type { AgentModelPreloader } from "./AgentModelPreloader";
import type {
BackendDescriptor,
BackendId,
BackendProcess,
BackendState,
CopilotMode,
ModeApplySpec,
ModelSelection,
PermissionDecision,
PermissionPrompt,
} from "./types";
const AUTOSAVE_DEBOUNCE_MS = 500;
export type PermissionPrompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
// 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 {
permissionPrompter: PermissionPrompter;
resolveDescriptor: DescriptorResolver;
modelPreloader: AgentModelPreloader;
/**
* Persistence layer for Agent Mode chats. Optional only so legacy callers
* (tests) can omit it; production wiring always supplies one via the
* barrel in `agentMode/index.ts`.
*/
persistenceManager?: AgentChatPersistenceManager;
}
/**
* 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
* 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 backends = new Map<BackendId, BackendProcess>();
private starting = new Map<BackendId, Promise<BackendProcess>>();
private sessions = new Map<string, AgentSession>();
private chatUIStates = new Map<string, AgentChatUIState>();
private activeSessionId: string | null = null;
// Dedupe only the auto-spawn path. Direct `createSession()` calls (e.g. `+`
// clicks) are independent — concurrent ones each spawn their own session.
private firstSessionPromise: Promise<AgentSession> | null = null;
private pendingCreates = 0;
private listeners = new Set<() => void>();
private disposed = false;
private startingBackendId: BackendId | null = null;
private lastError: string | null = null;
private readonly pendingBackendRestarts = new Map<BackendId, string>();
private readonly restartingBackends = new Set<BackendId>();
private readonly preloader: AgentModelPreloader;
/**
* Resolves once the plugin-load preload phase has settled (regardless of
* per-backend success). Lets the chat UI gate its first render so the
* picker never flashes empty before the cache populates.
*/
private preloadPromise: Promise<void> = Promise.resolve();
private preloadReady = true;
// Per-session bookkeeping, all keyed by `internalId`. Mixes persistence
// bookkeeping with subscription teardowns — the unifying property is
// "must be cleaned up when the session is detached":
// - `path`: persisted file (set after first successful save)
// - `timer`: pending debounce timer
// - `unsub`: tear-down for the auto-save `session.subscribe()`
// - `signature`: last serialized snapshot, for no-op skipping
// - `modelCacheUnsub`: tear-down for the model-cache mirror subscription
// - `attentionUnsub`: tear-down for the needs-attention status watcher
private readonly sessionState = new Map<
string,
{
path?: string;
timer?: number;
unsub?: () => void;
signature?: string;
modelCacheUnsub?: () => void;
attentionUnsub?: () => void;
}
>();
private getSessionState(internalId: string) {
let entry = this.sessionState.get(internalId);
if (!entry) {
entry = {};
this.sessionState.set(internalId, entry);
}
return entry;
}
constructor(
private readonly app: App,
private readonly plugin: CopilotPlugin,
private readonly opts: AgentSessionManagerOptions
) {
if (Platform.isMobile) {
throw new Error("AgentSessionManager is desktop only");
}
this.preloader = opts.modelPreloader;
}
/**
* List every persisted Agent Mode chat as a `ChatHistoryItem` ranked using
* the plugin's shared in-memory `lastAccessedAt` tracker. Returns `[]` when
* persistence isn't configured.
*/
async getChatHistoryItems(): Promise<ChatHistoryItem[]> {
const persistence = this.opts.persistenceManager;
if (!persistence) return [];
const files = await persistence.getAgentChatHistoryFiles();
const tracker = this.plugin.getChatHistoryLastAccessedAtManager();
return files.map((file) => fileToHistoryItem(file, tracker));
}
/** Update the user-visible title (frontmatter `topic`) of a saved chat. */
async updateChatTitle(fileId: string, newTitle: string): Promise<void> {
const persistence = this.opts.persistenceManager;
if (!persistence) throw new Error("Agent chat persistence is not configured.");
await persistence.updateTopic(fileId, newTitle);
}
/** Delete a saved chat by file path. */
async deleteChatHistory(fileId: string): Promise<void> {
const persistence = this.opts.persistenceManager;
if (!persistence) throw new Error("Agent chat persistence is not configured.");
await persistence.deleteFile(fileId);
}
/**
* Return the active `AgentSession` if one exists, otherwise create one.
* Used by the router to lazily seed the first session on chain switch.
* Subsequent `+` clicks should call `createSession()` directly.
*/
async getOrCreateActiveSession(): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
const active = this.getActiveSession();
if (active && active.getStatus() !== "closed") return active;
// Dedupe rapid auto-spawn callers (e.g. the router effect re-running
// before the first create has populated the pool) so we don't seed two
// sessions when one was asked for.
if (this.firstSessionPromise) return this.firstSessionPromise;
this.firstSessionPromise = this.createSession();
try {
return await this.firstSessionPromise;
} finally {
this.firstSessionPromise = null;
}
}
/**
* 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).
*
* The new session's initial (model, effort) is read from the persisted
* default for `backendId` via `getDefaultSelection`. Picker call sites that
* want a specific selection on a new backend should call
* `persistDefaultSelection` first.
*/
async createSession(backendId?: BackendId): Promise<AgentSession> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
}
const vaultBasePath = adapter.getBasePath();
const resolvedId = backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
const descriptor = this.resolveDescriptor(resolvedId);
this.pendingCreates++;
this.startingBackendId = resolvedId;
this.notify();
let backend: BackendProcess;
try {
backend = await this.ensureBackend(resolvedId, descriptor);
} catch (err) {
this.lastError = err2String(err);
this.finishPendingCreate();
throw err;
}
if (this.disposed) {
this.finishPendingCreate();
throw new Error("AgentSessionManager was shut down during session creation");
}
const seedSelection = this.getDefaultSelection(resolvedId);
const defaultModelId = seedSelection ? descriptor.wire.encode(seedSelection) : undefined;
const session = AgentSession.start({
backend,
cwd: vaultBasePath,
internalId: uuidv4(),
backendId: resolvedId,
defaultModelId,
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
});
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
this.activeSessionId = session.internalId;
this.attachAutoSave(session);
this.attachModelCacheSync(session);
this.attachAttentionTracking(session);
this.notify();
// Once the ACP session is ready, apply backend-specific persisted state
// (claude-code's effort, future config-option preferences) and clear the
// "starting" pill. On failure, capture into `lastError` so the status
// surface and retry handler can react. The session itself transitions to
// status "error" inside its own `initialize`.
void session.ready
.then(async () => {
if (descriptor.applyInitialSessionConfig) {
try {
await descriptor.applyInitialSessionConfig(session, getSettings());
} catch (e) {
logWarn(
`[AgentMode] applyInitialSessionConfig failed for ${resolvedId}; continuing`,
e
);
}
}
this.lastError = null;
logInfo(
`[AgentMode] session ready (internal=${session.internalId} backend-id=${session.getBackendSessionId()} backend=${resolvedId}); pool size=${this.sessions.size}`
);
})
.catch((err) => {
this.lastError = err2String(err);
})
.finally(() => this.finishPendingCreate());
return session;
}
private finishPendingCreate(): void {
this.pendingCreates--;
if (this.pendingCreates === 0) this.startingBackendId = null;
this.notify();
}
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();
}
/** Read the user's sticky model preference for `backendId`, or `null` if none. */
getDefaultSelection(backendId: BackendId): ModelSelection | null {
const backends = getSettings().agentMode?.backends as
| Record<string, { defaultModel?: ModelSelection | null } | undefined>
| undefined;
return backends?.[backendId]?.defaultModel ?? null;
}
/** Persist a sticky model preference for `backendId`. Pass `null` to clear. */
async persistDefaultSelection(
backendId: BackendId,
selection: ModelSelection | null
): Promise<void> {
setSettings((cur) => {
const existing = (cur.agentMode.backends as Record<string, unknown> | undefined)?.[
backendId
] as Record<string, unknown> | undefined;
return {
agentMode: {
...cur.agentMode,
backends: {
...cur.agentMode.backends,
[backendId]: { ...(existing ?? {}), defaultModel: selection },
},
},
};
});
}
/** Persist a sticky mode preference for `backendId`. No-op if the descriptor doesn't opt in. */
async persistModeFor(backendId: BackendId, value: CopilotMode): Promise<void> {
const descriptor = this.resolveDescriptor(backendId);
if (!descriptor.persistModeSelection) return;
await descriptor.persistModeSelection(value, this.plugin);
}
/**
* Apply a (baseModelId, effort) selection to the active session. Both
* fields are optional patches against the current selection:
* - `baseModelId` omitted keep current
* - `effort` omitted keep current
* - `effort: null` explicit "default" (no-op for descriptor-style
* backends, encoded as the bare model id for suffix-style backends)
*
* `opts.expectBackendId`, when provided, makes this a silent no-op if
* the active session is on a different backend. Used by the effort
* sibling, which captures the backend id at picker-build time and
* might fire after a session swap.
*
* After a successful descriptor apply, the resolved selection is also
* written to the persisted default for the active backend symmetric
* with `applyMode`. If the descriptor throws, no persistence occurs.
*/
async applySelection(
patch: { baseModelId?: string; effort?: string | null },
opts?: { expectBackendId?: BackendId }
): Promise<void> {
const session = this.getActiveSession();
if (!session) return;
if (opts?.expectBackendId && session.backendId !== opts.expectBackendId) return;
const current = session.getState()?.model?.current;
if (!current) return;
const descriptor = this.resolveDescriptor(session.backendId);
const resolved: ModelSelection = {
baseModelId: patch.baseModelId ?? current.baseModelId,
effort: patch.effort !== undefined ? patch.effort : current.effort,
};
await descriptor.applySelection(session, resolved);
await this.persistDefaultSelection(session.backendId, resolved);
}
/**
* Apply a canonical mode change against the active session. `value` is
* the canonical id (used for persistence); `spec` carries the native
* dispatch info (which ACP RPC + payload).
*/
async applyMode(backendId: BackendId, value: CopilotMode, spec: ModeApplySpec): Promise<void> {
const session = this.getActiveSession();
if (!session || session.backendId !== backendId) return;
if (spec.kind === "setMode") {
await session.setMode(spec.nativeId);
} else {
await session.setConfigOption(spec.configId, spec.value);
}
await this.persistModeFor(backendId, value);
}
getBackendProcess(backendId: BackendId): BackendProcess | null {
return this.backends.get(backendId) ?? null;
}
/**
* Restart a backend process so spawn-time configuration, including native
* skill discovery and deny rules, is rebuilt from current settings. If a
* session on that backend is busy, the restart is deferred until it is idle.
*
* Returns `true` when a running backend was restarted or a restart was
* scheduled; `false` when no backend process exists yet.
*/
async restartBackend(backendId: BackendId, reason: string): Promise<boolean> {
if (this.disposed) return false;
const inflight = this.starting.get(backendId);
if (inflight) {
await inflight.catch(() => undefined);
}
const backend = this.backends.get(backendId);
if (!backend) return false;
if (this.hasBusySession(backendId)) {
const prev = this.pendingBackendRestarts.get(backendId);
this.pendingBackendRestarts.set(backendId, prev ? `${prev}; ${reason}` : reason);
logInfo(`[AgentMode] deferred ${backendId} backend restart: ${reason}`);
return true;
}
await this.restartBackendNow(backendId, reason);
return true;
}
/** Cached unified backend state for `backendId`, populated by the model preloader. */
getCachedBackendState(backendId: BackendId): BackendState | null {
return this.preloader.getCachedBackendState(backendId);
}
/**
* The agent's catalog-declared default base model id for `backendId`.
* Trusts `availableModels` ordering (agents put their recommended model
* first). Returns `null` when the catalog hasn't been probed yet.
*/
getDefaultBaseModelId(backendId: BackendId): string | null {
const state = this.preloader.getCachedBackendState(backendId);
return state?.model?.availableModels[0]?.baseModelId ?? null;
}
/** Subscribe to preloader cache updates. Used by the picker hook. */
subscribeModelCache(listener: () => void): () => void {
return this.preloader.subscribe(listener);
}
/** Kick off a (best-effort) model probe for `backendId`. */
preloadModels(backendId: BackendId): Promise<void> {
return this.preloader.preload(backendId);
}
/**
* Register the aggregate preload promise typically the `Promise.allSettled`
* of every backend's `preloadModels` call from plugin bring-up. While it
* pends, `isPreloadReady()` returns `false`; the chat UI uses that flag to
* render a "Loading…" placeholder instead of an empty picker.
*/
setPreloadPromise(promise: Promise<void>): void {
this.preloadReady = false;
this.preloadPromise = promise;
void promise.then(() => {
this.preloadReady = true;
this.notify();
});
this.notify();
}
/** Resolves once `setPreloadPromise`'s promise settles. */
whenPreloadReady(): Promise<void> {
return this.preloadPromise;
}
/** Synchronous check, suitable for React render gates. */
isPreloadReady(): boolean {
return this.preloadReady;
}
/**
* 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
* last remaining session) as the new active `null` when none remain.
* Backend stays up.
*/
async closeSession(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) return;
// Capture the closed tab's index BEFORE delete so we can pick the
// neighbor that currently sits to its right.
const idsBefore = Array.from(this.sessions.keys());
const closedIdx = idsBefore.indexOf(id);
try {
await session.cancel();
} catch (e) {
logWarn(`[AgentMode] cancel during closeSession failed`, e);
}
// Drain any pending debounced auto-save before tearing the session
// down — otherwise the last few tokens of a fast turn never reach disk.
await this.drainAutoSave(session);
try {
await session.dispose();
} catch (e) {
logWarn(`[AgentMode] dispose during closeSession failed`, e);
}
this.detachAutoSave(id);
this.sessions.delete(id);
this.chatUIStates.delete(id);
if (this.activeSessionId === id) {
const remaining = Array.from(this.sessions.keys());
this.activeSessionId =
remaining.length === 0 ? null : remaining[Math.min(closedIdx, remaining.length - 1)];
}
this.notify();
}
/** Move the active pointer to `id`. No-op if `id` is unknown. */
setActiveSession(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
if (this.activeSessionId === id) return;
this.activeSessionId = id;
session.clearNeedsAttention();
this.notify();
}
/**
* Spawn a fresh session at `oldId`'s tab-strip position and close `oldId`
* in the background. Used by the in-tab "New Chat" button so the
* replacement chat takes the same slot the user was looking at instead of
* appearing at the end of the strip (which made it look like focus had
* jumped to a sibling tab). `backendId` defaults to the same fallback as
* `createSession`.
*/
async replaceSessionInPlace(oldId: string, backendId?: BackendId): Promise<AgentSession> {
const oldIdx = Array.from(this.sessions.keys()).indexOf(oldId);
const created = await this.createSession(backendId);
if (oldIdx >= 0) {
this.moveMapEntry(this.sessions, created.internalId, oldIdx);
this.moveMapEntry(this.chatUIStates, created.internalId, oldIdx);
this.notify();
}
void this.closeSession(oldId).catch((e) =>
logWarn(`[AgentMode] closeSession during replaceSessionInPlace failed`, e)
);
return created;
}
// Maps preserve insertion order, so reordering means rebuilding the map.
// Used to land a freshly-created session at a specific tab-strip index.
private moveMapEntry<V>(map: Map<string, V>, key: string, targetIdx: number): void {
if (!map.has(key)) return;
const entries = Array.from(map.entries());
const fromIdx = entries.findIndex(([k]) => k === key);
if (fromIdx === -1 || fromIdx === targetIdx) return;
const [entry] = entries.splice(fromIdx, 1);
entries.splice(targetIdx, 0, entry);
map.clear();
for (const [k, v] of entries) map.set(k, v);
}
/** Update a session's user-visible label. No-op if `id` is unknown. */
renameSession(id: string, label: string | null): void {
const session = this.sessions.get(id);
if (!session) return;
session.setLabel(label);
this.notify();
}
getIsStarting(): boolean {
return this.startingBackendId !== null;
}
/** Backend id currently being booted, or null when no create is in flight. */
getStartingBackendId(): BackendId | null {
return this.startingBackendId;
}
getLastError(): string | null {
return this.lastError;
}
getSession(id: string): AgentSession | null {
return this.sessions.get(id) ?? null;
}
/**
* Find a session by its backend `sessionId` (the agent-side identifier
* embedded in `requestPermission` / `session/update` notifications).
* Distinct from the internal id keying our own pool. Returns null while
* the session is still starting (no backend id yet) or when no session
* matches.
*/
getSessionByBackendId(backendSessionId: string): AgentSession | null {
for (const session of this.sessions.values()) {
if (session.getBackendSessionId() === backendSessionId) return session;
}
return null;
}
getChatUIState(id: string): AgentChatUIState | null {
return this.chatUIStates.get(id) ?? null;
}
getActiveSession(): AgentSession | null {
return this.activeSessionId ? (this.sessions.get(this.activeSessionId) ?? null) : null;
}
getActiveChatUIState(): AgentChatUIState | null {
return this.activeSessionId ? (this.chatUIStates.get(this.activeSessionId) ?? null) : null;
}
/** All sessions in creation order (Map iteration order). */
getSessions(): AgentSession[] {
return Array.from(this.sessions.values());
}
/**
* Subscribe to lifecycle changes (session created/closed/active changed/
* label changed, backend exit, isStarting/lastError flips). Returns an
* unsubscribe function. Listeners must not throw.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (e) {
logError("[AgentMode] manager listener threw", e);
}
}
}
/** Cancel any in-flight turn on the active session. Backend stays up. */
async cancel(): Promise<void> {
await this.getActiveSession()?.cancel();
}
/**
* 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}, backends=${this.backends.size})`
);
const allSessions = Array.from(this.sessions.values());
// Drain pending auto-saves for every session before disposing — same
// reasoning as `closeSession`. Done before the per-session unsubscribe so
// the timers don't fire with a half-disposed session.
await Promise.allSettled(allSessions.map((s) => this.drainAutoSave(s)));
for (const id of Array.from(this.sessionState.keys())) {
this.detachAutoSave(id);
}
await Promise.allSettled(
allSessions.map(async (session) => {
try {
await session.cancel();
} catch (e) {
logError("[AgentMode] cancel during shutdown failed", e);
}
try {
await session.dispose();
} catch (e) {
logError("[AgentMode] dispose during shutdown failed", e);
}
})
);
this.sessions.clear();
this.chatUIStates.clear();
this.activeSessionId = null;
const allBackends = Array.from(this.backends.values());
await Promise.allSettled(
allBackends.map(async (proc) => {
try {
await proc.shutdown();
} catch (e) {
logError("[AgentMode] backend shutdown failed", e);
}
})
);
this.backends.clear();
this.starting.clear();
this.startingBackendId = null;
this.listeners.clear();
this.preloader.shutdown();
}
/**
* Open a previously-saved Agent Mode chat. If a live session is already
* bound to that file (because the user opened it earlier this run), focus
* its tab instead of spawning a duplicate. Otherwise, spin up a fresh
* session on the saved backend, seed its store with the persisted display
* messages, and pin the persisted-path map so subsequent turns update the
* same file.
*/
async loadSessionFromHistory(file: TFile): Promise<AgentSession> {
if (!this.opts.persistenceManager) {
throw new Error("Agent chat persistence is not configured.");
}
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
for (const [internalId, state] of this.sessionState.entries()) {
if (state.path !== file.path) continue;
const existing = this.sessions.get(internalId);
if (existing && existing.getStatus() !== "closed") {
this.setActiveSession(internalId);
return existing;
}
state.path = undefined;
}
const loaded = await this.opts.persistenceManager.loadFile(file);
const session = await this.createSession(loaded.backendId);
session.store.loadMessages(loaded.messages);
if (loaded.label) session.setLabel(loaded.label);
this.getSessionState(session.internalId).path = file.path;
this.notify();
return session;
}
private attachAutoSave(session: AgentSession): void {
const persistence = this.opts.persistenceManager;
if (!persistence) return;
const trigger = () => this.scheduleAutoSave(session);
const unsubscribe = session.subscribe({
onMessagesChanged: trigger,
onStatusChanged: () => {},
onLabelChanged: trigger,
});
this.getSessionState(session.internalId).unsub = unsubscribe;
}
private scheduleAutoSave(session: AgentSession): void {
const state = this.getSessionState(session.internalId);
if (state.timer) window.clearTimeout(state.timer);
state.timer = window.setTimeout(() => {
state.timer = undefined;
this.flushAutoSave(session).catch((e) =>
logWarn(`[AgentMode] auto-save failed for ${session.internalId}`, e)
);
}, AUTOSAVE_DEBOUNCE_MS);
}
private async flushAutoSave(session: AgentSession): Promise<void> {
const persistence = this.opts.persistenceManager;
if (!persistence) return;
if (!this.sessions.has(session.internalId)) return;
const messages = session.store.getDisplayMessages();
if (messages.length === 0) return;
const label = session.getLabel();
// Skip the write when nothing user-visible has changed since the last
// save. Streaming token updates and idempotent label notifications
// otherwise rewrite the entire file on every debounce tick.
const signature = `${label ?? ""}-${messages.length}-${
messages[messages.length - 1]?.message ?? ""
}`;
const state = this.getSessionState(session.internalId);
if (state.signature === signature) return;
const result = await persistence.saveSession(messages, session.backendId, {
label,
existingPath: state.path,
});
if (result) {
state.path = result.path;
state.signature = signature;
}
}
/**
* Cancel any pending debounced auto-save for `session` and run it
* synchronously, so the on-disk file reflects the final state before the
* session is disposed. Safe to call when no save is pending.
*/
private async drainAutoSave(session: AgentSession): Promise<void> {
const state = this.sessionState.get(session.internalId);
if (!state?.timer) return;
window.clearTimeout(state.timer);
state.timer = undefined;
try {
await this.flushAutoSave(session);
} catch (e) {
logWarn(`[AgentMode] drain auto-save failed for ${session.internalId}`, e);
}
}
private detachAutoSave(internalId: string): void {
const state = this.sessionState.get(internalId);
if (!state) return;
if (state.timer) window.clearTimeout(state.timer);
state.unsub?.();
state.modelCacheUnsub?.();
state.attentionUnsub?.();
this.sessionState.delete(internalId);
}
/**
* Mirror this session's unified `BackendState` into the preloader cache
* so the picker reflects current state. Skips when the session has no
* usable state yet (during the `"starting"` window) a naive sync
* would clobber the previous session's cached entries with an empty
* snapshot.
*/
private attachModelCacheSync(session: AgentSession): void {
const sync = (): void => {
const state = session.getState();
if (!state) return;
if (!state.model && !state.mode) return;
this.preloader.setCached(session.backendId, state);
};
sync();
const unsubscribe = session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: () => {},
onModelChanged: () => sync(),
});
this.getSessionState(session.internalId).modelCacheUnsub = unsubscribe;
}
/**
* Watch this session's status transitions and flag `needsAttention` when
* it transitions out of `running` into a state that demands the user's
* eye (turn ended, errored, or paused for permission) while a *different*
* tab is active. The flag is cleared in `setActiveSession` when the user
* clicks back to this tab.
*/
private attachAttentionTracking(session: AgentSession): void {
let prev = session.getStatus();
const unsubscribe = session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: (next) => {
const wasRunning = prev === "running";
prev = next;
void this.flushDeferredBackendRestartIfReady(session.backendId);
if (!wasRunning) return;
if (!ATTENTION_TRIGGER_STATUSES.has(next)) return;
if (this.activeSessionId === session.internalId) return;
session.markNeedsAttention();
},
});
this.getSessionState(session.internalId).attentionUnsub = unsubscribe;
}
private async ensureBackend(
backendId: BackendId,
descriptor: BackendDescriptor
): Promise<BackendProcess> {
const existing = this.backends.get(backendId);
if (existing && existing.isRunning()) return existing;
const inflight = this.starting.get(backendId);
if (inflight) return inflight;
const proc = descriptor.createBackendProcess({
plugin: this.plugin,
app: this.app,
clientVersion: this.plugin.manifest.version,
descriptor,
});
const startPromise = (async () => {
// ACP backends declare `start()` to spawn the subprocess and run the
// initialize handshake. In-process adapters (Claude SDK) omit it.
if (proc.start) await proc.start();
proc.setPermissionPrompter(this.opts.permissionPrompter);
proc.onExit(() => {
// Backend died unexpectedly. Sessions belonging to *this* backend
// are now unusable (their backend 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;
for (const s of dead) {
this.detachAutoSave(s.internalId);
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 = `${descriptor.displayName} backend exited unexpectedly.`;
this.notify();
});
this.backends.set(backendId, proc);
return proc;
})();
this.starting.set(backendId, startPromise);
try {
return await startPromise;
} finally {
this.starting.delete(backendId);
}
}
/** Whether any session for `backendId` is not safe to dispose yet. */
private hasBusySession(backendId: BackendId): boolean {
return Array.from(this.sessions.values()).some((session) => {
if (session.backendId !== backendId) return false;
const status = session.getStatus();
return status === "starting" || status === "running" || status === "awaiting_permission";
});
}
/** Execute a pending backend restart once every session for that backend is idle. */
private async flushDeferredBackendRestartIfReady(backendId: BackendId): Promise<void> {
const reason = this.pendingBackendRestarts.get(backendId);
if (!reason) return;
if (this.hasBusySession(backendId)) return;
this.pendingBackendRestarts.delete(backendId);
try {
await this.restartBackendNow(backendId, reason);
} catch (err) {
this.lastError = err2String(err);
logError(`[AgentMode] deferred ${backendId} backend restart failed`, err);
this.notify();
}
}
/** Immediately tear down `backendId` and replace the active affected tab. */
private async restartBackendNow(backendId: BackendId, reason: string): Promise<void> {
if (this.restartingBackends.has(backendId)) return;
const proc = this.backends.get(backendId);
if (!proc) return;
this.restartingBackends.add(backendId);
logInfo(`[AgentMode] restarting ${backendId} backend: ${reason}`);
try {
const affected = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
const shouldCreateReplacement =
affected.length > 0 && affected.some((s) => s.internalId === this.activeSessionId);
for (const session of affected) {
await this.closeSession(session.internalId);
}
await proc.shutdown();
if (this.backends.get(backendId) === proc) {
this.backends.delete(backendId);
}
this.preloader.clearCached(backendId);
new Notice(`${this.resolveDescriptor(backendId).displayName} refreshed after skill changes.`);
if (shouldCreateReplacement && !this.disposed) {
await this.createSession(backendId);
}
this.notify();
} finally {
this.restartingBackends.delete(backendId);
}
}
}

View file

@ -0,0 +1,32 @@
import { logWarn } from "@/logger";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import type { CopilotMode } from "@/agentMode/session/types";
/**
* Replay a persisted mode on a freshly created session. Skipped when the
* agent doesn't advertise modes or when the persisted mode isn't currently
* mappable (filtered out by the descriptor's `getModeMapping`). Dispatches
* on the apply-spec kind so backends with `setMode` channels and backends
* with `configOption`-driven modes share one implementation.
*/
export async function applyPersistedMode(
session: AgentSession,
persistedMode: CopilotMode
): Promise<void> {
const state = session.getState();
if (!state?.mode) return;
if (state.mode.current === persistedMode) return;
const spec = state.mode.apply[persistedMode];
if (!spec) return;
try {
if (spec.kind === "setMode") {
await session.setMode(spec.nativeId);
} else {
await session.setConfigOption(spec.configId, spec.value);
}
} catch (e) {
if (e instanceof MethodUnsupportedError) return;
logWarn(`[AgentMode] could not apply default mode ${persistedMode}`, e);
}
}

View file

@ -0,0 +1,20 @@
import type { CopilotSettings } from "@/settings/model";
import type { BackendId } from "./types";
interface BackendSliceWithOverrides {
modelEnabledOverrides?: Record<string, boolean>;
}
/**
* `undefined` means "no overrides written" callers should fall back to the
* descriptor's default policy.
*/
export function getBackendModelOverrides(
settings: CopilotSettings,
backendId: BackendId
): Record<string, boolean> | undefined {
const backends = settings.agentMode?.backends as
| Record<string, BackendSliceWithOverrides | undefined>
| undefined;
return backends?.[backendId]?.modelEnabledOverrides;
}

View file

@ -0,0 +1,101 @@
import { FrameSink, getFrameLogPaths, NodeRuntime } from "./debugSink";
interface FakeRuntime extends NodeRuntime {
files: Map<string, string>;
removedPaths: string[];
}
/** Create an in-memory runtime for exercising the frame sink without disk IO. */
function makeRuntime(tmpDir = "/tmp"): FakeRuntime {
const files = new Map<string, string>();
const removedPaths: string[] = [];
const join = (...parts: string[]) => parts.join("/").replace(/\/+/g, "/");
return {
files,
removedPaths,
tmpdir: () => tmpDir,
join,
dirname: (path) => path.slice(0, path.lastIndexOf("/")) || "/",
mkdir: jest.fn(async () => undefined),
appendFile: jest.fn(async (path, data) => {
files.set(path, (files.get(path) ?? "") + data);
}),
writeFile: jest.fn(async (path, data) => {
files.set(path, data);
}),
rm: jest.fn(async (path) => {
removedPaths.push(path);
files.delete(path);
}),
stat: jest.fn(async (path) => {
const data = files.get(path);
if (data === undefined) throw new Error("ENOENT");
return { size: data.length };
}),
rename: jest.fn(async (oldPath, newPath) => {
const data = files.get(oldPath);
if (data === undefined) throw new Error("ENOENT");
files.set(newPath, data);
files.delete(oldPath);
}),
openPath: jest.fn(async () => ""),
};
}
describe("FrameSink", () => {
it("stores frame logs in a per-vault temp directory", () => {
const runtime = makeRuntime("C:/Users/zero/AppData/Local/Temp");
const first = getFrameLogPaths("C:/Users/zero/Vault", runtime);
const second = getFrameLogPaths("C:/Users/zero/OtherVault", runtime);
expect(first.logPath).toContain("/obsidian-copilot/acp-frames/");
expect(first.logPath).toMatch(/\/acp-frames\.ndjson$/);
expect(first.rotatedPath).toMatch(/\/acp-frames\.old\.ndjson$/);
expect(first.dirPath).not.toBe(second.dirPath);
});
it("summarizes oversized frames before appending", async () => {
const runtime = makeRuntime();
const sink = new FrameSink({ vaultBasePath: "/vault", runtime });
const paths = getFrameLogPaths("/vault", runtime);
sink.append({
ts: "2026-05-12T00:00:00.000Z",
dir: "←",
tag: "codex",
kind: "notif",
method: "session/update",
id: null,
payload: {
update: {
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
},
content: "x".repeat(100_000),
},
});
await sink.flush();
const log = runtime.files.get(paths.logPath) ?? "";
expect(log.length).toBeLessThan(5_000);
expect(log).toContain('"__truncated":true');
expect(log).toContain("sessionUpdate=tool_call_update");
expect(log).toContain("toolCallId=call-1");
});
it("clears active and rotated log files", async () => {
const runtime = makeRuntime();
const sink = new FrameSink({ vaultBasePath: "/vault", runtime });
const paths = getFrameLogPaths("/vault", runtime);
runtime.files.set(paths.logPath, "active");
runtime.files.set(paths.rotatedPath, "old");
await sink.clear();
expect(runtime.files.has(paths.logPath)).toBe(false);
expect(runtime.files.has(paths.rotatedPath)).toBe(false);
expect(runtime.removedPaths).toEqual(
expect.arrayContaining([paths.logPath, paths.rotatedPath])
);
});
});

View file

@ -0,0 +1,410 @@
import { FileSystemAdapter } from "obsidian";
/**
* Sidecar logger and payload formatter shared by every backend's debug tap.
* The ACP runtime (`acp/debugTap.ts`) and the SDK adapter
* (`sdk/sdkDebugTap.ts`) both feed `frameSink` so JSON-RPC and SDK turns
* land in the same NDJSON file. `tag` distinguishes the source.
*/
export interface FrameRecord {
ts: string;
dir: "→" | "←";
tag: string;
kind: "request" | "notif" | "result" | "error" | "raw";
method: string;
id: string | null;
payload: unknown;
}
const LOG_FILE_NAME = "acp-frames.ndjson";
const ROTATED_FILE_NAME = "acp-frames.old.ndjson";
const DESKTOP_UNAVAILABLE_PATH = "(Agent Mode frame logs are desktop-only)";
const LOG_DIR_PREFIX = ["obsidian-copilot", "acp-frames"] as const;
const ROTATE_BYTES = 50 * 1024 * 1024;
// Per-frame cap. Some backends (notably codex) re-emit the full cumulative
// tool output on every `tool_call_update`, so a single frame can exceed 1 MB.
// We replace the payload with a `__truncated` stub above this threshold.
const MAX_LINE_BYTES = 64 * 1024;
// Bound the in-flight write queue. Without this, a 160 fps frame storm pins
// hundreds of MB of stringified lines as closures in `writeChain`.
const MAX_QUEUE_FRAMES = 32;
const MAX_QUEUE_BYTES = 8 * 1024 * 1024;
// Stat-based rotation check every N writes. With MAX_LINE_BYTES capped at
// 64 KB, the worst-case overshoot per check window is ~1.6 MB — well under
// any reasonable disk budget.
const ROTATE_CHECK_EVERY = 25;
const MAX_PAYLOAD_CHARS = 400;
export interface FrameLogPaths {
dirPath: string;
logPath: string;
rotatedPath: string;
}
export interface NodeRuntime {
tmpdir: () => string;
join: (...parts: string[]) => string;
dirname: (path: string) => string;
mkdir: (path: string, opts: { recursive: boolean }) => Promise<void>;
appendFile: (path: string, data: string, encoding: "utf8") => Promise<void>;
writeFile: (path: string, data: string, encoding: "utf8") => Promise<void>;
rm: (path: string, opts: { force: boolean; recursive?: boolean }) => Promise<void>;
stat: (path: string) => Promise<{ size: number }>;
rename: (oldPath: string, newPath: string) => Promise<void>;
openPath?: (path: string) => Promise<string | void>;
showItemInFolder?: (path: string) => void;
}
export interface FrameSinkOptions {
vaultBasePath?: string | null;
runtime?: NodeRuntime | null;
}
/**
* Sidecar logger for full backend frames. Writes are append-only NDJSON to
* keep the file grep/jq-friendly. Writes are serialized through a single
* promise chain so concurrent calls don't interleave partial lines.
*
* Rotation: every ROTATE_CHECK_EVERY writes, stat the file; if it exceeds
* ROTATE_BYTES, rename to `.old.ndjson` (overwriting any prior `.old`) and
* start a fresh file. Bounds disk use without losing the most recent session.
*/
export class FrameSink {
private writeChain: Promise<void> = Promise.resolve();
private ensuredDirPath: string | null = null;
private writeCount = 0;
private pendingFrames = 0;
private pendingBytes = 0;
private droppedSinceLastWrite = 0;
constructor(private readonly options: FrameSinkOptions = {}) {}
/** Return the current NDJSON log path, or a desktop-unavailable placeholder. */
getPath(): string {
return this.resolvePaths()?.logPath ?? DESKTOP_UNAVAILABLE_PATH;
}
/** Schedule a write. Returns immediately; failures are swallowed. */
append(record: FrameRecord): void {
const paths = this.resolvePaths();
if (!paths) return;
const line = this.toLine(record);
// Backpressure: drop new frames when the queue is saturated. Without
// this, bursty backends (codex emitting cumulative content at 160 fps)
// pin hundreds of MB of stringified lines while the vault adapter
// catches up.
if (
this.pendingFrames >= MAX_QUEUE_FRAMES ||
this.pendingBytes + line.length > MAX_QUEUE_BYTES
) {
this.droppedSinceLastWrite++;
return;
}
const lineBytes = line.length;
this.pendingFrames++;
this.pendingBytes += lineBytes;
this.writeChain = this.writeChain
.then(() => this.doAppend(paths, line))
.then(
() => {
this.pendingFrames--;
this.pendingBytes -= lineBytes;
},
() => {
this.pendingFrames--;
this.pendingBytes -= lineBytes;
}
);
}
/** Delete the active and rotated log files after queued writes finish. */
async clear(): Promise<void> {
const task = this.writeChain.then(async () => {
const paths = this.resolvePaths();
if (!paths) return;
const runtime = this.getRuntime();
if (!runtime) return;
await removeIfExists(runtime, paths.logPath);
await removeIfExists(runtime, paths.rotatedPath);
});
this.writeChain = task.catch(() => {});
return task;
}
/** Ensure the log exists and open it with the desktop file handler. */
async open(): Promise<void> {
const task = this.writeChain.then(async () => {
const paths = this.resolvePaths();
if (!paths) return;
const runtime = this.getRuntime();
if (!runtime) return;
await this.ensureFolder(runtime, paths.dirPath);
await ensureFileExists(runtime, paths.logPath);
});
this.writeChain = task.catch(() => {});
await task;
const paths = this.resolvePaths();
if (!paths) return;
const runtime = this.getRuntime();
if (!runtime) return;
if (runtime.openPath) {
const errorMessage = await runtime.openPath(paths.logPath);
if (typeof errorMessage === "string" && errorMessage.length > 0) {
throw new Error(errorMessage);
}
return;
}
if (runtime.showItemInFolder) {
runtime.showItemInFolder(paths.logPath);
return;
}
throw new Error("No OS file opener is available.");
}
/** Wait for all queued writes to settle. Intended for tests and tooling. */
async flush(): Promise<void> {
await this.writeChain;
}
private resolvePaths(): FrameLogPaths | null {
const runtime = this.getRuntime();
if (!runtime) return null;
const vaultBasePath = this.options.vaultBasePath ?? getVaultBasePath();
if (!vaultBasePath) return null;
return getFrameLogPaths(vaultBasePath, runtime);
}
private getRuntime(): NodeRuntime | null {
return this.options.runtime ?? getNodeRuntime();
}
private async ensureFolder(runtime: NodeRuntime, dirPath: string): Promise<void> {
if (this.ensuredDirPath === dirPath) return;
await runtime.mkdir(dirPath, { recursive: true });
this.ensuredDirPath = dirPath;
}
/**
* Serialize a record to a single NDJSON line, replacing payloads that
* exceed MAX_LINE_BYTES with a `__truncated` stub so a single huge frame
* can't dominate the queue or the on-disk file.
*/
private toLine(record: FrameRecord): string {
let line: string;
try {
line = JSON.stringify(record) + "\n";
} catch {
// Payload not serializable (e.g. circular). Fall back to a stub so the
// frame still shows up in the log.
return (
JSON.stringify({
...record,
payload: { __unserializable: true },
}) + "\n"
);
}
if (line.length <= MAX_LINE_BYTES) return line;
let payloadBytes = 0;
try {
payloadBytes = JSON.stringify(record.payload).length;
} catch {
payloadBytes = 0;
}
return (
JSON.stringify({
...record,
payload: {
__truncated: true,
originalBytes: payloadBytes,
summary: summarizePayload(record.payload),
},
}) + "\n"
);
}
private async doAppend(paths: FrameLogPaths, line: string): Promise<void> {
const runtime = this.getRuntime();
if (!runtime) return;
// Surface dropped-frame counts inline so debugging-the-debugger is
// possible without code reading. Reset BEFORE writing so concurrent
// drops accumulate into the next note.
let payload = line;
if (this.droppedSinceLastWrite > 0) {
const dropped = this.droppedSinceLastWrite;
this.droppedSinceLastWrite = 0;
const note =
JSON.stringify({
ts: new Date().toISOString(),
dir: "→",
tag: "frameSink",
kind: "raw",
method: "frameSink.dropped",
id: null,
payload: { dropped },
}) + "\n";
payload = note + line;
}
try {
await this.ensureFolder(runtime, paths.dirPath);
await runtime.appendFile(paths.logPath, payload, "utf8");
} catch {
// appendFile can fail if the directory was removed while a write was
// queued; recreate the folder and write the frame as a fresh file.
try {
await runtime.mkdir(runtime.dirname(paths.logPath), { recursive: true });
await runtime.writeFile(paths.logPath, payload, "utf8");
} catch {
return;
}
}
this.writeCount++;
if (this.writeCount % ROTATE_CHECK_EVERY === 0) {
await this.maybeRotate(runtime, paths);
}
}
private async maybeRotate(runtime: NodeRuntime, paths: FrameLogPaths): Promise<void> {
try {
const stat = await runtime.stat(paths.logPath);
if (stat.size < ROTATE_BYTES) return;
await removeIfExists(runtime, paths.rotatedPath);
await runtime.rename(paths.logPath, paths.rotatedPath);
} catch {
// ignore
}
}
}
/** Build the per-vault temp NDJSON paths used by the full-frame sink. */
export function getFrameLogPaths(vaultBasePath: string, runtime: NodeRuntime): FrameLogPaths {
const vaultHash = stableHash(vaultBasePath);
const dirPath = runtime.join(runtime.tmpdir(), ...LOG_DIR_PREFIX, vaultHash);
return {
dirPath,
logPath: runtime.join(dirPath, LOG_FILE_NAME),
rotatedPath: runtime.join(dirPath, ROTATED_FILE_NAME),
};
}
function getVaultBasePath(): string | null {
if (typeof app === "undefined") return null;
const adapter = app.vault?.adapter;
if (!(adapter instanceof FileSystemAdapter)) return null;
return adapter.getBasePath();
}
function getNodeRuntime(): NodeRuntime | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("node:os") as typeof import("node:os");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("node:path") as typeof import("node:path");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const electron = require("electron") as {
shell?: {
openPath?: (path: string) => Promise<string>;
showItemInFolder?: (path: string) => void;
};
remote?: {
shell?: {
openPath?: (path: string) => Promise<string>;
showItemInFolder?: (path: string) => void;
};
};
};
const shell = electron.shell ?? electron.remote?.shell;
return {
tmpdir: () => os.tmpdir(),
join: (...segs: string[]) => path.join(...segs),
dirname: (p: string) => path.dirname(p),
mkdir: async (dirPath, opts) => {
await fs.mkdir(dirPath, opts);
},
appendFile: fs.appendFile,
writeFile: fs.writeFile,
rm: fs.rm,
stat: fs.stat,
rename: fs.rename,
openPath: shell?.openPath?.bind(shell),
showItemInFolder: shell?.showItemInFolder?.bind(shell),
};
} catch {
return null;
}
}
async function ensureFileExists(runtime: NodeRuntime, path: string): Promise<void> {
try {
await runtime.stat(path);
} catch {
await runtime.writeFile(path, "", "utf8");
}
}
async function removeIfExists(runtime: NodeRuntime, path: string): Promise<void> {
try {
await runtime.rm(path, { force: true });
} catch {
// ignore — file already gone or adapter unavailable
}
}
function stableHash(value: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
/**
* Best-effort one-line summary for a truncated payload. Keeps the most
* useful identifying fields (`sessionUpdate`, `toolCallId`, `method`) so a
* truncated frame still tells the reader which call it belonged to.
*/
function summarizePayload(payload: unknown): string {
if (!payload || typeof payload !== "object") return String(payload);
const obj = payload as Record<string, unknown>;
const update = obj.update as Record<string, unknown> | undefined;
const parts: string[] = [];
if (typeof obj.method === "string") parts.push(`method=${obj.method}`);
if (update && typeof update.sessionUpdate === "string") {
parts.push(`sessionUpdate=${update.sessionUpdate}`);
if (typeof update.toolCallId === "string") parts.push(`toolCallId=${update.toolCallId}`);
}
return parts.join(" ") || "<no summary>";
}
export const frameSink = new FrameSink();
/**
* Stringify a payload for the truncated console log. Returns "" for
* undefined so the log line stays compact.
*/
export function formatPayload(value: unknown): string {
if (value === undefined) return "";
let s: string;
try {
s = JSON.stringify(value);
} catch {
s =
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: Object.prototype.toString.call(value);
}
if (s.length <= MAX_PAYLOAD_CHARS) return s;
return s.slice(0, MAX_PAYLOAD_CHARS) + `…(+${s.length - MAX_PAYLOAD_CHARS})`;
}

View file

@ -0,0 +1,191 @@
import type { App } from "obsidian";
import type React from "react";
import type CopilotPlugin from "@/main";
import type { CopilotSettings } from "@/settings/model";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import type {
BackendConfigOption,
BackendId,
BackendModelInfo,
BackendProcess,
CopilotMode,
ModelSelection,
ModelWireCodec,
ModeMapping,
RawModeState,
} from "./types";
/** UI-facing install/setup state for a backend. */
export type InstallState =
| { kind: "absent" }
| { kind: "ready"; source: "managed" | "custom" }
| { kind: "error"; message: string };
/**
* Backend-agnostic descriptor consumed by `session/` and `ui/`. Each backend
* exports one of these from its own folder; the registry maps `BackendId →
* BackendDescriptor`. Adding a new backend is exactly: implement
* `createBackendProcess`, export a `BackendDescriptor`, register it. No
* edits to session or UI.
*/
export interface BackendDescriptor {
readonly id: BackendId;
readonly displayName: string;
/**
* Brand icon component for this backend. Rendered in the session tab strip
* and anywhere else the UI surfaces backend identity. Should accept a
* `className` for sizing/coloring and use `currentColor` for fill so it
* adopts the surrounding theme color.
*/
readonly Icon: React.ComponentType<{ className?: string }>;
/**
* Project-relative POSIX path of the directory this backend reads skills
* from. No leading slash. The symlink fanout writes
* `<vault>/<skillsProjectDir>/<skill-name>` for every enabled skill.
*/
readonly skillsProjectDir: string;
/**
* Other backends whose skill directories this backend also loads skills
* from at spawn time, beyond its own `skillsProjectDir`. Drives the deny
* list for cross-discovered managed skills (see
* `skills/denyListComposer.ts`).
*
* Required (not optional) so a new backend must make an explicit decision.
* `[]` is the right answer when there is no cross-discovery surface.
*/
readonly crossDiscoveredAgents: ReadonlyArray<BackendId>;
/**
* When true, the host restarts this backend whenever the effective managed
* skill set changes. Set for backends (opencode) whose native skill-command
* cache is built at spawn and won't otherwise pick up symlink fanout changes.
*
* Required (not optional) so a new backend must make an explicit decision.
*/
readonly restartOnManagedSkillsChange: boolean;
/** Sync read of install/setup state from settings + last-known disk reconcile. */
getInstallState(settings: CopilotSettings): InstallState;
/** Subscribe to settings/disk changes affecting install state. Returns unsubscribe. */
subscribeInstallState(plugin: CopilotPlugin, cb: () => void): () => void;
/** Open backend-specific install/setup modal. */
openInstallUI(plugin: CopilotPlugin): void;
/**
* Construct the backend process the session manager will drive. ACP-style
* backends typically delegate to `simpleBinaryBackendProcess` from
* `backends/shared/`, which wraps `AcpBackendProcess` around an
* `AcpBackend` spawn descriptor. In-process adapters (e.g. the Claude
* Agent SDK) construct their own `BackendProcess` implementation directly.
*
* `descriptor` is the descriptor itself passed back so the backend
* process can call dispatch hooks (`getModeMapping`, `wire.decode`,
* `wire.encode`, `wire.effortConfigFor`) when producing `BackendState`
* from its native catalogs.
*/
createBackendProcess(args: {
plugin: CopilotPlugin;
app: App;
clientVersion: string;
descriptor: BackendDescriptor;
}): BackendProcess;
/** Optional: backend-specific settings panel. Rendered inside the Agent Mode tab. */
SettingsPanel?: React.FC<{ plugin: CopilotPlugin; app: App }>;
/** Optional: reconcile install state on plugin load (e.g. clear stale managed install). */
onPluginLoad?(plugin: CopilotPlugin): Promise<void>;
/**
* Wire-format codec for this backend's model ids. The single point of
* truth for "how does this backend pack model+effort into one
* `RawModelState.availableModels[].modelId` string." Used at the
* agent boundary by the translator (decode incoming catalog) and the
* session manager (encode outgoing `setSessionModel`); never invoked
* by the application layer.
*/
readonly wire: ModelWireCodec;
/**
* Apply a (baseModelId, effort) selection to a live session. The descriptor
* decides whether effort travels in the wire model id (suffix-style
* backends: codex, opencode) or via a separate `setConfigOption` call
* (descriptor-style: Claude SDK).
*
* `effort: null` means "default" descriptor-style backends typically
* no-op the effort dispatch on null (no "clear to default" config call
* exists); suffix-style backends encode the null and re-emit the bare
* model id.
*
* Implementations are expected to swallow `MethodUnsupportedError` from
* the underlying `session.setConfigOption` call (the backend may simply
* lack the capability) and propagate everything else.
*/
applySelection(session: AgentSession, selection: ModelSelection): Promise<void>;
/**
* Optional: return the canonical native mode mapping for this backend
* given the current session state. Returning `null` hides the mode picker
* for this backend. The mode adapter dispatches on `mapping.kind` to pick
* between "set mode" and "set config option" channels.
*/
getModeMapping?(
modeState: RawModeState | null,
configOptions: BackendConfigOption[] | null
): ModeMapping | null;
/**
* Optional: persist the user's chosen mode so the next session can replay
* it. Called by `AgentSessionManager.persistModeFor`.
*/
persistModeSelection?(value: CopilotMode, plugin: CopilotPlugin): Promise<void>;
/**
* Optional: replay persisted state on a freshly created session. Runs
* once after `createSession` resolves.
*/
applyInitialSessionConfig?(session: AgentSession, settings: CopilotSettings): Promise<void>;
/**
* Optional: identify the backend's own plan-mode plan files. Used by the
* Claude SDK permission bridge to auto-allow `Write` calls that target
* backend-owned plan markdown (`~/.claude/plans/*.md`) while rejecting
* arbitrary built-in writes. No other consumer today.
*
* `cwd` is the session's working directory; pass `null` when unknown
* (the matcher should still recognize absolute data-dir paths).
*/
isPlanModePlanFilePath?(absolutePath: string, cwd: string | null | undefined): boolean;
/**
* Optional: default enable/disable policy for an agent-reported model when
* the user has no explicit `modelEnabledOverrides` entry. Returning `true`
* surfaces the model in the chat picker and the settings tab; `false`
* hides it. Omit to default-enable every agent-reported model.
*
* Used as a no-config curation knob Codex and Opencode advertise large
* catalogs and we ship with one-model defaults; Claude Code defaults to
* showing all reported models.
*/
isModelEnabledByDefault?(model: BackendModelInfo): boolean;
/**
* Optional: previously-stored 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
* `resumeSession` or `loadSession`. Only called by `AgentModelPreloader`.
*/
persistProbeSessionId?(sessionId: string, plugin: CopilotPlugin): Promise<void>;
}

View file

@ -0,0 +1,23 @@
/**
* Cross-layer error types raised by the `BackendProcess` contract. Both the
* ACP runtime (`acp/AcpBackendProcess`) and the in-process SDK adapter
* (`sdk/ClaudeSdkBackendProcess`) raise `MethodUnsupportedError` when the
* backend doesn't implement an optional capability; callers in `session/`,
* `backends/*`, and `ui/` catch it and degrade gracefully.
*/
/**
* Thrown when a backend does not implement an optional `BackendProcess`
* method (e.g. `setSessionModel`, `resumeSession`, `loadSession`). Callers
* should catch this and degrade gracefully (e.g. disable the model picker,
* fall through to the next preloader strategy).
*/
export class MethodUnsupportedError extends Error {
constructor(method: string) {
super(`Agent does not implement ${method}`);
this.name = "MethodUnsupportedError";
}
}
/** JSON-RPC standard "Method not found" error code. */
export const JSONRPC_METHOD_NOT_FOUND = -32601;

View file

@ -0,0 +1,141 @@
import { mockTFile } from "@/__tests__/mockObsidian";
import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix";
import { CustomCommand } from "@/commands/type";
import { extractTemplateNoteFiles } from "@/utils";
import { Vault } from "obsidian";
jest.mock("obsidian", () => ({
Notice: jest.fn(),
TFile: jest.fn(),
Vault: jest.fn(),
}));
jest.mock("@/logger", () => ({
logWarn: jest.fn(),
logInfo: jest.fn(),
logError: jest.fn(),
}));
jest.mock("@/utils", () => {
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
return {
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
getFileContent: jest.fn(),
getFileName: jest.fn(),
getNotesFromPath: jest.fn(),
getNotesFromTags: jest.fn(),
processVariableNameForNotePath: jest.fn(),
stripFrontmatter: actual.stripFrontmatter,
};
});
const makeCommand = (overrides: Partial<CustomCommand>): CustomCommand => ({
title: "test",
content: "",
showInContextMenu: false,
showInSlashMenu: true,
order: 0,
modelKey: "",
lastUsedMs: 0,
...overrides,
});
describe("expandCustomCommandPrefix", () => {
let vault: Vault;
beforeEach(() => {
jest.clearAllMocks();
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([]);
vault = {
adapter: {
stat: jest.fn().mockResolvedValue({ ctime: Date.now(), mtime: Date.now() }),
},
} as unknown as Vault;
});
it("returns input unchanged when text does not start with slash", async () => {
const result = await expandCustomCommandPrefix("hello world", [], vault, "", null);
expect(result).toEqual({ text: "hello world" });
});
it("returns input unchanged when no command matches", async () => {
const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })];
const result = await expandCustomCommandPrefix("/unknown", cmds, vault, "", null);
expect(result).toEqual({ text: "/unknown" });
});
it("returns input unchanged for a lone slash", async () => {
const cmds = [makeCommand({ title: "foo", content: "FOO BODY" })];
const result = await expandCustomCommandPrefix("/", cmds, vault, "", null);
expect(result).toEqual({ text: "/" });
});
it("returns input unchanged when an empty commands list is given (skill collision case)", async () => {
// Mirrors composeSlashMenuItems: if a skill shadows the command title,
// the command never reaches this expander, so an empty list = pass-through.
const result = await expandCustomCommandPrefix("/foo", [], vault, "", null);
expect(result).toEqual({ text: "/foo" });
});
it("expands `/foo` exactly to the command body (no args)", async () => {
const cmd = makeCommand({ title: "foo", content: "FOO BODY" });
const result = await expandCustomCommandPrefix("/foo", [cmd], vault, "", null);
expect(result.matched).toBe(cmd);
expect(result.text).toBe("FOO BODY\n\n");
});
it("is case-insensitive on the command title", async () => {
const cmd = makeCommand({ title: "Random-Hello", content: "Say hi" });
const result = await expandCustomCommandPrefix("/random-hello", [cmd], vault, "", null);
expect(result.matched).toBe(cmd);
expect(result.text).toBe("Say hi\n\n");
});
it("appends trailing args to the body before processing", async () => {
const cmd = makeCommand({ title: "foo", content: "FOO BODY" });
const result = await expandCustomCommandPrefix("/foo bar baz", [cmd], vault, "", null);
expect(result.matched).toBe(cmd);
expect(result.text).toBe("FOO BODY\n\nbar baz\n\n");
});
it("prefers the longest matching title", async () => {
const fooBar = makeCommand({ title: "foo-bar", content: "LONG" });
const foo = makeCommand({ title: "foo", content: "SHORT" });
const result = await expandCustomCommandPrefix("/foo-bar args", [foo, fooBar], vault, "", null);
expect(result.matched).toBe(fooBar);
expect(result.text).toBe("LONG\n\nargs\n\n");
});
it("does not match when the title is a prefix but not followed by whitespace", async () => {
const foo = makeCommand({ title: "foo", content: "FOO" });
const result = await expandCustomCommandPrefix("/foobar", [foo], vault, "", null);
expect(result).toEqual({ text: "/foobar" });
});
it("expands `{}` against selected text", async () => {
const cmd = makeCommand({ title: "rewrite", content: "Rewrite this: {}" });
const result = await expandCustomCommandPrefix(
"/rewrite",
[cmd],
vault,
"the selected paragraph",
null
);
expect(result.matched).toBe(cmd);
expect(result.text).toBe(
"Rewrite this: {selected_text}\n\n<selected_text>\nthe selected paragraph\n</selected_text>"
);
});
it("treats activeNote as the {} target when no selection is present", async () => {
const cmd = makeCommand({ title: "summarize", content: "Summarize: {}" });
const activeNote = mockTFile({ path: "notes/today.md", basename: "today" });
const utils = jest.requireMock<{ getFileContent: jest.Mock }>("@/utils");
utils.getFileContent.mockResolvedValue("note body");
const result = await expandCustomCommandPrefix("/summarize", [cmd], vault, "", activeNote);
expect(result.matched).toBe(cmd);
expect(result.text).toContain('<selected_text type="active_note">');
expect(result.text).toContain("note body");
});
});

View file

@ -0,0 +1,53 @@
import { processPrompt } from "@/commands/customCommandUtils";
import type { CustomCommand } from "@/commands/type";
import type { TFile, Vault } from "obsidian";
export interface ExpandCustomCommandResult {
/** Final text to send to the backend. Equal to input when no command matched. */
text: string;
/** The matched command, if `input` started with `/<command-title>`. */
matched?: CustomCommand;
}
/**
* If `input` starts with `/<command-title>` (optionally followed by
* whitespace + args), substitute the command's body and return the
* processed prompt. Otherwise return `input` unchanged.
*
* Args typed after the command name are appended to the command body
* (separated by a blank line) so `processPrompt` can resolve `{}` /
* `{selection}` against either selected text or the trailing args.
*
* Matching is case-insensitive on `title`. When multiple titles share a
* prefix (e.g. `foo` and `foo-bar`), the longest match wins. The match
* must be followed by whitespace or end-of-string so `/foobar` does not
* match a `foo` command.
*/
export async function expandCustomCommandPrefix(
input: string,
commands: readonly CustomCommand[],
vault: Vault,
selectedText: string,
activeNote: TFile | null
): Promise<ExpandCustomCommandResult> {
if (!input.startsWith("/") || input.length < 2) return { text: input };
const afterSlash = input.slice(1);
const lowerAfterSlash = afterSlash.toLowerCase();
// Longest-first so `/foo-bar` wins over `/foo`.
const candidates = [...commands].sort((a, b) => b.title.length - a.title.length);
const matched = candidates.find((cmd) => {
const title = cmd.title.toLowerCase();
if (!lowerAfterSlash.startsWith(title)) return false;
const next = afterSlash.charAt(title.length);
return next === "" || /\s/.test(next);
});
if (!matched) return { text: input };
const args = afterSlash.slice(matched.title.length).trim();
const body = args ? `${matched.content}\n\n${args}` : matched.content;
const result = await processPrompt(body, selectedText, vault, activeNote, false);
return { text: result.processedPrompt, matched };
}

View file

@ -0,0 +1,26 @@
export {
AgentSessionManager,
type AgentSessionManagerOptions,
type PermissionPrompter,
} from "./AgentSessionManager";
export { AgentSession, type AgentSessionStatus, type AgentSessionListener } from "./AgentSession";
export { AgentChatUIState } from "./AgentChatUIState";
export type { AgentChatBackend } from "./AgentChatBackend";
export { AgentMessageStore } from "./AgentMessageStore";
export type {
BackendId,
BackendProcess,
AgentChatMessage,
AgentMessagePart,
AgentToolCallOutput,
AgentToolKind,
AgentToolStatus,
AgentPlanEntry,
NewAgentChatMessage,
PermissionDecision,
PermissionPrompt,
SessionEvent,
SessionUpdate,
} from "./types";
export type { BackendDescriptor, InstallState } from "./descriptor";
export { MethodUnsupportedError, JSONRPC_METHOD_NOT_FOUND } from "./errors";

Some files were not shown because too many files have changed in this diff Show more