diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 6f5799838..8a7cd87f9 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -19,6 +19,10 @@ port: 3000 defaults: runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b agent: claude-code # claude-code | codex | aider | goose | custom + # orchestrator: + # agent: claude-code + # worker: + # agent: codex workspace: worktree # worktree | clone | copy notifiers: [desktop] # desktop | slack | discord | webhook | email @@ -59,6 +63,16 @@ projects: # permissions: skip # --dangerously-skip-permissions # model: opus + # Optional role-specific agent overrides + # orchestrator: + # agent: claude-code + # agentConfig: + # model: claude-sonnet-4-5 + # worker: + # agent: codex + # agentConfig: + # model: gpt-5-codex + # Inline rules included in every agent prompt for this project # agentRules: | # Always run tests before pushing. diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index a8a1a033b..0ba53c0cc 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -414,6 +414,71 @@ describe("Config Schema Validation", () => { expect(validated.projects.proj1.agentConfig?.model).toBe("worker-model"); expect(validated.projects.proj1.agentConfig?.orchestratorModel).toBe("orchestrator-model"); }); + + it("accepts role-specific agent overrides at defaults and project scope", () => { + const config = { + defaults: { + agent: "claude-code", + orchestrator: { + agent: "opencode", + }, + worker: { + agent: "codex", + }, + }, + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + orchestrator: { + agent: "claude-code", + agentConfig: { + model: "orchestrator-model", + }, + }, + worker: { + agent: "codex", + agentConfig: { + model: "worker-model", + }, + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.defaults.orchestrator?.agent).toBe("opencode"); + expect(validated.defaults.worker?.agent).toBe("codex"); + expect(validated.projects.proj1.orchestrator?.agent).toBe("claude-code"); + expect(validated.projects.proj1.orchestrator?.agentConfig?.model).toBe("orchestrator-model"); + expect(validated.projects.proj1.worker?.agent).toBe("codex"); + expect(validated.projects.proj1.worker?.agentConfig?.model).toBe("worker-model"); + }); + + it("does not inject default permissions into role-specific agent config", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + agentConfig: { + permissions: "suggest", + }, + worker: { + agent: "codex", + agentConfig: { + model: "worker-model", + }, + }, + }, + }, + }); + + expect(config.projects.proj1.agentConfig?.permissions).toBe("suggest"); + expect(config.projects.proj1.worker?.agentConfig?.permissions).toBeUndefined(); + }); }); describe("Config Defaults", () => { diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 172301559..4f22a74ab 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -205,6 +205,59 @@ describe("check (single session)", () => { expect(meta!["status"]).toBe("working"); }); + it("uses worker-specific agent fallback when metadata does not persist an agent", async () => { + const codexAgent: Agent = { + ...mockAgent, + name: "codex", + processName: "codex", + getActivityState: vi.fn().mockResolvedValue({ state: "active" as ActivityState }), + }; + const registryWithMultipleAgents: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") { + if (name === "codex") return codexAgent; + if (name === "mock-agent") return mockAgent; + } + return null; + }), + }; + const configWithWorkerAgent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "mock-agent", + worker: { + agent: "codex", + }, + }, + }, + }; + const session = makeSession({ status: "working", metadata: {} }); + vi.mocked(mockSessionManager.get).mockResolvedValue(session); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + }); + + const lm = createLifecycleManager({ + config: configWithWorkerAgent, + registry: registryWithMultipleAgents, + sessionManager: mockSessionManager, + }); + + await lm.check("app-1"); + + expect(codexAgent.getActivityState).toHaveBeenCalled(); + expect(mockAgent.getActivityState).not.toHaveBeenCalled(); + }); + it("detects killed state when runtime is dead", async () => { vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts new file mode 100644 index 000000000..fa585db6d --- /dev/null +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { validateSession } from "../recovery/validator.js"; +import type { ScannedSession } from "../recovery/scanner.js"; +import type { Agent, OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js"; +import { getSessionsDir } from "../paths.js"; + +describe("recovery validator", () => { + let rootDir = ""; + + afterEach(() => { + if (rootDir) { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("uses role-specific orchestrator agent fallback when metadata is missing agent", async () => { + rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + const projectPath = join(rootDir, "project"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn(), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn().mockResolvedValue(true), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn(), + list: vi.fn(), + exists: vi.fn().mockResolvedValue(true), + }; + const mockWorkerAgent: Agent = { + name: "mock-agent", + processName: "mock-agent", + getLaunchCommand: vi.fn(), + getEnvironment: vi.fn(), + detectActivity: vi.fn(), + getActivityState: vi.fn(), + isProcessRunning: vi.fn().mockResolvedValue(false), + getSessionInfo: vi.fn(), + }; + const mockOrchestratorAgent: Agent = { + ...mockWorkerAgent, + name: "codex", + processName: "codex", + isProcessRunning: vi.fn().mockResolvedValue(true), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") { + if (name === "codex") return mockOrchestratorAgent; + if (name === "mock-agent") return mockWorkerAgent; + } + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const config: OrchestratorConfig = { + configPath: join(rootDir, "agent-orchestrator.yaml"), + port: 3000, + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "mock-agent", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + app: { + name: "app", + repo: "org/repo", + path: projectPath, + defaultBranch: "main", + sessionPrefix: "app", + agent: "mock-agent", + orchestrator: { + agent: "codex", + }, + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + }; + const scanned: ScannedSession = { + sessionId: "app-orchestrator", + projectId: "app", + project: config.projects.app, + sessionsDir: getSessionsDir(config.configPath, projectPath), + rawMetadata: { + worktree: projectPath, + status: "working", + role: "orchestrator", + runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }), + }, + }; + + const assessment = await validateSession(scanned, config, registry); + + expect(assessment.agentProcessRunning).toBe(true); + expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled(); + expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 6f8f325fd..b23ea6b8b 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; import { homedir, tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createSessionManager } from "../session-manager.js"; +import { validateConfig } from "../config.js"; import { writeMetadata, readMetadata, @@ -819,6 +820,60 @@ describe("spawn", () => { expect(meta!["agent"]).toBe("mock-agent"); }); + it("uses project worker agent when configured and no spawn override is provided", async () => { + const configWithWorkerAgent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "mock-agent", + worker: { + agent: "codex", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithWorkerAgent, + registry: registryWithMultipleAgents, + }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); + expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("codex"); + }); + + it("uses defaults worker agent when project agent is not set", async () => { + const configWithDefaultWorkerAgent: OrchestratorConfig = { + ...config, + defaults: { + ...config.defaults, + worker: { + agent: "codex", + }, + }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: undefined, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithDefaultWorkerAgent, + registry: registryWithMultipleAgents, + }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("codex"); + }); + it("readMetadata returns agent field (typed SessionMetadata)", async () => { const sm = createSessionManager({ config, registry: registryWithMultipleAgents }); @@ -3338,6 +3393,191 @@ describe("spawnOrchestrator", () => { ); }); + it("keeps orchestrator launch permissionless even when shared config sets permissions", async () => { + const configWithSharedPermissions: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + permissions: "suggest", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithSharedPermissions, + registry: mockRegistry, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + permissions: "permissionless", + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ permissions: "permissionless" }), + }), + }), + ); + }); + + it("uses project orchestrator agent when configured", async () => { + const mockCodexAgent: Agent = { + ...mockAgent, + name: "codex", + processName: "codex", + getLaunchCommand: vi.fn().mockReturnValue("codex --start"), + getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }), + }; + const registryWithMultipleAgents: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") { + if (name === "codex") return mockCodexAgent; + if (name === "mock-agent") return mockAgent; + } + return null; + }), + }; + const configWithOrchestratorAgent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "mock-agent", + orchestrator: { + agent: "codex", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithOrchestratorAgent, + registry: registryWithMultipleAgents, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); + expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + }); + + it("uses defaults orchestrator agent when project agent is not set", async () => { + const mockCodexAgent: Agent = { + ...mockAgent, + name: "codex", + processName: "codex", + getLaunchCommand: vi.fn().mockReturnValue("codex --start"), + getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }), + }; + const registryWithMultipleAgents: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") { + if (name === "codex") return mockCodexAgent; + if (name === "mock-agent") return mockAgent; + } + return null; + }), + }; + const configWithDefaultOrchestratorAgent: OrchestratorConfig = { + ...config, + defaults: { + ...config.defaults, + orchestrator: { + agent: "codex", + }, + }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: undefined, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithDefaultOrchestratorAgent, + registry: registryWithMultipleAgents, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + }); + + it("keeps shared worker permissions when role-specific config only overrides model", async () => { + const configWithSharedPermissions: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + permissions: "suggest", + }, + worker: { + agentConfig: { + model: "worker-model", + }, + }, + }, + }, + }; + + const validatedConfig = validateConfig(configWithSharedPermissions); + validatedConfig.configPath = config.configPath; + const sm = createSessionManager({ + config: validatedConfig, + registry: mockRegistry, + }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ permissions: "suggest", model: "worker-model" }), + ); + }); + + it("uses role-specific orchestratorModel when configured", async () => { + const configWithRoleOrchestratorModel: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + model: "worker-model", + orchestratorModel: "shared-orchestrator-model", + }, + orchestrator: { + agentConfig: { + orchestratorModel: "role-orchestrator-model", + }, + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithRoleOrchestratorModel, + registry: mockRegistry, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ model: "role-orchestrator-model" }), + ); + }); + it("forwards configured subagent to orchestrator launch", async () => { const configWithSubagent: OrchestratorConfig = { ...config, diff --git a/packages/core/src/agent-selection.ts b/packages/core/src/agent-selection.ts new file mode 100644 index 000000000..de8a76d77 --- /dev/null +++ b/packages/core/src/agent-selection.ts @@ -0,0 +1,89 @@ +import { + normalizeAgentPermissionMode, + isOrchestratorSession, + type AgentPermissionMode, + type AgentSpecificConfig, + type DefaultPlugins, + type ProjectConfig, +} from "./types.js"; + +export type SessionRole = "orchestrator" | "worker"; + +export interface ResolvedAgentSelection { + role: SessionRole; + agentName: string; + agentConfig: AgentSpecificConfig; + model?: string; + permissions?: AgentPermissionMode; + subagent?: string; +} + +export function resolveSessionRole( + sessionId: string, + metadata?: Record, +): SessionRole { + return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker"; +} + +export function resolveAgentSelection(params: { + role: SessionRole; + project: ProjectConfig; + defaults: DefaultPlugins; + persistedAgent?: string; + spawnAgentOverride?: string; +}): ResolvedAgentSelection { + const { role, project, defaults, persistedAgent, spawnAgentOverride } = params; + const roleProjectConfig = role === "orchestrator" ? project.orchestrator : project.worker; + const roleDefaults = role === "orchestrator" ? defaults.orchestrator : defaults.worker; + const sharedConfig = project.agentConfig ?? {}; + const roleAgentConfig = roleProjectConfig?.agentConfig ?? {}; + + const agentName = persistedAgent + ? persistedAgent + : role === "worker" + ? (spawnAgentOverride ?? + roleProjectConfig?.agent ?? + project.agent ?? + roleDefaults?.agent ?? + defaults.agent) + : (roleProjectConfig?.agent ?? project.agent ?? roleDefaults?.agent ?? defaults.agent); + + const agentConfig: AgentSpecificConfig = { + ...sharedConfig, + }; + for (const [key, value] of Object.entries(roleAgentConfig)) { + if (value !== undefined) { + agentConfig[key] = value; + } + } + + const model = + role === "orchestrator" + ? (roleAgentConfig.orchestratorModel ?? + roleAgentConfig.model ?? + sharedConfig.orchestratorModel ?? + sharedConfig.model) + : (roleAgentConfig.model ?? sharedConfig.model); + + if (model !== undefined) { + agentConfig.model = model; + } + + const permissions = normalizeAgentPermissionMode( + typeof agentConfig.permissions === "string" ? agentConfig.permissions : undefined, + ); + if (permissions !== undefined) { + agentConfig.permissions = permissions; + } + const subagent = + typeof agentConfig["subagent"] === "string" ? agentConfig["subagent"] : undefined; + + return { + role, + agentName, + agentConfig, + model, + permissions, + subagent, + }; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index dea31124c..d0b704633 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -104,6 +104,30 @@ const AgentSpecificConfigSchema = z }) .passthrough(); +const RoleAgentSpecificConfigSchema = z + .object({ + permissions: z + .union([z.enum(["permissionless", "default", "auto-edit", "suggest"]), z.literal("skip")]) + .optional(), + model: z.string().optional(), + orchestratorModel: z.string().optional(), + opencodeSessionId: z.string().optional(), + }) + .passthrough(); + +const RoleAgentDefaultsSchema = z + .object({ + agent: z.string().optional(), + }) + .optional(); + +const RoleAgentConfigSchema = z + .object({ + agent: z.string().optional(), + agentConfig: RoleAgentSpecificConfigSchema.optional(), + }) + .optional(); + const DecomposerConfigSchema = z .object({ enabled: z.boolean().default(false), @@ -135,6 +159,8 @@ const ProjectConfigSchema = z.object({ symlinks: z.array(z.string()).optional(), postCreate: z.array(z.string()).optional(), agentConfig: AgentSpecificConfigSchema.default({}), + orchestrator: RoleAgentConfigSchema, + worker: RoleAgentConfigSchema, reactions: z.record(ReactionConfigSchema.partial()).optional(), agentRules: z.string().optional(), agentRulesFile: z.string().optional(), @@ -151,6 +177,8 @@ const DefaultPluginsSchema = z.object({ agent: z.string().default("claude-code"), workspace: z.string().default("worktree"), notifiers: z.array(z.string()).default(["composio", "desktop"]), + orchestrator: RoleAgentDefaultsSchema, + worker: RoleAgentDefaultsSchema, }); const OrchestratorConfigSchema = z.object({ diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index e4cb6e866..bb20d5bb7 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -36,6 +36,7 @@ import { import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; import { createCorrelationId, createProjectObserver } from "./observability.js"; +import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; /** Parse a duration string like "10m", "30s", "1h" to milliseconds. */ function parseDuration(str: string): number { @@ -202,7 +203,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project) return session.status; - const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent; + const agentName = resolveAgentSelection({ + role: resolveSessionRole(session.id, session.metadata), + project, + defaults: config.defaults, + persistedAgent: session.metadata["agent"], + }).agentName; const agent = registry.get("agent", agentName); const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index 8b4534991..3a896e06f 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -19,6 +19,7 @@ import { type RecoveryAction, type RecoveryConfig, } from "./types.js"; +import { resolveAgentSelection, resolveSessionRole } from "../agent-selection.js"; export async function validateSession( scanned: ScannedSession, @@ -29,7 +30,12 @@ export async function validateSession( const { sessionId, projectId, project, rawMetadata } = scanned; const runtimeName = project.runtime ?? config.defaults.runtime; - const agentName = rawMetadata["agent"] ?? project.agent ?? config.defaults.agent; + const agentName = resolveAgentSelection({ + role: resolveSessionRole(sessionId, rawMetadata), + project, + defaults: config.defaults, + persistedAgent: rawMetadata["agent"], + }).agentName; const workspaceName = project.workspace ?? config.defaults.workspace; const runtime = registry.get("runtime", runtimeName); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 0c0313d30..d546bc244 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -73,6 +73,7 @@ import { } from "./global-pause.js"; import { sessionFromMetadata } from "./utils/session-from-metadata.js"; import { safeJsonParse } from "./utils/validation.js"; +import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; const execFileAsync = promisify(execFile); const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000; @@ -690,12 +691,9 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } /** Resolve which plugins to use for a project. */ - function resolvePlugins(project: ProjectConfig, agentOverride?: string) { + function resolvePlugins(project: ProjectConfig, agentName?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); - const agent = registry.get( - "agent", - agentOverride ?? project.agent ?? config.defaults.agent, - ); + const agent = registry.get("agent", agentName ?? project.agent ?? config.defaults.agent); const workspace = registry.get( "workspace", project.workspace ?? config.defaults.workspace, @@ -708,6 +706,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return { runtime, agent, workspace, tracker, scm }; } + function resolveSelectionForSession( + project: ProjectConfig, + sessionId: string, + metadata: Record, + ) { + return resolveAgentSelection({ + role: resolveSessionRole(sessionId, metadata), + project, + defaults: config.defaults, + persistedAgent: metadata["agent"], + }); + } + async function ensureOpenCodeSessionMapping( session: Session, sessionName: string, @@ -871,22 +882,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } - const plugins = resolvePlugins(project); + const selection = resolveAgentSelection({ + role: "worker", + project, + defaults: config.defaults, + spawnAgentOverride: spawnConfig.agent, + }); + const plugins = resolvePlugins(project, selection.agentName); if (!plugins.runtime) { throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); } - // Allow --agent override to swap the agent plugin for this session - if (spawnConfig.agent) { - const overrideAgent = registry.get("agent", spawnConfig.agent); - if (!overrideAgent) { - throw new Error(`Agent plugin '${spawnConfig.agent}' not found`); - } - plugins.agent = overrideAgent; - } - if (!plugins.agent) { - throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); + throw new Error(`Agent plugin '${selection.agentName}' not found`); } // Validate issue exists BEFORE creating any resources @@ -1010,25 +1018,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM strategy: opencodeIssueSessionStrategy, }) : undefined; - const configuredSubagent = - typeof project.agentConfig?.["subagent"] === "string" - ? project.agentConfig["subagent"] - : undefined; - const agentLaunchConfig = { sessionId, projectConfig: { ...project, agentConfig: { - ...(project.agentConfig ?? {}), + ...selection.agentConfig, ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}), }, }, issueId: spawnConfig.issueId, prompt: composedPrompt, - permissions: project.agentConfig?.permissions, - model: project.agentConfig?.model, - subagent: spawnConfig.subagent ?? configuredSubagent, + permissions: selection.permissions, + model: selection.model, + subagent: spawnConfig.subagent ?? selection.subagent, }; let handle: RuntimeHandle; @@ -1095,7 +1098,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM tmuxName, // Store tmux name for mapping issue: spawnConfig.issueId, project: spawnConfig.projectId, - agent: plugins.agent.name, // Persist agent name for lifecycle manager + agent: selection.agentName, // Persist agent name for lifecycle manager createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), opencodeSessionId: reusedOpenCodeSessionId, @@ -1178,12 +1181,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } - const plugins = resolvePlugins(project); + const selection = resolveAgentSelection({ + role: "orchestrator", + project, + defaults: config.defaults, + }); + const plugins = resolvePlugins(project, selection.agentName); if (!plugins.runtime) { throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); } if (!plugins.agent) { - throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); + throw new Error(`Agent plugin '${selection.agentName}' not found`); } const sessionId = `${project.sessionPrefix}-orchestrator`; @@ -1296,11 +1304,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM strategy: "reuse", }) : undefined; - const configuredSubagent = - typeof project.agentConfig?.["subagent"] === "string" - ? project.agentConfig["subagent"] - : undefined; - if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { await resolveOpenCodeSessionReuse({ sessionsDir, @@ -1317,14 +1320,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectConfig: { ...project, agentConfig: { - ...(project.agentConfig ?? {}), + ...selection.agentConfig, + permissions: "permissionless" as const, ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}), }, }, permissions: "permissionless" as const, - model: project.agentConfig?.orchestratorModel ?? project.agentConfig?.model, + model: selection.model, systemPromptFile, - subagent: configuredSubagent, + subagent: selection.subagent, }; const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); @@ -1370,7 +1374,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM role: "orchestrator", tmuxName, project: orchestratorConfig.projectId, - agent: plugins.agent.name, + agent: selection.agentName, createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), opencodeSessionId: reusableOpenCodeSessionId, @@ -1444,8 +1448,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt); - const selectedAgentName = raw["agent"]; - const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; + const selection = resolveSelectionForSession(project, sessionName, raw); + const effectiveAgentName = selection.agentName; const plugins = resolvePlugins(project, effectiveAgentName); const sessionListPromise = effectiveAgentName === "opencode" @@ -1507,8 +1511,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt); - const selectedAgentName = repaired.raw["agent"]; - const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; + const selection = resolveSelectionForSession(project, sessionId, repaired.raw); + const effectiveAgentName = selection.agentName; const plugins = resolvePlugins(project, effectiveAgentName); await ensureHandleAndEnrich( session, @@ -1528,7 +1532,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM async function kill(sessionId: SessionId, options?: { purgeOpenCode?: boolean }): Promise { const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); - const cleanupAgent = raw["agent"] ?? project?.agent ?? config.defaults.agent; + const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName; // Destroy runtime — prefer handle.runtimeName to find the correct plugin if (raw["runtimeHandle"]) { @@ -1710,7 +1714,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM continue; } - const cleanupAgent = archived["agent"] ?? project.agent ?? config.defaults.agent; + const cleanupAgent = resolveSelectionForSession(project, archivedId, archived).agentName; const mappedOpenCodeSessionId = asValidOpenCodeSessionId(archived["opencodeSessionId"]); if (cleanupAgent === "opencode" && archived["opencodeCleanedAt"]) { pushSkipped(projectKey, archivedId); @@ -1764,7 +1768,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } - const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + const selection = resolveSelectionForSession(project, sessionId, raw); + const selectedAgent = selection.agentName; if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) { const discovered = await discoverOpenCodeSessionIdByTitle( sessionId, @@ -1779,7 +1784,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ? safeJsonParse(raw["runtimeHandle"]) : null; const runtimeName = parsedHandle?.runtimeName ?? project.runtime ?? config.defaults.runtime; - const agentName = raw["agent"] ?? project.agent ?? config.defaults.agent; + const agentName = selectedAgent; const runtimePlugin = registry.get("runtime", runtimeName); if (!runtimePlugin) { @@ -2053,7 +2058,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`); } - const plugins = resolvePlugins(project, raw["agent"]); + const plugins = resolvePlugins( + project, + resolveSelectionForSession(project, sessionId, raw).agentName, + ); const scm = plugins.scm; if (!scm?.resolvePR || !scm.checkoutPR) { throw new Error( @@ -2140,7 +2148,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM async function remap(sessionId: SessionId, force = false): Promise { const { raw, sessionsDir, project } = requireSessionRecord(sessionId); - const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + const selection = resolveSelectionForSession(project, sessionId, raw); + const selectedAgent = selection.agentName; if (selectedAgent !== "opencode") { throw new Error(`Session ${sessionId} is not using the opencode agent`); } @@ -2197,7 +2206,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new SessionNotFoundError(sessionId); } - const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + const selection = resolveSelectionForSession(project, sessionId, raw); + const selectedAgent = selection.agentName; if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) { const discovered = await discoverOpenCodeSessionIdByTitle( sessionId, @@ -2217,7 +2227,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. const session = metadataToSession(sessionId, raw, projectId); - const plugins = resolvePlugins(project, raw["agent"]); + const plugins = resolvePlugins(project, selection.agentName); await enrichSessionWithRuntimeState(session, plugins, true); // 3. Validate restorability @@ -2253,7 +2263,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); } if (!plugins.agent) { - throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); + throw new Error(`Agent plugin '${selection.agentName}' not found`); } // 5. Check workspace @@ -2304,28 +2314,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // 7. Get launch command — try restore command first, fall back to fresh launch let launchCommand: string; - const configuredSubagent = - typeof project.agentConfig?.["subagent"] === "string" - ? project.agentConfig["subagent"] - : undefined; const agentLaunchConfig = { sessionId, projectConfig: { ...project, agentConfig: { - ...(project.agentConfig ?? {}), + ...selection.agentConfig, + ...(selection.role === "orchestrator" ? { permissions: "permissionless" as const } : {}), ...(session.metadata?.opencodeSessionId ? { opencodeSessionId: session.metadata.opencodeSessionId } : {}), }, }, issueId: session.issueId ?? undefined, - permissions: project.agentConfig?.permissions, - model: - raw["role"] === "orchestrator" - ? (project.agentConfig?.orchestratorModel ?? project.agentConfig?.model) - : project.agentConfig?.model, - subagent: configuredSubagent, + permissions: selection.role === "orchestrator" ? "permissionless" : selection.permissions, + model: selection.model, + subagent: selection.subagent, }; if (plugins.agent.getRestoreCommand) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bc245170a..12c879892 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -919,6 +919,17 @@ export interface DefaultPlugins { agent: string; workspace: string; notifiers: string[]; + orchestrator?: { + agent?: string; + }; + worker?: { + agent?: string; + }; +} + +export interface RoleAgentConfig { + agent?: string; + agentConfig?: AgentSpecificConfig; } export interface ProjectConfig { @@ -961,6 +972,10 @@ export interface ProjectConfig { /** Agent-specific configuration */ agentConfig?: AgentSpecificConfig; + orchestrator?: RoleAgentConfig; + + worker?: RoleAgentConfig; + /** Per-project reaction overrides */ reactions?: Record>;