logancyang_obsidian-copilot/src/agentMode/index.ts
Logan Yang e962c79f60
Surface a custom model set via ANTHROPIC_MODEL in the Claude model picker (#2588)
* feat(agent-mode): surface a custom ANTHROPIC_MODEL as a selectable Claude model

Setting ANTHROPIC_MODEL=<id> in the Claude backend's env overrides had no
effect on the model picker: the picker is built only from the SDK's advertised
catalog, so a free-form id had nowhere to surface.

Read ANTHROPIC_MODEL from the backend's env overrides and merge it into the
catalog as a synthetic entry, so it flows through model discovery into the
curation list and chat picker. Selecting it sends the id as options.model;
it is also honored as the persisted default seed. A custom id that shadows a
built-in is deduped (catalog returned by the same reference).

Closes #149

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): restart the affected backend when its env overrides change

An env-overrides edit only reached the live or warm backend process on the
next spawn/probe: nothing watched agentMode.backends.<id>.envOverrides, so
the picker kept serving the pre-edit cached catalog until an unrelated
restart or a new session recomputed it. With the Claude SDK now folding a
custom ANTHROPIC_MODEL into its catalog, that staleness became user-visible.

Subscribe to settings changes and restart the backend whose env-overrides
record actually changed (order-independent key, so unrelated settings writes
and key reordering are no-ops). The editor debounces commits and the
manager/preloader coalesce rapid restarts into one re-probe, matching the
existing provider-config subscription.

Addresses the Codex P2 review finding on #2588.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(agent-mode): surface ANTHROPIC_MODEL via the SDK's native catalog

Per review (zeroliu): avoid maintaining a second source of truth for Claude
models. Verified empirically that the `claude` CLI already reflects a
user-set `ANTHROPIC_MODEL` into its init handshake `init.models` (with full
effort metadata) when the env var is present in the probe's environment.

The custom model never appeared only because `probeClaudeSdkCatalog` spawned
the init probe with `pathToClaudeCodeExecutable` alone and no `env`, so the
override never reached it. Fix: thread the backend's `envOverrides` into the
probe (merged onto `process.env`, mirroring the prompt path). The model now
surfaces natively as a first-class catalog entry, including effort levels.

Drops the synthetic catalog injection (`customModelFromEnv` / `mergeCustomModel`
/ `effectiveCatalog`). Scopes the plugin-lifetime catalog cache by an
order-independent env-overrides key so editing `ANTHROPIC_MODEL` invalidates a
stale entry; the next `ensureModelCatalog()` re-probes with the new env. The
env-change restart wiring from the prior commit still triggers that re-probe
and the picker refresh.

Tests: env-scoped cache + probe env-passing in effortOption.test.ts; backend
threading of env overrides into the probe in ClaudeSdkBackendProcess.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:12:59 -07:00

389 lines
19 KiB
TypeScript

import { type App, Platform } from "obsidian";
import type CopilotPlugin from "@/main";
import { logError } from "@/logger";
import { DEFAULT_SKILLS_FOLDER } from "@/constants";
import { getSettings, subscribeToSettingsChange, type CopilotSettings } from "@/settings/model";
import { subscribeToSystemPromptChange } from "@/system-prompts/state";
import { buildAgentSystemPrompt } from "./backends/shared/agentSystemPrompt";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import type { BackendId } from "./session/types";
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
import { AgentModelPreloader } from "./session/AgentModelPreloader";
import { AgentSessionManager } from "./session/AgentSessionManager";
import { shouldUseMiyo } from "@/miyo/miyoUtils";
import { SkillManager } from "./skills";
import { managedBuiltinSkills, MIYO_SEARCH_SKILL } from "./skills/builtin/builtinSkills";
import {
removeSeededBuiltin,
seedBuiltinSkills,
type BuiltinSeedFs,
} from "./skills/builtin/seedBuiltinSkills";
import {
createDefaultAskUserQuestionPrompter,
createDefaultPermissionPrompter,
} from "./ui/permissionPrompter";
export { AGENT_CHAT_MODE } from "@/constants";
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";
// First-enrollment default-enable rule (enable the agent's current model).
export { computeDefaultEnabledIds } from "./session/agentDefaultEnable";
export type { EnrolledModelRef } from "./session/agentDefaultEnable";
export { partitionOpencodeOnlyWireIds } from "./backends/opencode/opencodeProbePartition";
export { mapProviderToOpencodeId } from "./backends/opencode/opencodeModelResolve";
export type { OpencodeProviderMapping } from "./backends/opencode/opencodeModelResolve";
export { installBadge, InstallBadge, InstallStatusLine } from "./backends/shared/installStatus";
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 { ModelEnableList } from "./ui/ModelEnableList";
export type { ModelEnableGroup, ModelEnableRow } from "./ui/ModelEnableList";
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, setFrameSinkVaultBasePath } from "./session/debugSink";
export { getManagedSkills, SkillManager, SkillsSettings, useManagedSkills } from "./skills";
export type { Skill } from "./skills";
/**
* True when the platform supports Agent Mode. Agent Mode is always on, but
* requires subprocess support, so this is always false on mobile.
*/
export function isAgentModeEnabled(): boolean {
return !Platform.isMobile;
}
/** Hook variant for symmetry with other settings-derived hooks. */
export function useIsAgentModeEnabled(): boolean {
return isAgentModeEnabled();
}
/**
* 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;
}
/**
* The exact system prompt every backend bakes in — i.e. `buildAgentSystemPrompt`'s
* real output, not a hand-picked subset. Used as the per-backend restart dedup
* key so a restart fires iff the composed prompt actually changes: the "disable
* builtin" toggle, the user's custom prompt (including the legacy
* `userSystemPrompt` fallback), the base framing, and the pill directive.
* Keying on the builder's actual output means the key can't silently drift from
* what the backend sends.
*
* The prompt is provider-agnostic, so the key is the same for every `backendId`
* and does not vary with the sticky default model — switching models never
* needs a prompt-driven restart. The parameter is kept so the call site can key
* per-backend should that ever change.
*/
function backendSystemPromptKey(_backendId: BackendId): string {
return buildAgentSystemPrompt();
}
/**
* Order-independent dedup key for a backend's env overrides, so the restart
* subscription fires iff the effective record actually changes (not on key
* reordering or unrelated settings writes).
*/
function backendEnvOverridesKey(settings: CopilotSettings, backendId: BackendId): string {
const backends = settings.agentMode?.backends as
| Partial<Record<BackendId, { envOverrides?: Record<string, string> }>>
| undefined;
const env = backends?.[backendId]?.envOverrides ?? {};
return JSON.stringify(Object.entries(env).sort(([a], [b]) => a.localeCompare(b)));
}
/**
* 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 initialized before the preload probes fire: any
* spawn-time directive that reads `SkillManager.getInstance()` synchronously
* inside `newSession()` would otherwise throw "called before initialize"
* when the probe runs. Doing it in this function (rather than from
* `main.ts` via a separate call) keeps the dependency order obvious.
*
* `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(
(id) => managerRef?.getSessionByBackendId(id) ?? null
);
const askUserQuestionPrompter = createDefaultAskUserQuestionPrompter(
(id) => managerRef?.getSessionByBackendId(id) ?? null
);
const manager = new AgentSessionManager(app, plugin, {
permissionPrompter: prompter,
askUserQuestionPrompter,
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)
);
});
// Provider rows, API keys, and per-backend enabled-models lists are baked
// into subprocess backends' spawn config (e.g. opencode's
// `OPENCODE_CONFIG_CONTENT`). Restart any descriptor that opts in so a
// new spawn picks them up. Without this, a key entered after the
// subprocess started never reaches it — opencode keeps making un-
// authenticated requests and surfaces them as silent zero-token turns.
//
// A single BYOK save fires several emits in quick succession (provider row →
// API key → enabled models). Coalescing them into one re-probe lives a layer
// down: a running backend folds rapid restarts via the manager's restart
// queue, and a warm preload probe folds them via `preloader.refresh` — both
// re-read the *final* config once the burst settles, so no debounce here.
const restartProviderAffected = (reason: string): void => {
for (const descriptor of listBackendDescriptors()) {
if (!descriptor.restartOnProviderConfigChange) continue;
void manager
.restartBackend(descriptor.id, reason)
.catch((error) =>
logError(`[AgentMode] restart after ${reason} failed: ${descriptor.id}`, error)
);
}
};
plugin.modelManagement.providerRegistry.subscribe(() =>
restartProviderAffected("provider config changed")
);
plugin.modelManagement.backendConfigRegistry.subscribe(() =>
restartProviderAffected("backend enabled models changed")
);
// The composed Agent Mode system prompt (Copilot base + pill directive + the
// user's custom prompt) is baked into opencode/codex spawn-time config and
// shared across sessions, so a prompt change only reaches those agents on a
// fresh spawn. Restart the opted-in backends when their *effective* composed
// prompt changes; the Claude SDK re-reads it per `newSession()` and opts out.
//
// The effective prompt depends on several stores (the session-selection atom,
// the prompts list, the persisted default-prompt-title, and the legacy
// `userSystemPrompt` fallback), and the underlying atoms also fire on no-op
// list reloads — so we dedupe per backend on the builder's real output rather
// than a guessed subset of inputs. On initial load this is a harmless no-op:
// `restartBackend` returns early when no subprocess is running yet.
const lastSystemPromptKeys = new Map<BackendId, string>();
for (const descriptor of listBackendDescriptors()) {
if (descriptor.restartOnSystemPromptChange) {
lastSystemPromptKeys.set(descriptor.id, backendSystemPromptKey(descriptor.id));
}
}
const restartSystemPromptAffected = (): void => {
for (const descriptor of listBackendDescriptors()) {
if (!descriptor.restartOnSystemPromptChange) continue;
const key = backendSystemPromptKey(descriptor.id);
if (key === lastSystemPromptKeys.get(descriptor.id)) continue;
lastSystemPromptKeys.set(descriptor.id, key);
void manager
.restartBackend(descriptor.id, "system prompt changed")
.catch((error) =>
logError(`[AgentMode] restart after system prompt change failed: ${descriptor.id}`, error)
);
}
};
subscribeToSystemPromptChange(restartSystemPromptAffected);
// Env overrides are baked into subprocess spawn env, and the Claude SDK
// folds them into its model catalog (a custom `ANTHROPIC_MODEL` becomes a
// picker entry). Either way an edit only reaches the live or warm process on
// a fresh spawn/probe, so restart the backend whose record actually changed.
// The editor debounces commits and the manager/preloader coalesce rapid
// restarts, so a burst of edits folds into one re-probe (same rationale as
// the provider-config subscription above).
subscribeToSettingsChange((prev, next) => {
for (const descriptor of listBackendDescriptors()) {
if (
backendEnvOverridesKey(prev, descriptor.id) === backendEnvOverridesKey(next, descriptor.id)
) {
continue;
}
void manager
.restartBackend(descriptor.id, "env overrides changed")
.catch((error) =>
logError(`[AgentMode] restart after env overrides change failed: ${descriptor.id}`, error)
);
}
});
// Seed the plugin-shipped builtin skills into the canonical folder, then run
// discovery so the pass picks them up and fans them out to the agent dirs.
// The Plus relay skills are always seeded; the Miyo vault-search skill is
// gated on Miyo being in use — seeded when on, and the seeded copy pruned
// when off — so it only surfaces while Miyo is available. Discovery runs even
// when seeding fails so existing skills still reconcile.
const seedManagedBuiltins = async (folder: string): Promise<void> => {
const adapter = app.vault.adapter;
const fs: BuiltinSeedFs = {
exists: (p) => adapter.exists(p),
read: (p) => adapter.read(p),
write: (p, c) => adapter.write(p, c),
mkdir: (p) => adapter.mkdir(p),
rmRecursive: (p) => adapter.rmdir(p, true),
};
const useMiyo = shouldUseMiyo(getSettings());
try {
await seedBuiltinSkills({
skillsFolderRelPath: folder,
fs,
skills: managedBuiltinSkills(useMiyo),
});
if (!useMiyo) {
await removeSeededBuiltin(folder, MIYO_SEARCH_SKILL.name, fs);
}
} catch (e) {
logError("[Skills] builtin skill seeding failed", e);
}
await skillManager.refresh();
};
subscribeToSettingsChange((prev, next) => {
if (
prev.defaultSystemPromptTitle !== next.defaultSystemPromptTitle ||
prev.userSystemPrompt !== next.userSystemPrompt
) {
restartSystemPromptAffected();
}
// Copilot Plus sign-in/out (or license rotation) changes the managed env
// injected at spawn — the decrypted license the builtin Plus skill scripts
// read. Restart every backend so the next session sees the license appear
// or disappear without a plugin reload.
if (prev.isPlusUser !== next.isPlusUser || prev.plusLicenseKey !== next.plusLicenseKey) {
for (const descriptor of listBackendDescriptors()) {
void manager
.restartBackend(descriptor.id, "Copilot Plus license changed")
.catch((e) =>
logError(`[AgentMode] restart after plus change failed: ${descriptor.id}`, e)
);
}
}
// Re-seed builtins when the canonical skills folder changes (so the tools
// appear in the new folder without a reload) or when Miyo availability
// flips (so the gated Miyo skill is seeded/pruned to match). Both run the
// same gate-aware seed pass against the current folder.
const prevFolder = prev.agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const nextFolder = next.agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
// `shouldUseMiyo` depends on `isSelfHostAccessValid()`, which reads the
// self-host validation fields — and those are refreshed asynchronously at
// startup (after the initial seed pass). Watch them too, so the skill is
// re-seeded when validation flips invalid→valid without a reload.
const miyoAvailabilityChanged =
prev.enableMiyo !== next.enableMiyo ||
prev.miyoServerUrl !== next.miyoServerUrl ||
prev.isPlusUser !== next.isPlusUser ||
prev.selfHostModeValidatedAt !== next.selfHostModeValidatedAt ||
prev.selfHostValidationCount !== next.selfHostValidationCount;
if (prevFolder !== nextFolder || miyoAvailabilityChanged) {
// Miyo availability also gates the `miyo-search` system-prompt steering
// (see `buildAgentSystemPrompt`). codex/opencode bake the prompt at spawn,
// so a mid-session flip needs a restart to apply. Chain the restart AFTER
// the seed + `skillManager.refresh()` resolve so the skill is written and
// reconciled into the agent dirs before the agent respawns — otherwise
// codex (which has `restartOnManagedSkillsChange: false`) could come back
// with steering pointing at a skill that isn't on disk yet. The seed pass
// swallows its own errors and always refreshes, so this normally runs
// after reconcile; `restartSystemPromptAffected` dedupes on the real prompt
// key, so it's a no-op when the rebuilt prompt is unchanged.
void seedManagedBuiltins(nextFolder)
.then(() => {
if (miyoAvailabilityChanged) {
restartSystemPromptAffected();
}
})
.catch((e) => logError("[Skills] builtin skill re-seeding failed", e));
}
});
// A backend's binary path (or a binary install/update) is resolved at spawn
// time, so a change must reach the running/warm process — otherwise it only
// updates the settings status line and the agent keeps the old binary until
// a plugin reload. Each descriptor's `subscribeInstallState` already fires
// only on its own path/install field, so this won't churn on unrelated saves.
for (const descriptor of listBackendDescriptors()) {
descriptor.subscribeInstallState(plugin, () => {
void manager
.onInstallStateChanged(descriptor.id)
.catch((error) =>
logError(`[AgentMode] install-state refresh failed: ${descriptor.id}`, error)
);
});
}
// Seed plugin-shipped builtin skills into the canonical folder, THEN run
// discovery so the first pass picks them up and fans them out to the agent
// dirs. Non-blocking for plugin load.
void seedManagedBuiltins(getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER).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 (!isAgentModeEnabled()) return manager;
// Per-backend preload registration: each backend's status flips
// independently. The chat UI gates on the active backend's status; the
// picker reads every backend's status to render per-backend loading rows.
for (const descriptor of listBackendDescriptors()) {
if (descriptor.getInstallState(settings).kind !== "ready") continue;
const promise = manager.preloadModels(descriptor.id);
manager.registerPreload(
descriptor.id,
promise.catch((e) => {
logError(`[AgentMode] preload ${descriptor.id} failed`, e);
throw e;
})
);
}
return manager;
}