feat: support distinct worker and orchestrator agents (#439)

* feat: add role-specific agent resolution

* test: cover role-specific agent precedence

* docs: show role-specific agent config

* fix: preserve shared agent config fallbacks

* fix: avoid role-config permission clobbering

* fix: keep orchestrator launches permissionless

* fix: remove stale lifecycle imports
This commit is contained in:
Harsh Batheja 2026-03-12 20:58:55 +05:30 committed by GitHub
parent 0a41b7dcdb
commit 3deea32bda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 707 additions and 63 deletions

View File

@ -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.

View File

@ -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", () => {

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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,

View File

@ -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<string, string>,
): 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,
};
}

View File

@ -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({

View File

@ -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>("agent", agentName);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;

View File

@ -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>("runtime", runtimeName);

View File

@ -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>("runtime", project.runtime ?? config.defaults.runtime);
const agent = registry.get<Agent>(
"agent",
agentOverride ?? project.agent ?? config.defaults.agent,
);
const agent = registry.get<Agent>("agent", agentName ?? project.agent ?? config.defaults.agent);
const workspace = registry.get<Workspace>(
"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<string, string>,
) {
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>("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<void> {
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<RuntimeHandle>(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>("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<string> {
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) {

View File

@ -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<string, Partial<ReactionConfig>>;