diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index f5405857c..2ad3648e3 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -14,6 +14,7 @@ import { type HostProfile, } from "@/types/host-connection"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; import { shouldUseDesktopDaemon, @@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { suppressSendErrors: true, clientId, clientType: "mobile" as const, + appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, }; if (connection.type === "directSocket" || connection.type === "directPipe") { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index dadff3900..9230d5fa1 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -155,6 +155,7 @@ export type DaemonClientConfig = { url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp"; + appVersion?: string; runtimeGeneration?: number | null; authHeader?: string; suppressSendErrors?: boolean; @@ -3285,6 +3286,7 @@ export class DaemonClient { clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, + ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), ); } catch (error) { diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 78eb15e60..34bdfd86b 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -176,12 +176,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin return definition; } -export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [ - string, - ...string[], -]; +export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id); -export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS); +export const AgentProviderSchema = z.string(); export function isValidAgentProvider(value: string): boolean { return AGENT_PROVIDER_IDS.includes(value); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f80a4ece8..7302fb472 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,7 +1,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 LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -82,7 +82,7 @@ const FeatureVoiceModeSchema = z enabled: z.boolean().optional(), llm: z .object({ - provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(), + provider: z.string().optional(), model: z.string().min(1).optional(), }) .strict() diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cc2096383..eebfbecd1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -190,6 +190,28 @@ const execFileAsync = promisify(execFile); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; + +// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. +// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject +// the entire session message if they encounter an unknown provider. +const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); +const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; + +function clientSupportsAllProviders(appVersion: string | null): boolean { + if (!appVersion) return false; + // Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" + const base = appVersion.replace(/-.*$/, ""); + const parts = base.split(".").map(Number); + const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number); + for (let i = 0; i < minParts.length; i++) { + const a = parts[i] ?? 0; + const b = minParts[i] ?? 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; @@ -355,6 +377,7 @@ type VoiceTranscriptionResultPayload = { export type SessionOptions = { clientId: string; + appVersion: string | null; onMessage: (msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; getBinaryBufferedAmount?: () => number; @@ -506,6 +529,7 @@ function toAgentPersistenceHandle( */ export class Session { private readonly clientId: string; + private readonly appVersion: string | null; private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; @@ -601,6 +625,7 @@ export class Session { constructor(options: SessionOptions) { const { clientId, + appVersion, onMessage, onBinaryMessage, getBinaryBufferedAmount, @@ -627,6 +652,7 @@ export class Session { agentProviderRuntimeSettings, } = options; this.clientId = clientId; + this.appVersion = appVersion; this.sessionId = uuidv4(); this.onMessage = onMessage; this.onBinaryMessage = onBinaryMessage ?? null; @@ -1130,6 +1156,12 @@ export class Session { } } + // TODO: Remove once all app store clients are on >=0.1.45. + private isProviderVisibleToClient(provider: string): boolean { + if (clientSupportsAllProviders(this.appVersion)) return true; + return LEGACY_PROVIDER_IDS.has(provider); + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -1198,6 +1230,11 @@ export class Session { subscription: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload, ): void { + // TODO: Remove once all app store clients are on >=0.1.45. + if (payload.kind === "upsert" && !this.isProviderVisibleToClient(payload.agent.provider)) { + return; + } + if (subscription.isBootstrapping) { subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload); return; @@ -3179,7 +3216,11 @@ export class Session { ): Promise { const fetchedAt = new Date().toISOString(); try { - const providers = await this.agentManager.listProviderAvailability(); + let providers = await this.agentManager.listProviderAvailability(); + + // TODO: Remove once all app store clients are on >=0.1.45. + providers = providers.filter((p) => this.isProviderVisibleToClient(p.provider)); + this.emit({ type: "list_available_providers_response", payload: { @@ -5691,6 +5732,12 @@ export class Session { } const payload = await this.listFetchAgentsEntries(request); + + // TODO: Remove once all app store clients are on >=0.1.45. + payload.entries = payload.entries.filter((entry) => + this.isProviderVisibleToClient(entry.agent.provider), + ); + const snapshotUpdatedAtByAgentId = new Map(); for (const entry of payload.entries) { const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ab9d0f787..0b9fdcf66 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -176,6 +176,7 @@ type WebSocketLike = { type SessionConnection = { session: Session; clientId: string; + appVersion: string | null; connectionLogger: pino.Logger; sockets: Set; externalDisconnectCleanupTimeout: ReturnType | null; @@ -595,13 +596,15 @@ export class VoiceAssistantWebSocketServer { private createSessionConnection(params: { ws: WebSocketLike; clientId: string; + appVersion: string | null; connectionLogger: pino.Logger; }): SessionConnection { - const { ws, clientId, connectionLogger } = params; + const { ws, clientId, appVersion, connectionLogger } = params; let connection: SessionConnection | null = null; const session = new Session({ clientId, + appVersion, onMessage: (msg) => { if (!connection) { return; @@ -677,6 +680,7 @@ export class VoiceAssistantWebSocketServer { connection = { session, clientId, + appVersion, connectionLogger, sockets: new Set([ws]), externalDisconnectCleanupTimeout: null, @@ -760,6 +764,7 @@ export class VoiceAssistantWebSocketServer { const connection = this.createSessionConnection({ ws, clientId, + appVersion: message.appVersion ?? null, connectionLogger, }); this.sessions.set(ws, connection); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 0773bfbc7..5dcd0b00a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -2653,6 +2653,7 @@ export const WSHelloMessageSchema = z.object({ clientId: z.string().min(1), clientType: z.enum(["mobile", "browser", "cli", "mcp"]), protocolVersion: z.number().int(), + appVersion: z.string().optional(), capabilities: z .object({ voice: z.boolean().optional(),