feat: add provider runtime launcher settings for agent CLIs
This commit is contained in:
@@ -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,
|
AgentProvider,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||||
@@ -30,25 +31,45 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
|||||||
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProviderRegistry(logger: Logger): Record<AgentProvider, ProviderDefinition> {
|
type BuildProviderRegistryOptions = {
|
||||||
const claudeClient = new ClaudeAgentClient({ logger });
|
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
const codexClient = new CodexAppServerAgentClient(logger);
|
};
|
||||||
const opencodeClient = new OpenCodeAgentClient(logger);
|
|
||||||
|
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 {
|
return {
|
||||||
claude: {
|
claude: {
|
||||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "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),
|
fetchModels: (options) => claudeClient.listModels(options),
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "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),
|
fetchModels: (options) => codexClient.listModels(options),
|
||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "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),
|
fetchModels: (options) => opencodeClient.listModels(options),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -57,8 +78,11 @@ export function buildProviderRegistry(logger: Logger): Record<AgentProvider, Pro
|
|||||||
// Deprecated: Use buildProviderRegistry instead
|
// Deprecated: Use buildProviderRegistry instead
|
||||||
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
|
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
|
||||||
|
|
||||||
export function createAllClients(logger: Logger): Record<AgentProvider, AgentClient> {
|
export function createAllClients(
|
||||||
const registry = buildProviderRegistry(logger);
|
logger: Logger,
|
||||||
|
options?: BuildProviderRegistryOptions
|
||||||
|
): Record<AgentProvider, AgentClient> {
|
||||||
|
const registry = buildProviderRegistry(logger, options);
|
||||||
return {
|
return {
|
||||||
claude: registry.claude.createClient(logger),
|
claude: registry.claude.createClient(logger),
|
||||||
codex: registry.codex.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> {
|
export async function shutdownProviders(
|
||||||
await OpenCodeServerManager.getInstance(logger).shutdown();
|
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 { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { promises } from "node:fs";
|
import { promises } from "node:fs";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type PermissionResult,
|
type PermissionResult,
|
||||||
type PermissionUpdate,
|
type PermissionUpdate,
|
||||||
type Query,
|
type Query,
|
||||||
|
type SpawnOptions,
|
||||||
type SDKMessage,
|
type SDKMessage,
|
||||||
type SDKPartialAssistantMessage,
|
type SDKPartialAssistantMessage,
|
||||||
type SDKResultMessage,
|
type SDKResultMessage,
|
||||||
@@ -56,6 +57,11 @@ import type {
|
|||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
import {
|
||||||
|
applyProviderEnv,
|
||||||
|
isProviderCommandAvailable,
|
||||||
|
type ProviderRuntimeSettings,
|
||||||
|
} from "../provider-launch-config.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
|
|
||||||
const fsPromises = promises;
|
const fsPromises = promises;
|
||||||
@@ -131,15 +137,56 @@ type ClaudeOptions = Options;
|
|||||||
type ClaudeAgentClientOptions = {
|
type ClaudeAgentClientOptions = {
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClaudeAgentSessionOptions = {
|
type ClaudeAgentSessionOptions = {
|
||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
claudePath: string | null;
|
claudePath: string | null;
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
logger: Logger;
|
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 {
|
export function extractUserMessageText(content: unknown): string | null {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
@@ -351,10 +398,12 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly claudePath: string | null;
|
private readonly claudePath: string | null;
|
||||||
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
|
||||||
constructor(options: ClaudeAgentClientOptions) {
|
constructor(options: ClaudeAgentClientOptions) {
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||||
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
try {
|
try {
|
||||||
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
|
this.claudePath = execSync("which claude", { encoding: "utf8" }).trim() || null;
|
||||||
} catch {
|
} 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> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
const claudeConfig = this.assertConfig(config);
|
const claudeConfig = this.assertConfig(config);
|
||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
claudePath: this.claudePath,
|
claudePath: this.claudePath,
|
||||||
|
runtimeSettings: this.runtimeSettings,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -385,6 +462,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
claudePath: this.claudePath,
|
claudePath: this.claudePath,
|
||||||
|
runtimeSettings: this.runtimeSettings,
|
||||||
handle,
|
handle,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
@@ -399,7 +477,10 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
...(this.claudePath ? { pathToClaudeCodeExecutable: this.claudePath } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const claudeQuery = query({ prompt, options: claudeOptions });
|
const claudeQuery = query({
|
||||||
|
prompt,
|
||||||
|
options: this.applyRuntimeSettings(claudeOptions),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const models: ModelInfo[] = await claudeQuery.supportedModels();
|
const models: ModelInfo[] = await claudeQuery.supportedModels();
|
||||||
return models.map((model) => ({
|
return models.map((model) => ({
|
||||||
@@ -451,6 +532,10 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
|
const commandConfig = this.runtimeSettings?.command;
|
||||||
|
if (commandConfig?.mode === "replace") {
|
||||||
|
return isProviderCommandAvailable(commandConfig, resolveClaudeBinary);
|
||||||
|
}
|
||||||
return this.claudePath !== null;
|
return this.claudePath !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +554,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private readonly config: ClaudeAgentConfig;
|
private readonly config: ClaudeAgentConfig;
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly claudePath: string | null;
|
private readonly claudePath: string | null;
|
||||||
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private query: Query | null = null;
|
private query: Query | null = null;
|
||||||
private input: Pushable<SDKUserMessage> | null = null;
|
private input: Pushable<SDKUserMessage> | null = null;
|
||||||
@@ -508,6 +594,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.claudePath = options.claudePath;
|
this.claudePath = options.claudePath;
|
||||||
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
const handle = options.handle;
|
const handle = options.handle;
|
||||||
|
|
||||||
@@ -971,7 +1058,34 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.claudeSessionId) {
|
if (this.claudeSessionId) {
|
||||||
base.resume = 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(
|
private normalizeMcpServers(
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ import {
|
|||||||
mapCodexRolloutToolCall,
|
mapCodexRolloutToolCall,
|
||||||
mapCodexToolCallFromThreadItem,
|
mapCodexToolCallFromThreadItem,
|
||||||
} from "./codex/tool-call-mapper.js";
|
} 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;
|
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 {
|
function resolveCodexHomeDir(): string {
|
||||||
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
}
|
}
|
||||||
@@ -1750,7 +1765,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
constructor(
|
constructor(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
|
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 });
|
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
|
||||||
if (config.modeId === undefined) {
|
if (config.modeId === undefined) {
|
||||||
@@ -1773,11 +1789,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.connected) return;
|
if (this.connected) return;
|
||||||
const binaryPath = resolveCodexBinary();
|
const child = this.spawnAppServer();
|
||||||
const child = spawn(binaryPath, ["app-server"], {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
this.client = new CodexAppServerClient(child, this.logger);
|
this.client = new CodexAppServerClient(child, this.logger);
|
||||||
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
|
||||||
this.registerRequestHandlers();
|
this.registerRequestHandlers();
|
||||||
@@ -2854,11 +2866,27 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
readonly provider = CODEX_PROVIDER;
|
readonly provider = CODEX_PROVIDER;
|
||||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
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> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
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();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -2871,7 +2899,12 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
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();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -2879,10 +2912,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
async listPersistedAgents(
|
async listPersistedAgents(
|
||||||
options?: ListPersistedAgentsOptions
|
options?: ListPersistedAgentsOptions
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
const binaryPath = resolveCodexBinary();
|
const child = this.spawnAppServer();
|
||||||
const child = spawn(binaryPath, ["app-server"], {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
const client = new CodexAppServerClient(child, this.logger);
|
const client = new CodexAppServerClient(child, this.logger);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2957,10 +2987,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
const binaryPath = resolveCodexBinary();
|
const child = this.spawnAppServer();
|
||||||
const child = spawn(binaryPath, ["app-server"], {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
const client = new CodexAppServerClient(child, this.logger);
|
const client = new CodexAppServerClient(child, this.logger);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3050,11 +3077,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveCodexBinary);
|
||||||
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
|
|
||||||
return Boolean(codexPath);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import type {
|
|||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
import {
|
||||||
|
applyProviderEnv,
|
||||||
|
isProviderCommandAvailable,
|
||||||
|
resolveProviderCommandPrefix,
|
||||||
|
type ProviderRuntimeSettings,
|
||||||
|
} from "../provider-launch-config.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
|
|
||||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
@@ -63,6 +69,20 @@ type OpenCodeMcpConfig =
|
|||||||
|
|
||||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
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 {
|
function toOpenCodeMcpConfig(config: McpServerConfig): OpenCodeMcpConfig {
|
||||||
if (config.type === "stdio") {
|
if (config.type === "stdio") {
|
||||||
return {
|
return {
|
||||||
@@ -122,15 +142,31 @@ export class OpenCodeServerManager {
|
|||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
private startPromise: Promise<{ port: number; url: string }> | null = null;
|
private startPromise: Promise<{ port: number; url: string }> | null = null;
|
||||||
private readonly logger: Logger;
|
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.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) {
|
if (!OpenCodeServerManager.instance) {
|
||||||
OpenCodeServerManager.instance = new OpenCodeServerManager(logger);
|
OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
|
||||||
OpenCodeServerManager.registerExitHandler();
|
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;
|
return OpenCodeServerManager.instance;
|
||||||
}
|
}
|
||||||
@@ -174,12 +210,20 @@ export class OpenCodeServerManager {
|
|||||||
private async startServer(): Promise<{ port: number; url: string }> {
|
private async startServer(): Promise<{ port: number; url: string }> {
|
||||||
this.port = await findAvailablePort();
|
this.port = await findAvailablePort();
|
||||||
const url = `http://127.0.0.1:${this.port}`;
|
const url = `http://127.0.0.1:${this.port}`;
|
||||||
|
const launchPrefix = resolveProviderCommandPrefix(
|
||||||
|
this.runtimeSettings?.command,
|
||||||
|
resolveOpenCodeBinary
|
||||||
|
);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: { ...process.env },
|
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -242,10 +286,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
private readonly serverManager: OpenCodeServerManager;
|
private readonly serverManager: OpenCodeServerManager;
|
||||||
private readonly logger: Logger;
|
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.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> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
@@ -381,12 +430,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
return isProviderCommandAvailable(
|
||||||
const opencodePath = execSync("which opencode", { encoding: "utf8" }).trim();
|
this.runtimeSettings?.command,
|
||||||
return Boolean(opencodePath);
|
resolveOpenCodeBinary
|
||||||
} catch {
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import type {
|
|||||||
AgentClient,
|
AgentClient,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} 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 { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
|
||||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +121,7 @@ export type PaseoDaemonConfig = {
|
|||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
|
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PaseoDaemon {
|
export interface PaseoDaemon {
|
||||||
@@ -274,7 +276,9 @@ export async function createPaseoDaemon(
|
|||||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||||
const agentManager = new AgentManager({
|
const agentManager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
...createAllClients(logger),
|
...createAllClients(logger, {
|
||||||
|
runtimeSettings: config.agentProviderSettings,
|
||||||
|
}),
|
||||||
...config.agentClients,
|
...config.agentClients,
|
||||||
},
|
},
|
||||||
registry: agentStorage,
|
registry: agentStorage,
|
||||||
@@ -496,7 +500,8 @@ export async function createPaseoDaemon(
|
|||||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||||
stt: dictationSttService,
|
stt: dictationSttService,
|
||||||
localModels: localModelConfig ?? undefined,
|
localModels: localModelConfig ?? undefined,
|
||||||
}
|
},
|
||||||
|
config.agentProviderSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
@@ -590,7 +595,9 @@ export async function createPaseoDaemon(
|
|||||||
await agentManager.flush().catch(() => undefined);
|
await agentManager.flush().catch(() => undefined);
|
||||||
detachAgentStoragePersistence();
|
detachAgentStoragePersistence();
|
||||||
await agentStorage.flush().catch(() => undefined);
|
await agentStorage.flush().catch(() => undefined);
|
||||||
await shutdownProviders(logger);
|
await shutdownProviders(logger, {
|
||||||
|
runtimeSettings: config.agentProviderSettings,
|
||||||
|
});
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
cleanupSpeechRuntime();
|
cleanupSpeechRuntime();
|
||||||
await relayTransport?.stop().catch(() => undefined);
|
await relayTransport?.stop().catch(() => undefined);
|
||||||
|
|||||||
@@ -125,5 +125,6 @@ export function loadConfig(
|
|||||||
voiceLlmProvider,
|
voiceLlmProvider,
|
||||||
voiceLlmProviderExplicit,
|
voiceLlmProviderExplicit,
|
||||||
voiceLlmModel,
|
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 path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||||
|
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
|
||||||
|
|
||||||
const LogConfigSchema = z
|
const LogConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -122,6 +123,12 @@ export const PersistedConfigSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
providers: ProvidersSchema.optional(),
|
providers: ProvidersSchema.optional(),
|
||||||
|
agents: z
|
||||||
|
.object({
|
||||||
|
providers: AgentProviderRuntimeSettingsMapSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
dictation: FeatureDictationSchema.optional(),
|
dictation: FeatureDictationSchema.optional(),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type {
|
|||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||||
@@ -297,6 +298,7 @@ export type SessionOptions = {
|
|||||||
defaultModelIds: LocalSpeechModelId[];
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
function convertPCMToWavBuffer(
|
function convertPCMToWavBuffer(
|
||||||
@@ -468,6 +470,7 @@ export class Session {
|
|||||||
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
|
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||||
private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||||||
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||||||
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
private voiceModeAgentId: string | null = null;
|
private voiceModeAgentId: string | null = null;
|
||||||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
||||||
|
|
||||||
@@ -488,6 +491,7 @@ export class Session {
|
|||||||
voice,
|
voice,
|
||||||
voiceBridge,
|
voiceBridge,
|
||||||
dictation,
|
dictation,
|
||||||
|
agentProviderRuntimeSettings,
|
||||||
} = options;
|
} = options;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
@@ -515,13 +519,16 @@ export class Session {
|
|||||||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
||||||
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent;
|
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent;
|
||||||
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent;
|
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent;
|
||||||
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.sessionLogger = logger.child({
|
this.sessionLogger = logger.child({
|
||||||
module: "session",
|
module: "session",
|
||||||
clientId: this.clientId,
|
clientId: this.clientId,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
});
|
});
|
||||||
this.providerRegistry = buildProviderRegistry(this.sessionLogger);
|
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
||||||
|
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize per-session managers
|
// Initialize per-session managers
|
||||||
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
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 { isHostAllowed } from "./allowed-hosts.js";
|
||||||
import { Session } from "./session.js";
|
import { Session } from "./session.js";
|
||||||
import type { AgentProvider } from "./agent/agent-sdk-types.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 { PushTokenStore } from "./push/token-store.js";
|
||||||
import { PushService } from "./push/push-service.js";
|
import { PushService } from "./push/push-service.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
@@ -92,6 +93,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
VoiceSpeakHandler
|
VoiceSpeakHandler
|
||||||
>();
|
>();
|
||||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||||
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
@@ -117,7 +119,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
modelsDir: string;
|
modelsDir: string;
|
||||||
defaultModelIds: LocalSpeechModelId[];
|
defaultModelIds: LocalSpeechModelId[];
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.serverId = serverId;
|
this.serverId = serverId;
|
||||||
@@ -131,6 +134,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.terminalManager = terminalManager ?? null;
|
this.terminalManager = terminalManager ?? null;
|
||||||
this.voice = voice ?? null;
|
this.voice = voice ?? null;
|
||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
|
|
||||||
const pushLogger = this.logger.child({ module: "push" });
|
const pushLogger = this.logger.child({ module: "push" });
|
||||||
this.pushTokenStore = new PushTokenStore(
|
this.pushTokenStore = new PushTokenStore(
|
||||||
@@ -257,6 +261,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
|
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
|
||||||
},
|
},
|
||||||
dictation: this.dictation ?? undefined,
|
dictation: this.dictation ?? undefined,
|
||||||
|
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.set(ws, session);
|
this.sessions.set(ws, session);
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
},
|
},
|
||||||
"endpoint": {
|
"endpoint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicEndpoint": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -102,6 +105,92 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"features": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -112,10 +201,17 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"allOf": [
|
||||||
"enum": [
|
{
|
||||||
"openai",
|
"type": "string"
|
||||||
"local"
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"openai",
|
||||||
|
"local"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
@@ -156,11 +252,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
|
||||||
"enum": [
|
|
||||||
"openai",
|
|
||||||
"local"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -173,11 +265,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
|
||||||
"enum": [
|
|
||||||
"openai",
|
|
||||||
"local"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -193,6 +281,12 @@
|
|||||||
"nova",
|
"nova",
|
||||||
"shimmer"
|
"shimmer"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"speakerId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -78,6 +78,83 @@ function Configuration() {
|
|||||||
</pre>
|
</pre>
|
||||||
</section>
|
</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">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-medium">Voice</h2>
|
<h2 className="text-xl font-medium">Voice</h2>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
|||||||
Reference in New Issue
Block a user