summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/agent-manager.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
committerAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
commit8151447758e6826a578363758a755c6cebd1c05f (patch)
tree6afa780c28ca6e4622c1ab30238665caaad4371e /packages/api/src/agent-manager.ts
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz
dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/api/src/agent-manager.ts')
-rw-r--r--packages/api/src/agent-manager.ts176
1 files changed, 161 insertions, 15 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 28b54f5..4f45781 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -19,11 +19,15 @@ import {
ModelResolver,
TaskList,
createTaskListTool,
+ type ClaudeAccount,
+ discoverClaudeAccounts,
+ refreshAccountCredentials,
+ refreshAccountCredentialsAsync,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
import { setConfigGetter } from "./routes/config.js";
import { setSkillsGetter } from "./routes/skills.js";
-import { setModelsGetter } from "./routes/models.js";
+import { setModelsGetter, setAccountsGetter } from "./routes/models.js";
const SYSTEM_PROMPT = `You are Dispatch, a helpful AI coding assistant. You have access to the following tools for working with files in the current working directory:
@@ -42,6 +46,9 @@ export class AgentManager {
private eventListeners: Set<(event: AgentEvent) => void> = new Set();
private permissionManager: PermissionManager | undefined;
+ activeModelId: string | null = null;
+ activeKeyId: string | null = null;
+
private config: DispatchConfig;
private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] };
private modelRegistry: ModelRegistry | null = null;
@@ -51,6 +58,8 @@ export class AgentManager {
private configWatcher: { close(): void } | null = null;
private skillsWatcher: { close(): void } | null = null;
+ private claudeAccounts: ClaudeAccount[] = [];
+
constructor(permissionManager?: PermissionManager) {
this.permissionManager = permissionManager;
@@ -71,6 +80,9 @@ export class AgentManager {
// Load initial skills
this.skillsData = loadSkills(workingDirectory);
+ // Discover Claude accounts
+ this._refreshClaudeAccounts();
+
// Wire route getters
setConfigGetter(() => this.config);
setSkillsGetter(() => this.skillsData);
@@ -78,6 +90,7 @@ export class AgentManager {
() => this.modelRegistry,
() => this.modelResolver,
);
+ setAccountsGetter(() => this.claudeAccounts);
// Set up task list
this.taskList = new TaskList();
@@ -109,6 +122,17 @@ export class AgentManager {
});
}
+ private _refreshClaudeAccounts(): void {
+ try {
+ this.claudeAccounts = discoverClaudeAccounts();
+ if (this.claudeAccounts.length > 0) {
+ console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`);
+ }
+ } catch (err) {
+ console.warn(`dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
private _initModelRegistry(config: DispatchConfig): void {
if (config.models && config.keys) {
if (this.modelRegistry) {
@@ -132,7 +156,23 @@ export class AgentManager {
return this.taskList;
}
- private getOrCreateAgent(): Agent {
+ getClaudeAccounts(): ClaudeAccount[] {
+ return this.claudeAccounts;
+ }
+
+ private async getOrCreateAgent(keyId?: string, modelId?: string): Promise<Agent> {
+ // Determine effective override: use provided values, or fall back to stored active values
+ const effectiveKeyId = keyId ?? this.activeKeyId ?? undefined;
+ const effectiveModelId = modelId ?? this.activeModelId ?? undefined;
+
+ // If the override differs from what the current agent was built with, invalidate the cache
+ if (
+ this.agent &&
+ (effectiveKeyId !== this.activeKeyId || effectiveModelId !== this.activeModelId)
+ ) {
+ this.agent = null;
+ }
+
if (!this.agent) {
const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
@@ -150,8 +190,90 @@ export class AgentManager {
let apiKey = process.env.OPENCODE_API_KEY ?? "";
let model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
let baseURL = "https://opencode.ai/zen/go/v1";
+ let provider: string | undefined;
+ let claudeCredentials: { accessToken: string } | undefined;
+
+ let useOverride = false;
+
+ if (effectiveKeyId && effectiveModelId && this.modelRegistry) {
+ // Direct override: look up the key by id in the registry
+ const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === effectiveKeyId);
+ if (keyState) {
+ const key = keyState.definition;
+ if (key.provider === "anthropic") {
+ // Anthropic provider: resolve credentials from Claude accounts
+ const credFile = key.credentials_file;
+ const account = credFile
+ ? this.claudeAccounts.find((a) => a.source === credFile)
+ : this.claudeAccounts[0];
+ if (account) {
+ const creds = refreshAccountCredentials(account);
+ if (creds && creds.expiresAt > Date.now() + 60_000) {
+ claudeCredentials = { accessToken: creds.accessToken };
+ apiKey = creds.accessToken;
+ baseURL = key.base_url;
+ model = effectiveModelId;
+ provider = "anthropic";
+ this.activeKeyId = effectiveKeyId;
+ this.activeModelId = effectiveModelId;
+ useOverride = true;
+ } else {
+ // Token expired — await the async refresh
+ const fresh = await refreshAccountCredentialsAsync(account);
+ if (fresh && fresh.expiresAt > Date.now() + 60_000) {
+ account.credentials = fresh;
+ claudeCredentials = { accessToken: fresh.accessToken };
+ apiKey = fresh.accessToken;
+ baseURL = key.base_url;
+ model = effectiveModelId;
+ provider = "anthropic";
+ this.activeKeyId = effectiveKeyId;
+ this.activeModelId = effectiveModelId;
+ useOverride = true;
+ } else {
+ console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`);
+ claudeCredentials = { accessToken: account.credentials.accessToken };
+ apiKey = account.credentials.accessToken;
+ baseURL = key.base_url;
+ model = effectiveModelId;
+ provider = "anthropic";
+ this.activeKeyId = effectiveKeyId;
+ this.activeModelId = effectiveModelId;
+ useOverride = true;
+ }
+ }
+ } else {
+ console.warn(`dispatch: no Claude credentials found for key "${key.id}"`);
+ }
+ } else {
+ // Standard key: resolve from env var
+ const envKey = key.env ? process.env[key.env] : undefined;
+ if (envKey) {
+ apiKey = envKey;
+ baseURL = key.base_url;
+ model = effectiveModelId;
+ this.activeKeyId = effectiveKeyId;
+ this.activeModelId = effectiveModelId;
+ useOverride = true;
+ } else {
+ console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`);
+ this.activeKeyId = effectiveKeyId;
+ this.activeModelId = effectiveModelId;
+ useOverride = true;
+ }
+ }
+ } else {
+ console.warn(`dispatch: key "${effectiveKeyId}" not found in model registry, falling back to tag-based resolution`);
+ }
+ }
- if (this.modelRegistry && this.modelResolver) {
+ if (!useOverride) {
+ // Clear any previous override when falling back to default resolution
+ this.activeKeyId = null;
+ this.activeModelId = null;
+ }
+
+ if (!useOverride && this.modelRegistry && this.modelResolver) {
// Try to get model_tag from default agent template, fall back to "heavy"
const defaultAgent = this.config.agents?.["default"];
const tag = defaultAgent?.model_tag ?? "heavy";
@@ -159,15 +281,38 @@ export class AgentManager {
if (resolved) {
model = resolved.model.id;
baseURL = resolved.key.base_url;
- const envKey = process.env[resolved.key.env];
- if (envKey) {
- apiKey = envKey;
+ // Check if resolved key is anthropic
+ if (resolved.key.provider === "anthropic") {
+ const credFile = resolved.key.credentials_file;
+ const account = credFile
+ ? this.claudeAccounts.find((a) => a.source === credFile)
+ : this.claudeAccounts[0];
+ if (account) {
+ let creds = refreshAccountCredentials(account);
+ if (!creds || creds.expiresAt <= Date.now() + 60_000) {
+ creds = await refreshAccountCredentialsAsync(account);
+ if (creds) account.credentials = creds;
+ }
+ if (creds) {
+ claudeCredentials = { accessToken: creds.accessToken };
+ apiKey = creds.accessToken;
+ provider = "anthropic";
+ } else {
+ console.warn(`dispatch: no valid Claude credentials for key "${resolved.key.id}"`);
+ }
+ } else {
+ console.warn(`dispatch: no Claude credentials found for key "${resolved.key.id}"`);
+ }
} else {
- console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`);
- // Don't use the resolved key — fall back to default env vars entirely
- model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
- baseURL = "https://opencode.ai/zen/go/v1";
- apiKey = process.env.OPENCODE_API_KEY ?? "";
+ const envKey = process.env[resolved.key.env!];
+ if (envKey) {
+ apiKey = envKey;
+ } else {
+ console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`);
+ model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
+ baseURL = "https://opencode.ai/zen/go/v1";
+ apiKey = process.env.OPENCODE_API_KEY ?? "";
+ }
}
} else {
console.warn(`dispatch: could not resolve model for tag "${tag}", falling back to env vars`);
@@ -183,6 +328,8 @@ export class AgentManager {
workingDirectory,
permissionChecker: this.permissionManager ?? undefined,
ruleset,
+ provider,
+ ...(claudeCredentials ? { claudeCredentials } : {}),
});
}
return this.agent;
@@ -209,14 +356,13 @@ export class AgentManager {
}
}
- async processMessage(message: string): Promise<void> {
- const agent = this.getOrCreateAgent();
-
+ async processMessage(message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> {
this.status = "running";
this.messageCount += 1;
try {
- for await (const event of agent.run(message)) {
+ const agent = await this.getOrCreateAgent(keyId, modelId);
+ for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) {
this.status = event.type === "status" ? event.status : this.status;
this.emit(event);
}