feat: add provider runtime launcher settings for agent CLIs

This commit is contained in:
Mohamed Boudra
2026-02-11 11:45:55 +07:00
parent 0d60588431
commit 29855e07e9
14 changed files with 736 additions and 70 deletions
@@ -0,0 +1,77 @@
import { describe, expect, test, vi } from "vitest";
import {
resolveProviderCommandPrefix,
applyProviderEnv,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
describe("resolveProviderCommandPrefix", () => {
test("uses resolved default command in default mode", () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix(undefined, resolveDefault);
expect(resolveDefault).toHaveBeenCalledTimes(1);
expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] });
});
test("appends args in append mode", () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix(
{
mode: "append",
args: ["--chrome"],
},
resolveDefault
);
expect(resolveDefault).toHaveBeenCalledTimes(1);
expect(resolved).toEqual({
command: "/usr/local/bin/claude",
args: ["--chrome"],
});
});
test("replaces command in replace mode without resolving default", () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix(
{
mode: "replace",
argv: ["docker", "run", "--rm", "my-wrapper"],
},
resolveDefault
);
expect(resolveDefault).not.toHaveBeenCalled();
expect(resolved).toEqual({
command: "docker",
args: ["run", "--rm", "my-wrapper"],
});
});
});
describe("applyProviderEnv", () => {
test("merges provider env overrides", () => {
const base = {
PATH: "/usr/bin",
HOME: "/tmp",
};
const runtime: ProviderRuntimeSettings = {
env: {
HOME: "/custom/home",
FOO: "bar",
},
};
const env = applyProviderEnv(base, runtime);
expect(env).toEqual({
PATH: "/usr/bin",
HOME: "/custom/home",
FOO: "bar",
});
});
});
@@ -0,0 +1,119 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { z } from "zod";
import type { AgentProvider } from "./agent-sdk-types.js";
import { AgentProviderSchema } from "./provider-manifest.js";
const ProviderCommandDefaultSchema = z
.object({
mode: z.literal("default"),
})
.strict();
const ProviderCommandAppendSchema = z
.object({
mode: z.literal("append"),
args: z.array(z.string()).optional(),
})
.strict();
const ProviderCommandReplaceSchema = z
.object({
mode: z.literal("replace"),
argv: z.array(z.string().min(1)).min(1),
})
.strict();
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
ProviderCommandDefaultSchema,
ProviderCommandAppendSchema,
ProviderCommandReplaceSchema,
]);
export const ProviderRuntimeSettingsSchema = z
.object({
command: ProviderCommandSchema.optional(),
env: z.record(z.string()).optional(),
})
.strict();
export const AgentProviderRuntimeSettingsMapSchema = z.record(
AgentProviderSchema,
ProviderRuntimeSettingsSchema
);
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
export type ProviderRuntimeSettings = z.infer<typeof ProviderRuntimeSettingsSchema>;
export type AgentProviderRuntimeSettingsMap = Partial<
Record<AgentProvider, ProviderRuntimeSettings>
>;
export type ProviderCommandPrefix = {
command: string;
args: string[];
};
export function resolveProviderCommandPrefix(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string
): ProviderCommandPrefix {
if (!commandConfig || commandConfig.mode === "default") {
return {
command: resolveDefaultCommand(),
args: [],
};
}
if (commandConfig.mode === "append") {
return {
command: resolveDefaultCommand(),
args: [...(commandConfig.args ?? [])],
};
}
return {
command: commandConfig.argv[0]!,
args: commandConfig.argv.slice(1),
};
}
export function applyProviderEnv(
baseEnv: Record<string, string | undefined>,
runtimeSettings?: ProviderRuntimeSettings
): Record<string, string | undefined> {
return {
...baseEnv,
...(runtimeSettings?.env ?? {}),
};
}
export function isCommandAvailable(command: string): boolean {
const normalized = command.trim();
if (!normalized) {
return false;
}
if (normalized.includes("/") || normalized.includes("\\")) {
return existsSync(normalized);
}
try {
const output = execFileSync("which", [normalized], { encoding: "utf8" }).trim();
return output.length > 0;
} catch {
return false;
}
}
export function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string
): boolean {
try {
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
return isCommandAvailable(prefix.command);
} catch {
return false;
}
}
@@ -4,6 +4,7 @@ import type {
AgentProvider,
ListModelsOptions,
} from "./agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
import type { Logger } from "pino";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
@@ -30,25 +31,45 @@ export interface ProviderDefinition extends AgentProviderDefinition {
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
}
export function buildProviderRegistry(logger: Logger): Record<AgentProvider, ProviderDefinition> {
const claudeClient = new ClaudeAgentClient({ logger });
const codexClient = new CodexAppServerAgentClient(logger);
const opencodeClient = new OpenCodeAgentClient(logger);
type BuildProviderRegistryOptions = {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
};
export function buildProviderRegistry(
logger: Logger,
options?: BuildProviderRegistryOptions
): Record<AgentProvider, ProviderDefinition> {
const runtimeSettings = options?.runtimeSettings;
const claudeClient = new ClaudeAgentClient({
logger,
runtimeSettings: runtimeSettings?.claude,
});
const codexClient = new CodexAppServerAgentClient(
logger,
runtimeSettings?.codex
);
const opencodeClient = new OpenCodeAgentClient(
logger,
runtimeSettings?.opencode
);
return {
claude: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!,
createClient: (logger: Logger) => new ClaudeAgentClient({ logger }),
createClient: (logger: Logger) =>
new ClaudeAgentClient({ logger, runtimeSettings: runtimeSettings?.claude }),
fetchModels: (options) => claudeClient.listModels(options),
},
codex: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
createClient: (logger: Logger) => new CodexAppServerAgentClient(logger),
createClient: (logger: Logger) =>
new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
fetchModels: (options) => codexClient.listModels(options),
},
opencode: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
createClient: (logger: Logger) => new OpenCodeAgentClient(logger),
createClient: (logger: Logger) =>
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
fetchModels: (options) => opencodeClient.listModels(options),
},
};
@@ -57,8 +78,11 @@ export function buildProviderRegistry(logger: Logger): Record<AgentProvider, Pro
// Deprecated: Use buildProviderRegistry instead
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
export function createAllClients(logger: Logger): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger);
export function createAllClients(
logger: Logger,
options?: BuildProviderRegistryOptions
): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger, options);
return {
claude: registry.claude.createClient(logger),
codex: registry.codex.createClient(logger),
@@ -66,6 +90,12 @@ export function createAllClients(logger: Logger): Record<AgentProvider, AgentCli
};
}
export async function shutdownProviders(logger: Logger): Promise<void> {
await OpenCodeServerManager.getInstance(logger).shutdown();
export async function shutdownProviders(
logger: Logger,
options?: BuildProviderRegistryOptions
): Promise<void> {
await OpenCodeServerManager.getInstance(
logger,
options?.runtimeSettings?.opencode
).shutdown();
}
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
@@ -15,6 +15,7 @@ import {
type PermissionResult,
type PermissionUpdate,
type Query,
type SpawnOptions,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKResultMessage,
@@ -56,6 +57,11 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
isProviderCommandAvailable,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
@@ -131,15 +137,56 @@ type ClaudeOptions = Options;
type ClaudeAgentClientOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
type ClaudeAgentSessionOptions = {
defaults?: { agents?: Record<string, AgentDefinition> };
claudePath: string | null;
runtimeSettings?: ProviderRuntimeSettings;
handle?: AgentPersistenceHandle;
logger: Logger;
};
function resolveClaudeBinary(): string {
try {
const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
if (claudePath) {
return claudePath;
}
} catch {
// fall through
}
throw new Error(
"Claude CLI not found. Install claude or configure agents.providers.claude.command.mode='replace'."
);
}
function resolveClaudeSpawnCommand(
spawnOptions: SpawnOptions,
runtimeSettings?: ProviderRuntimeSettings
): { command: string; args: string[] } {
const commandConfig = runtimeSettings?.command;
if (!commandConfig || commandConfig.mode === "default") {
return {
command: spawnOptions.command,
args: [...spawnOptions.args],
};
}
if (commandConfig.mode === "append") {
return {
command: spawnOptions.command,
args: [...(commandConfig.args ?? []), ...spawnOptions.args],
};
}
return {
command: commandConfig.argv[0]!,
args: [...commandConfig.argv.slice(1), ...spawnOptions.args],
};
}
export function extractUserMessageText(content: unknown): string | null {
if (typeof content === "string") {
@@ -351,10 +398,12 @@ export class ClaudeAgentClient implements AgentClient {
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly logger: Logger;
private readonly claudePath: string | null;
private readonly runtimeSettings?: ProviderRuntimeSettings;
constructor(options: ClaudeAgentClientOptions) {
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
try {
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
} catch {
@@ -362,11 +411,39 @@ export class ClaudeAgentClient implements AgentClient {
}
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0;
const commandMode = this.runtimeSettings?.command?.mode;
const needsCustomSpawn =
hasEnvOverrides || commandMode === "append" || commandMode === "replace";
if (!needsCustomSpawn) {
return options;
}
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(
spawnOptions,
this.runtimeSettings
);
return spawn(resolved.command, resolved.args, {
cwd: spawnOptions.cwd,
env: applyProviderEnv(spawnOptions.env, this.runtimeSettings),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});
},
};
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const claudeConfig = this.assertConfig(config);
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
claudePath: this.claudePath,
runtimeSettings: this.runtimeSettings,
logger: this.logger,
});
}
@@ -385,6 +462,7 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
claudePath: this.claudePath,
runtimeSettings: this.runtimeSettings,
handle,
logger: this.logger,
});
@@ -399,7 +477,10 @@ export class ClaudeAgentClient implements AgentClient {
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
};
const claudeQuery = query({ prompt, options: claudeOptions });
const claudeQuery = query({
prompt,
options: this.applyRuntimeSettings(claudeOptions),
});
try {
const models: ModelInfo[] = await claudeQuery.supportedModels();
return models.map((model) => ({
@@ -451,6 +532,10 @@ export class ClaudeAgentClient implements AgentClient {
}
async isAvailable(): Promise<boolean> {
const commandConfig = this.runtimeSettings?.command;
if (commandConfig?.mode === "replace") {
return isProviderCommandAvailable(commandConfig, resolveClaudeBinary);
}
return this.claudePath !== null;
}
@@ -469,6 +554,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly config: ClaudeAgentConfig;
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
private readonly claudePath: string | null;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly logger: Logger;
private query: Query | null = null;
private input: Pushable<SDKUserMessage> | null = null;
@@ -508,6 +594,7 @@ class ClaudeAgentSession implements AgentSession {
this.config = config;
this.defaults = options.defaults;
this.claudePath = options.claudePath;
this.runtimeSettings = options.runtimeSettings;
this.logger = options.logger;
const handle = options.handle;
@@ -971,7 +1058,34 @@ class ClaudeAgentSession implements AgentSession {
if (this.claudeSessionId) {
base.resume = this.claudeSessionId;
}
return base;
return this.applyRuntimeSettings(base);
}
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
const hasEnvOverrides = Object.keys(this.runtimeSettings?.env ?? {}).length > 0;
const commandMode = this.runtimeSettings?.command?.mode;
const needsCustomSpawn =
hasEnvOverrides || commandMode === "append" || commandMode === "replace";
if (!needsCustomSpawn) {
return options;
}
return {
...options,
spawnClaudeCodeProcess: (spawnOptions) => {
const resolved = resolveClaudeSpawnCommand(
spawnOptions,
this.runtimeSettings
);
return spawn(resolved.command, resolved.args, {
cwd: spawnOptions.cwd,
env: applyProviderEnv(spawnOptions.env, this.runtimeSettings),
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
});
},
};
}
private normalizeMcpServers(
@@ -39,6 +39,12 @@ import {
mapCodexRolloutToolCall,
mapCodexToolCallFromThreadItem,
} from "./codex/tool-call-mapper.js";
import {
applyProviderEnv,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -158,6 +164,15 @@ function resolveCodexBinary(): string {
);
}
function resolveCodexLaunchPrefix(
runtimeSettings?: ProviderRuntimeSettings
): { command: string; args: string[] } {
return resolveProviderCommandPrefix(
runtimeSettings?.command,
resolveCodexBinary
);
}
function resolveCodexHomeDir(): string {
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
}
@@ -1750,7 +1765,8 @@ class CodexAppServerAgentSession implements AgentSession {
constructor(
config: AgentSessionConfig,
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
logger: Logger
logger: Logger,
private readonly spawnAppServer: () => ChildProcessWithoutNullStreams
) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
if (config.modeId === undefined) {
@@ -1773,11 +1789,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> {
if (this.connected) return;
const binaryPath = resolveCodexBinary();
const child = spawn(binaryPath, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
});
const child = this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger);
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -2854,11 +2866,27 @@ export class CodexAppServerAgentClient implements AgentClient {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
constructor(private readonly logger: Logger) {}
constructor(
private readonly logger: Logger,
private readonly runtimeSettings?: ProviderRuntimeSettings
) {}
private spawnAppServer(): ChildProcessWithoutNullStreams {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
stdio: ["pipe", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
});
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger);
const session = new CodexAppServerAgentSession(
sessionConfig,
null,
this.logger,
() => this.spawnAppServer()
);
await session.connect();
return session;
}
@@ -2871,7 +2899,12 @@ export class CodexAppServerAgentClient implements AgentClient {
provider: CODEX_PROVIDER,
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
};
const session = new CodexAppServerAgentSession(merged, handle, this.logger);
const session = new CodexAppServerAgentSession(
merged,
handle,
this.logger,
() => this.spawnAppServer()
);
await session.connect();
return session;
}
@@ -2879,10 +2912,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async listPersistedAgents(
options?: ListPersistedAgentsOptions
): Promise<PersistedAgentDescriptor[]> {
const binaryPath = resolveCodexBinary();
const child = spawn(binaryPath, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
});
const child = this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
@@ -2957,10 +2987,7 @@ export class CodexAppServerAgentClient implements AgentClient {
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const binaryPath = resolveCodexBinary();
const child = spawn(binaryPath, ["app-server"], {
stdio: ["pipe", "pipe", "pipe"],
});
const child = this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
@@ -3050,11 +3077,6 @@ export class CodexAppServerAgentClient implements AgentClient {
}
async isAvailable(): Promise<boolean> {
try {
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
return Boolean(codexPath);
} catch {
return false;
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveCodexBinary);
}
}
@@ -26,6 +26,12 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
@@ -63,6 +69,20 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
function resolveOpenCodeBinary(): string {
try {
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
if (opencodePath) {
return opencodePath;
}
} catch {
// fall through
}
throw new Error(
"OpenCode CLI not found. Please install opencode globally so Paseo can launch the provider."
);
}
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
if (config.type === "stdio") {
return {
@@ -122,15 +142,31 @@ export class OpenCodeServerManager {
private port: number | null = null;
private startPromise: Promise<{ port: number; url: string }> | null = null;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string;
private constructor(logger: Logger) {
private constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger;
this.runtimeSettings = runtimeSettings;
this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
}
static getInstance(logger: Logger): OpenCodeServerManager {
static getInstance(
logger: Logger,
runtimeSettings?: ProviderRuntimeSettings
): OpenCodeServerManager {
const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager(logger);
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
OpenCodeServerManager.registerExitHandler();
} else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
logger.warn(
{
existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
requestedRuntimeSettings: nextSettingsKey,
},
"OpenCode server manager already initialized with different runtime settings"
);
}
return OpenCodeServerManager.instance;
}
@@ -174,12 +210,20 @@ export class OpenCodeServerManager {
private async startServer(): Promise<{ port: number; url: string }> {
this.port = await findAvailablePort();
const url = `http://127.0.0.1:${this.port}`;
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary
);
return new Promise((resolve, reject) => {
this.server = spawn("opencode", ["serve", "--port", String(this.port)], {
this.server = spawn(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)],
{
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
env: applyProviderEnv(process.env, this.runtimeSettings),
}
);
let started = false;
const timeout = setTimeout(() => {
@@ -242,10 +286,15 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly serverManager: OpenCodeServerManager;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
constructor(logger: Logger) {
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.serverManager = OpenCodeServerManager.getInstance(this.logger);
this.runtimeSettings = runtimeSettings;
this.serverManager = OpenCodeServerManager.getInstance(
this.logger,
runtimeSettings
);
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
@@ -381,12 +430,10 @@ export class OpenCodeAgentClient implements AgentClient {
}
async isAvailable(): Promise<boolean> {
try {
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
return Boolean(opencodePath);
} catch {
return false;
}
return isProviderCommandAvailable(
this.runtimeSettings?.command,
resolveOpenCodeBinary
);
}
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
+10 -3
View File
@@ -64,6 +64,7 @@ import type {
AgentClient,
AgentProvider,
} from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
@@ -120,6 +121,7 @@ export type PaseoDaemonConfig = {
voiceLlmModel?: string | null;
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
};
export interface PaseoDaemon {
@@ -274,7 +276,9 @@ export async function createPaseoDaemon(
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger),
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
}),
...config.agentClients,
},
registry: agentStorage,
@@ -496,7 +500,8 @@ export async function createPaseoDaemon(
finalTimeoutMs: config.dictationFinalTimeoutMs,
stt: dictationSttService,
localModels: localModelConfig ?? undefined,
}
},
config.agentProviderSettings
);
const start = async () => {
@@ -590,7 +595,9 @@ export async function createPaseoDaemon(
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
});
terminalManager.killAll();
cleanupSpeechRuntime();
await relayTransport?.stop().catch(() => undefined);
+1
View File
@@ -125,5 +125,6 @@ export function loadConfig(
voiceLlmProvider,
voiceLlmProviderExplicit,
voiceLlmModel,
agentProviderSettings: persisted.agents?.providers,
};
}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "./persisted-config.js";
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("accepts provider command append args and env", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: {
mode: "append",
args: ["--chrome"],
},
env: {
FOO: "bar",
},
},
},
},
});
expect(parsed.agents?.providers?.claude?.command?.mode).toBe("append");
expect(parsed.agents?.providers?.claude?.env?.FOO).toBe("bar");
});
test("accepts provider command replace argv", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
codex: {
command: {
mode: "replace",
argv: ["docker", "run", "--rm", "my-codex-wrapper"],
},
},
},
},
});
expect(parsed.agents?.providers?.codex?.command?.mode).toBe("replace");
});
test("rejects replace command without argv", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
opencode: {
command: {
mode: "replace",
},
},
},
},
});
expect(result.success).toBe(false);
});
});
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
const LogConfigSchema = z
.object({
@@ -122,6 +123,12 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
agents: z
.object({
providers: AgentProviderRuntimeSettingsMapSchema.optional(),
})
.strict()
.optional(),
features: z
.object({
dictation: FeatureDictationSchema.optional(),
+8 -1
View File
@@ -48,6 +48,7 @@ import type {
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
@@ -297,6 +298,7 @@ export type SessionOptions = {
defaultModelIds: LocalSpeechModelId[];
};
};
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
};
function convertPCMToWavBuffer(
@@ -468,6 +470,7 @@ export class Session {
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
@@ -488,6 +491,7 @@ export class Session {
voice,
voiceBridge,
dictation,
agentProviderRuntimeSettings,
} = options;
this.clientId = clientId;
this.sessionId = uuidv4();
@@ -515,13 +519,16 @@ export class Session {
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent;
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.abortController = new AbortController();
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.providerRegistry = buildProviderRegistry(this.sessionLogger);
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
runtimeSettings: this.agentProviderRuntimeSettings,
});
// Initialize per-session managers
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
@@ -17,6 +17,7 @@ import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { Session } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -92,6 +93,7 @@ export class VoiceAssistantWebSocketServer {
VoiceSpeakHandler
>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
constructor(
server: HTTPServer,
@@ -117,7 +119,8 @@ export class VoiceAssistantWebSocketServer {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
};
}
},
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -131,6 +134,7 @@ export class VoiceAssistantWebSocketServer {
this.terminalManager = terminalManager ?? null;
this.voice = voice ?? null;
this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(
@@ -257,6 +261,7 @@ export class VoiceAssistantWebSocketServer {
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
},
dictation: this.dictation ?? undefined,
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
});
this.sessions.set(ws, session);
@@ -57,6 +57,9 @@
},
"endpoint": {
"type": "string"
},
"publicEndpoint": {
"type": "string"
}
},
"additionalProperties": false
@@ -102,6 +105,92 @@
},
"additionalProperties": false
},
"agents": {
"type": "object",
"properties": {
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "default"
}
},
"required": [
"mode"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "append"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"mode"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "replace"
},
"argv": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
}
},
"required": [
"mode",
"argv"
],
"additionalProperties": false
}
]
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"additionalProperties": false
},
"propertyNames": {
"enum": [
"claude",
"codex",
"opencode"
]
}
}
},
"additionalProperties": false
},
"features": {
"type": "object",
"properties": {
@@ -112,10 +201,17 @@
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"openai",
"local"
"allOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [
"openai",
"local"
]
}
]
},
"model": {
@@ -156,11 +252,7 @@
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"openai",
"local"
]
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
},
"model": {
"type": "string",
@@ -173,11 +265,7 @@
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"openai",
"local"
]
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
},
"model": {
"type": "string",
@@ -193,6 +281,12 @@
"nova",
"shimmer"
]
},
"speakerId": {
"type": "integer"
},
"speed": {
"type": "number"
}
},
"additionalProperties": false
@@ -78,6 +78,83 @@ function Configuration() {
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Agent provider runtime settings</h2>
<p className="text-white/60 leading-relaxed">
Use <code className="font-mono">agents.providers</code> to customize how Paseo launches agent provider CLIs.
This works for <code className="font-mono">claude</code>, <code className="font-mono">codex</code>, and
<code className="font-mono"> opencode</code>.
</p>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">command.mode</code> can be <code className="font-mono">default</code>,{' '}
<code className="font-mono">append</code>, or <code className="font-mono">replace</code>. Use{' '}
<code className="font-mono">env</code> to inject provider-specific environment variables.
</p>
<h3 className="text-lg font-medium">Enable Claude Code Chrome MCP</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "append",
"args": ["--chrome"]
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">Point Claude to Anthropic-compatible endpoints (z.ai example)</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "auth token",
"ANTHROPIC_API_KEY": ""
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">Run Claude through Docker</h3>
<p className="text-white/60 leading-relaxed">
Create a wrapper script that runs Claude in Docker, then tell Paseo to replace the Claude launch command
with that script.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "replace",
"argv": ["/Users/you/bin/claude-docker"]
}
}
}
}
}`}
</pre>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`#!/usr/bin/env bash
set -euo pipefail
docker run --rm -i \\
-v "$PWD":"$PWD" \\
-w "$PWD" \\
-v "$HOME/.claude":"$HOME/.claude" \\
ghcr.io/anthropics/claude-code:latest \\
claude "$@"`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Voice</h2>
<p className="text-white/60 leading-relaxed">