Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/components/icons/opencode-icon.tsx
#	packages/app/src/components/provider-icons.ts
#	packages/app/src/panels/agent-panel.tsx
#	packages/cli/src/commands/provider/ls.ts
#	packages/server/src/server/agent/provider-manifest.ts
#	packages/server/src/server/agent/provider-registry.ts
#	packages/server/src/server/agent/providers/claude-agent.test.ts
#	packages/server/src/server/agent/providers/claude-agent.ts
#	packages/server/src/server/persistence-hooks.ts
#	packages/server/src/server/session.ts
This commit is contained in:
Mohamed Boudra
2026-04-03 10:10:20 +07:00
65 changed files with 4510 additions and 970 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.43-rc.1",
"@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/server": "0.1.43-rc.1",
"@getpaseo/expo-two-way-audio": "0.1.43",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/server": "0.1.43",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+165 -121
View File
@@ -8,7 +8,12 @@ import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildFavoriteModelKey,
mergeProviderPreferences,
toggleFavoriteModel,
useFormPreferences,
} from "@/hooks/use-form-preferences";
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,6 +30,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
AGENT_PROVIDER_DEFINITIONS,
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
@@ -42,6 +48,10 @@ type StatusOption = {
type StatusSelector = "provider" | "mode" | "model" | "thinking";
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
);
type ControlledAgentStatusBarProps = {
provider: string;
providerOptions?: StatusOption[];
@@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = {
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions?: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
};
export interface DraftAgentStatusBarProps {
@@ -142,6 +157,11 @@ function ControlledStatusBar({
onSelectThinkingOption,
disabled = false,
isModelLoading = false,
providerDefinitions,
allProviderModels,
canSelectModelProvider,
favoriteKeys = new Set<string>(),
onToggleFavoriteModel,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
@@ -205,6 +225,26 @@ function ControlledStatusBar({
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions],
);
const fallbackAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (!modelOptions || modelOptions.length === 0) {
return map;
}
map.set(
provider,
modelOptions.map((option) => ({
provider: provider as AgentProvider,
id: option.id,
label: option.label,
})),
);
return map;
}, [modelOptions, provider]);
const effectiveProviderDefinitions = providerDefinitions ??
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -289,49 +329,36 @@ function ControlledStatusBar({
) : null}
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
@@ -454,73 +481,38 @@ function ControlledStatusBar({
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[
styles.sheetSelect,
modelDisabled && styles.disabledSheetSelect,
]}
pointerEvents="none"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
/>
</View>
) : null}
@@ -650,6 +642,35 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
},
});
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
const displayMode =
@@ -674,6 +695,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
@@ -693,6 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
onSelectMode={(modeId) => {
if (!client) {
return;
@@ -722,6 +749,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[AgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
@@ -775,6 +808,7 @@ export function DraftAgentStatusBar({
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
const { preferences, updatePreferences } = useFormPreferences();
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -789,6 +823,10 @@ export function DraftAgentStatusBar({
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const favoriteKeys = useMemo(
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
[preferences.favoriteModels],
);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
@@ -803,6 +841,12 @@ export function DraftAgentStatusBar({
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
favoriteKeys={favoriteKeys}
onToggleFavorite={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
isLoading={isAllModelsLoading}
disabled={disabled}
/>
@@ -820,29 +864,29 @@ export function DraftAgentStatusBar({
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
const modelOptions: StatusOption[] = models.map((model) => ({
id: model.id,
label: model.label,
}));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
});
}}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
describe("combined model selector helpers", () => {
const providerDefinitions = [
{
id: "claude",
label: "Claude",
description: "Claude provider",
defaultModeId: "default",
modes: [],
},
{
id: "codex",
label: "Codex",
description: "Codex provider",
defaultModeId: "auto",
modes: [],
},
];
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet-4.6",
label: "Sonnet 4.6",
},
];
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
];
it("keeps enough data to search by model and provider name", async () => {
const rows = buildModelRows(providerDefinitions, new Map([
["claude", claudeModels],
["codex", codexModels],
]));
expect(rows).toEqual([
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
]);
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
});
});
@@ -1,22 +1,39 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Search,
Star,
} from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import {
buildModelRows,
buildSelectedTriggerLabel,
matchesSearch,
resolveProviderLabel,
type SelectorModelRow,
} from "./combined-model-selector.utils";
const INLINE_MODEL_THRESHOLD = 8;
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
type DrillDownView = { provider: string };
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
@@ -25,9 +42,408 @@ interface CombinedModelSelectorProps {
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
canSelectProvider?: (provider: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
}) => React.ReactNode;
disabled?: boolean;
}
interface SelectorContentProps {
view: SelectorView;
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
onBack?: () => void;
}
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function partitionRows(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
const favoriteRows: SelectorModelRow[] = [];
const regularRows: SelectorModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favoriteRows.push(row);
continue;
}
regularRows.push(row);
}
return { favoriteRows, regularRows };
}
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
const grouped = new Map<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
for (const row of rows) {
const existing = grouped.get(row.provider);
if (existing) {
existing.rows.push(row);
continue;
}
grouped.set(row.provider, {
providerId: row.provider,
providerLabel: row.providerLabel,
rows: [row],
});
}
return Array.from(grouped.values());
}
function ModelRow({
row,
isSelected,
isFavorite,
disabled = false,
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider);
const isWeb = Platform.OS === "web";
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onToggleFavorite?.(row.provider, row.modelId);
},
[onToggleFavorite, row.modelId, row.provider],
);
const item = (
<ComboboxItem
label={row.modelLabel}
selected={isSelected}
disabled={disabled}
onPress={onPress}
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => (
<Star
size={16}
color={
isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable>
) : null
}
/>
);
if (!isWeb || !row.description) {
return item;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>{item}</View>
</TooltipTrigger>
<TooltipContent side="right" align="center" offset={4}>
<Text style={styles.tooltipText}>{row.description}</Text>
</TooltipContent>
</Tooltip>
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
if (favoriteRows.length === 0) {
return null;
}
return (
<View>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
{favoriteRows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
<View style={styles.separator} />
</View>
);
}
function GroupedProviderRows({
providerDefinitions,
groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
return (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function SelectorContent({
view,
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
onBack,
}: SelectorContentProps) {
const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions],
);
const scopedRows = useMemo(() => {
if (view.kind === "provider") {
return allRows.filter((row) => row.provider === view.providerId);
}
return allRows;
}, [allRows, view]);
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo(
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
[normalizedQuery, scopedRows],
);
const { favoriteRows, regularRows } = useMemo(
() => partitionRows(visibleRows, favoriteKeys),
[favoriteKeys, visibleRows],
);
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
return (
<View>
{view.kind === "provider" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
) : null}
<SearchInput
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
/>
) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
function ProviderBackButton({
providerId,
providerLabel,
onBack,
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (!onBack) {
return null;
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
@@ -35,48 +451,80 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
canSelectProvider = () => true,
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [isContentReady, setIsContentReady] = useState(isWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setView({ kind: "all" });
if (!open) {
setSearchQuery("");
}
},
[allProviderModels, selectedProvider],
[],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setView({ kind: "all" });
setSearchQuery("");
},
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedProviderLabel = useMemo(
() => resolveProviderLabel(providerDefinitions, selectedProvider),
[providerDefinitions, selectedProvider],
);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Select model";
const model = models.find((m) => m.id === selectedModel);
if (!models) {
return isLoading ? "Loading..." : "Select model";
}
const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
return;
}
if (!isOpen) {
setIsContentReady(false);
return;
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
return (
<>
@@ -90,14 +538,26 @@ export function CombinedModelSelector({
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
]}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
disabled,
isOpen,
})
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
</Pressable>
<Combobox
options={[]}
@@ -105,184 +565,46 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
{view === "groups" ? (
<GroupsView
{isContentReady ? (
<SelectorContent
view={view}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider });
setSearchQuery("");
}}
/>
) : (
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
onBack={() => {
setView("groups");
setSearchQuery("");
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
</View>
)}
</Combobox>
</>
);
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
function DrillDownModelView({
provider,
providerDefinitions,
models,
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSelect,
onBack,
}: {
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
@@ -307,6 +629,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
height: "auto",
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
@@ -374,9 +701,37 @@ const styles = StyleSheet.create((theme) => ({
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
favoriteButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
favoriteButtonHovered: {
backgroundColor: theme.colors.surface2,
},
favoriteButtonPressed: {
backgroundColor: theme.colors.surface1,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
sheetLoadingState: {
minHeight: 160,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[2],
},
sheetLoadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,50 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
export type SelectorModelRow = FavoriteModelRow;
export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
return modelLabel;
}
export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): SelectorModelRow[] {
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
for (const model of allProviderModels.get(definition.id) ?? []) {
rows.push({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
});
}
}
return rows;
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) {
return true;
}
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
}
@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface CopilotIconProps {
size?: number;
color?: string;
}
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
<Path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="nonzero"
/>
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</Svg>
);
}
@@ -8,7 +8,7 @@ interface OpenCodeIconProps {
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
return (
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
<Path
fillRule="evenodd"
clipRule="evenodd"
@@ -4,14 +4,17 @@ import { AmpIcon } from "@/components/icons/amp-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { GeminiIcon } from "@/components/icons/gemini-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
"claude-acp": ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
gemini: GeminiIcon as unknown as typeof Bot,
amp: AmpIcon as unknown as typeof Bot,
aider: AiderIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
};
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
CircleAlert,
ChevronDown,
ChevronRight,
Copy,
@@ -73,7 +74,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -105,6 +106,10 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
@@ -243,6 +248,14 @@ function WorkspaceStatusIndicator({
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "checkout"
? Monitor
@@ -252,6 +265,13 @@ function WorkspaceStatusIndicator({
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
@@ -263,6 +283,10 @@ function WorkspaceStatusIndicator({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -332,11 +356,26 @@ function ProjectLeadingVisual({
);
}
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.projectLeadingVisualSlot}>
@@ -348,6 +387,10 @@ function ProjectLeadingVisual({
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -2212,10 +2255,10 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: 7,
height: 7,
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
+23 -3
View File
@@ -65,6 +65,7 @@ export interface ComboboxProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -145,8 +146,10 @@ export interface ComboboxItemProps {
description?: string;
kind?: "directory" | "file";
leadingSlot?: ReactNode;
trailingSlot?: ReactNode;
selected?: boolean;
active?: boolean;
disabled?: boolean;
onPress: () => void;
testID?: string;
}
@@ -156,8 +159,10 @@ export function ComboboxItem({
description,
kind,
leadingSlot,
trailingSlot,
selected,
active,
disabled,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
@@ -178,12 +183,14 @@ export function ComboboxItem({
return (
<Pressable
testID={testID}
disabled={disabled}
onPress={onPress}
style={({ pressed, hovered = false }) => [
styles.comboboxItem,
hovered && styles.comboboxItemHovered,
pressed && styles.comboboxItemPressed,
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
]}
>
{leadingContent}
@@ -197,9 +204,12 @@ export function ComboboxItem({
</Text>
) : null}
</View>
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot}
</View>
) : null}
</Pressable>
@@ -233,6 +243,7 @@ export function Combobox({
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
@@ -642,6 +653,7 @@ export function Combobox({
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -777,10 +789,18 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemActive: {
backgroundColor: theme.colors.surface1,
},
comboboxItemDisabled: {
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemTrailingContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: "auto",
},
comboboxItemContent: {
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
import {
buildFavoriteModelKey,
isFavoriteModel,
mergeProviderPreferences,
toggleFavoriteModel,
} from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
@@ -55,3 +60,92 @@ describe("mergeProviderPreferences", () => {
});
});
});
describe("favorite model preferences", () => {
it("builds a stable favorite key from provider and model", () => {
expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe(
"claude:sonnet-4.6",
);
});
it("adds a model to favorites without dropping other preferences", () => {
expect(
toggleFavoriteModel({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
},
},
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
},
},
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
});
});
it("removes a model from favorites when toggled again", () => {
expect(
toggleFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toEqual({
favoriteModels: [],
});
});
it("reports whether a model is favorited", () => {
expect(
isFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "codex",
modelId: "gpt-5.4",
}),
).toBe(true);
expect(
isFavoriteModel({
preferences: {
favoriteModels: [
{
provider: "codex",
modelId: "gpt-5.4",
},
],
},
provider: "claude",
modelId: "sonnet-4.6",
}),
).toBe(false);
});
});
@@ -7,6 +7,20 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
export interface FavoriteModelPreference {
provider: string;
modelId: string;
}
export interface FavoriteModelRow {
favoriteKey: string;
provider: string;
providerLabel: string;
modelId: string;
modelLabel: string;
description?: string;
}
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
@@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
favoriteModels: z.array(
z.object({
provider: z.string(),
modelId: z.string(),
}),
).optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
@@ -66,6 +86,41 @@ export function mergeProviderPreferences(args: {
};
}
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
return `${input.provider}:${input.modelId}`;
}
export function isFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): boolean {
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
return (args.preferences.favoriteModels ?? []).some(
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
);
}
export function toggleFavoriteModel(args: {
preferences: FormPreferences;
provider: string;
modelId: string;
}): FormPreferences {
const favorite = { provider: args.provider, modelId: args.modelId };
const favoriteKey = buildFavoriteModelKey(favorite);
const existingFavorites = args.preferences.favoriteModels ?? [];
const hasFavorite = existingFavorites.some(
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
);
return {
...args.preferences,
favoriteModels: hasFavorite
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
: [...existingFavorites, favorite],
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
+5 -7
View File
@@ -49,16 +49,14 @@ import {
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
}
if (provider === "codex") {
return "Codex";
}
if (!provider) {
return "Agent";
}
return provider.charAt(0).toUpperCase() + provider.slice(1);
return provider
.split(/[-_\s]+/)
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
@@ -8,7 +8,7 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
export interface WorkspaceTabPresentation {
@@ -21,6 +21,11 @@ export interface WorkspaceTabPresentation {
statusBucket: SidebarStateBucket | null;
}
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = -2;
const EMPHASIZED_STATUS_DOT_OFFSET = -3;
type WorkspaceTabPresentationResolverProps = {
tab: WorkspaceTabDescriptor;
serverId: string;
@@ -114,6 +119,13 @@ export function WorkspaceTabIcon({
bucket: presentation.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
const shouldShowLoader = shouldRenderSyncedStatusLoader({
bucket: presentation.statusBucket,
});
@@ -137,6 +149,10 @@ export function WorkspaceTabIcon({
{
backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
width: statusDotSize,
height: statusDotSize,
right: statusDotOffset,
bottom: statusDotOffset,
},
]}
/>
@@ -199,10 +215,10 @@ const styles = StyleSheet.create((theme) => ({
},
statusDot: {
position: "absolute",
right: -2,
bottom: -2,
width: 7,
height: 7,
right: DEFAULT_STATUS_DOT_OFFSET,
bottom: DEFAULT_STATUS_DOT_OFFSET,
width: DEFAULT_STATUS_DOT_SIZE,
height: DEFAULT_STATUS_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
@@ -72,6 +72,11 @@ declare global {
}
}
const isMac =
typeof navigator !== "undefined" &&
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as any).platform ?? ""));
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
@@ -280,6 +285,10 @@ export class TerminalEmulatorRuntime {
return true;
}
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
@@ -25,3 +25,9 @@ export function getStatusDotColor(input: {
}
return null;
}
export function isEmphasizedStatusDotBucket(
bucket: SidebarStateBucket | null | undefined,
): boolean {
return bucket === "needs_input" || bucket === "attention";
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.43-rc.1",
"@getpaseo/server": "0.1.43-rc.1",
"@getpaseo/relay": "0.1.43",
"@getpaseo/server": "0.1.43",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+2 -2
View File
@@ -411,7 +411,7 @@ export async function runRunCommand(
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
provider: resolvedProviderModel.provider,
cwd,
title: options.name,
modeId: options.mode,
@@ -510,7 +510,7 @@ export async function runRunCommand(
// Create the agent
const agent = await client.createAgent({
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
provider: resolvedProviderModel.provider,
cwd,
title: options.name,
modeId: options.mode,
+2 -2
View File
@@ -18,9 +18,9 @@ export interface LoopRunRow {
}
export interface LoopRunOptions extends CommandOptions {
provider?: "claude" | "codex" | "opencode";
provider?: string;
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyProvider?: string;
verifyModel?: string;
verify?: string;
verifyCheck?: string[];
+6 -6
View File
@@ -45,11 +45,11 @@ export interface LoopRecord {
name: string | null;
prompt: string;
cwd: string;
provider: "claude" | "codex" | "opencode";
provider: string;
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerProvider: string | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierProvider: string | null;
verifierModel: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
@@ -122,11 +122,11 @@ export interface LoopStopPayload {
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: "claude" | "codex" | "opencode";
provider?: string;
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerProvider?: string;
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierProvider?: string;
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];
+8 -39
View File
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
/** Provider list item for display */
export interface ProviderListItem {
@@ -9,45 +10,13 @@ export interface ProviderListItem {
modes: string;
}
/** Static provider data - providers are built-in and don't require daemon */
const PROVIDERS: ProviderListItem[] = [
{
provider: "claude",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
{
provider: "codex",
status: "available",
defaultMode: "auto",
modes: "read-only, auto, full-access",
},
{
provider: "gemini",
status: "available",
defaultMode: "-",
modes: "-",
},
{
provider: "amp",
status: "available",
defaultMode: "-",
modes: "-",
},
{
provider: "aider",
status: "available",
defaultMode: "-",
modes: "-",
},
{
provider: "opencode",
status: "available",
defaultMode: "build",
modes: "build, plan",
},
];
/** Derive provider list from the manifest — single source of truth */
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
status: "available",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
+1 -1
View File
@@ -22,7 +22,7 @@ export type ScheduleTarget =
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
provider: string;
cwd: string;
modeId?: string;
model?: string;
+1 -1
View File
@@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = [];
const ctx = await createE2ETestContext({ timeout: 120000 });
async function runProviderModelsJson(
provider: "claude" | "codex" | "opencode",
provider: string,
): Promise<ProviderModel[]> {
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];
@@ -50,7 +50,7 @@ async function cleanup(): Promise<void> {
}
async function runProviderCase(input: {
provider: "claude" | "codex" | "opencode";
provider: string;
mode: string;
model: string;
}): Promise<void> {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.43-rc.1",
"@getpaseo/server": "0.1.43-rc.1",
"@getpaseo/cli": "0.1.43",
"@getpaseo/server": "0.1.43",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
+6 -15
View File
@@ -17,7 +17,11 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import {
createNodeEntrypointInvocation,
resolveDaemonRunnerEntrypoint,
runCliJsonCommand,
} from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -405,20 +409,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
if (!status.listen) {
throw new Error("Daemon listen target is unavailable.");
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
}
const response = await fetch(`${baseUrl}/pairing`);
if (!response.ok) {
throw new Error(`Daemon pairing request failed with ${response.status}`);
}
const payload = (await response.json()) as unknown;
const payload = runCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
+39 -9
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -219,23 +219,22 @@ export function createNodeEntrypointInvocation(input: {
});
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
}
export function runCliPassthroughCommand(args: string[]): number {
const result = spawnCliProcess(args);
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
@@ -246,3 +245,34 @@ export function runCliPassthroughCommand(args: string[]): number {
return result.signal ? 1 : 0;
}
export function runCliJsonCommand(args: string[]): unknown {
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
}
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
if (stdout.length === 0) {
throw new Error("CLI command did not produce JSON output.");
}
try {
return JSON.parse(stdout) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -61,11 +61,12 @@
"test:e2e:ui": "vitest --ui e2e.test.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.43-rc.1",
"@getpaseo/relay": "0.1.43-rc.1",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/relay": "0.1.43",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -11,6 +11,9 @@ export interface AgentModeVisuals {
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
// TODO: `modes` should not be static. Providers (especially ACP) report their
// own modes at runtime via session/new. We should fetch modes from the provider
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
export interface AgentProviderDefinition {
id: string;
label: string;
@@ -80,6 +83,30 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
},
];
const COPILOT_MODES: AgentProviderModeDefinition[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
icon: "ShieldCheck",
colorTier: "planning",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{
id: "build",
@@ -110,6 +137,18 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "haiku",
},
},
{
id: "claude-acp",
label: "Claude ACP",
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
@@ -143,6 +182,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: null,
modes: [],
},
{
id: "copilot",
label: "Copilot",
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
modes: COPILOT_MODES,
},
{
id: "opencode",
label: "OpenCode",
@@ -7,11 +7,13 @@ import type {
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
import type { Logger } from "pino";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import {
@@ -33,55 +35,61 @@ type BuildProviderRegistryOptions = {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
};
type ProviderClientFactory = (
logger: Logger,
runtimeSettings?: AgentProviderRuntimeSettingsMap,
) => AgentClient;
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
claude: (logger, runtimeSettings) =>
new ClaudeAgentClient({
logger,
runtimeSettings: runtimeSettings?.claude,
}),
"claude-acp": (logger, runtimeSettings) =>
new ClaudeACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["claude-acp"],
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp),
aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings?.aider),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
const factory = PROVIDER_CLIENT_FACTORIES[provider];
if (!factory) {
throw new Error(`No provider client factory registered for '${provider}'`);
}
return factory;
}
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 geminiClient = new GeminiAgentClient(runtimeSettings?.gemini);
const ampClient = new AmpAgentClient(runtimeSettings?.amp);
const aiderClient = new AiderAgentClient(runtimeSettings?.aider);
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
return {
claude: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!,
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, runtimeSettings?.codex),
fetchModels: (options) => codexClient.listModels(options),
},
gemini: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "gemini")!,
createClient: () => new GeminiAgentClient(runtimeSettings?.gemini),
fetchModels: (options) => geminiClient.listModels(options),
},
amp: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "amp")!,
createClient: () => new AmpAgentClient(runtimeSettings?.amp),
fetchModels: (options) => ampClient.listModels(options),
},
aider: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "aider")!,
createClient: () => new AiderAgentClient(runtimeSettings?.aider),
fetchModels: (options) => aiderClient.listModels(options),
},
opencode: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
fetchModels: (options) => opencodeClient.listModels(options),
},
};
return Object.fromEntries(
AGENT_PROVIDER_DEFINITIONS.map((definition) => {
const createClient = getProviderClientFactory(definition.id);
const modelClient = createClient(logger, runtimeSettings);
return [
definition.id,
{
...definition,
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
} satisfies ProviderDefinition,
];
}),
) as Record<AgentProvider, ProviderDefinition>;
}
// Deprecated: Use buildProviderRegistry instead
@@ -92,14 +100,9 @@ export function createAllClients(
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger, options);
return {
claude: registry.claude.createClient(logger),
codex: registry.codex.createClient(logger),
gemini: registry.gemini.createClient(logger),
amp: registry.amp.createClient(logger),
aider: registry.aider.createClient(logger),
opencode: registry.opencode.createClient(logger),
};
return Object.fromEntries(
Object.entries(registry).map(([provider, definition]) => [provider, definition.createClient(logger)]),
) as Record<AgentProvider, AgentClient>;
}
export async function shutdownProviders(
@@ -0,0 +1,301 @@
import { describe, expect, test, vi } from "vitest";
import {
ACPAgentSession,
deriveModelDefinitionsFromACP,
deriveModesFromACP,
mapACPUsage,
} from "./acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function createSession(): ACPAgentSession {
return new ACPAgentSession(
{
provider: "claude-acp",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["claude", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
}
describe("mapACPUsage", () => {
test("maps ACP usage fields into Paseo usage", () => {
expect(
mapACPUsage({
inputTokens: 11,
outputTokens: 7,
totalTokens: 18,
cachedReadTokens: 5,
}),
).toEqual({
inputTokens: 11,
outputTokens: 7,
cachedInputTokens: 5,
});
});
});
describe("deriveModesFromACP", () => {
test("prefers explicit ACP mode state", () => {
const result = deriveModesFromACP(
[{ id: "fallback", label: "Fallback" }],
{
availableModes: [
{ id: "default", name: "Always Ask", description: "Prompt before tools" },
{ id: "plan", name: "Plan", description: "Read only" },
],
currentModeId: "plan",
},
[],
);
expect(result).toEqual({
modes: [
{ id: "default", label: "Always Ask", description: "Prompt before tools" },
{ id: "plan", label: "Plan", description: "Read only" },
],
currentModeId: "plan",
});
});
test("falls back to config options when explicit mode state is absent", () => {
const result = deriveModesFromACP(
[{ id: "fallback", label: "Fallback" }],
null,
[
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "acceptEdits",
options: [
{ value: "default", name: "Always Ask" },
{ value: "acceptEdits", name: "Accept File Edits" },
],
},
],
);
expect(result).toEqual({
modes: [
{ id: "default", label: "Always Ask", description: undefined },
{ id: "acceptEdits", label: "Accept File Edits", description: undefined },
],
currentModeId: "acceptEdits",
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP("claude-acp", {
availableModels: [
{ modelId: "haiku", name: "Haiku", description: "Fast" },
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
],
currentModelId: "haiku",
}, [
{
id: "reasoning",
name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]);
expect(result).toEqual([
{
provider: "claude-acp",
id: "haiku",
label: "Haiku",
description: "Fast",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
],
defaultThinkingOptionId: "medium",
},
{
provider: "claude-acp",
id: "sonnet",
label: "Sonnet",
description: "Balanced",
isDefault: false,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
],
defaultThinkingOptionId: "medium",
},
]);
});
});
describe("ACPAgentSession", () => {
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
(session as any).sessionId = "session-1";
session.subscribe((event) => {
events.push(event as { type: string; item?: { type: string; text?: string } });
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-1",
content: { type: "text", text: "Hey!" },
} as any,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-1",
content: { type: "text", text: " How are you?" },
} as any,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "thought-1",
content: { type: "text", text: "Thinking" },
} as any,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "thought-1",
content: { type: "text", text: " more" },
} as any,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: { type: "text", text: "hel" },
} as any,
});
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: { type: "text", text: "lo" },
} as any,
});
const timeline = events
.filter((event) => event.type === "timeline")
.map((event) => event.item)
.filter(Boolean);
expect(timeline).toEqual([
{ type: "assistant_message", text: "Hey!" },
{ type: "assistant_message", text: " How are you?" },
{ type: "reasoning", text: "Thinking" },
{ type: "reasoning", text: " more" },
{ type: "user_message", text: "hel", messageId: "user-1" },
{ type: "user_message", text: "hello", messageId: "user-1" },
]);
});
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
const session = createSession();
const events: Array<{ type: string; turnId?: string }> = [];
let resolvePrompt!: (value: any) => void;
const prompt = vi.fn(
() =>
new Promise((resolve) => {
resolvePrompt = resolve;
}),
);
(session as any).sessionId = "session-1";
(session as any).connection = { prompt };
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string });
});
const { turnId } = await session.startTurn("hello");
expect(prompt).toHaveBeenCalledOnce();
expect(events.find((event) => event.type === "turn_started")).toMatchObject({
type: "turn_started",
turnId,
});
expect((session as any).activeForegroundTurnId).toBe(turnId);
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
await Promise.resolve();
await Promise.resolve();
expect(events.find((event) => event.type === "turn_completed")).toMatchObject({
type: "turn_completed",
turnId,
});
expect((session as any).activeForegroundTurnId).toBeNull();
});
test("startTurn converts background prompt rejections into turn_failed events", async () => {
const session = createSession();
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
let rejectPrompt!: (error: Error) => void;
const prompt = vi.fn(
() =>
new Promise((_, reject) => {
rejectPrompt = reject;
}),
);
(session as any).sessionId = "session-1";
(session as any).connection = { prompt };
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string; error?: string });
});
const { turnId } = await session.startTurn("hello");
rejectPrompt(new Error("prompt failed"));
await Promise.resolve();
await Promise.resolve();
const turnFailedEvent = events.find((event) => event.type === "turn_failed");
expect(turnFailedEvent).toMatchObject({
type: "turn_failed",
turnId,
error: "prompt failed",
});
expect((session as any).activeForegroundTurnId).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const CLAUDE_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const CLAUDE_ACP_MODES: AgentMode[] = [
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
},
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
},
];
type ClaudeACPAgentClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class ClaudeACPAgentClient extends ACPAgentClient {
constructor(options: ClaudeACPAgentClientOptions) {
super({
provider: "claude-acp",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
defaultModes: CLAUDE_ACP_MODES,
capabilities: CLAUDE_ACP_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
if (!(await super.isAvailable())) {
return false;
}
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
}
}
@@ -1,5 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
@@ -242,167 +241,23 @@ describe("convertClaudeHistoryEntry", () => {
describe("ClaudeAgentClient.listModels", () => {
const logger = createTestLogger();
function createSupportedModelsQueryMock(models: ModelInfo[]) {
return {
supportedModels: vi.fn(async () => models),
return: vi.fn(async () => ({ done: true, value: undefined })),
};
}
test("returns models with required fields", async () => {
test("returns hardcoded claude models", async () => {
const client = new ClaudeAgentClient({ logger });
const models = await client.listModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
expect(models.map((m) => m.id)).toEqual([
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]);
for (const model of models) {
expect(model.provider).toBe("claude");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
expect(typeof model.label).toBe("string");
expect(model.label.length).toBeGreaterThan(0);
}
const modelIds = models.map((model) => model.id);
expect(
modelIds.some(
(id) =>
id.includes("claude") ||
id.includes("sonnet") ||
id.includes("opus") ||
id.includes("haiku"),
),
).toBe(true);
}, 60_000);
test("prefers provider-discovered Claude defaults and effort levels", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "opus",
displayName: "Opus",
description: "Opus 4.6 · Most capable for complex work",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "haiku",
displayName: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers",
},
] satisfies ModelInfo[]);
const queryFactory = vi.fn(() => queryMock);
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
expect(queryMock.return).toHaveBeenCalledTimes(1);
expect(models).toEqual([
expect.objectContaining({
id: "claude-sonnet-4-6",
isDefault: true,
label: "Sonnet 4.6",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
}),
expect.objectContaining({
id: "claude-opus-4-6",
label: "Opus 4.6",
}),
expect.objectContaining({
id: "claude-haiku-4-5",
label: "Haiku 4.5",
}),
]);
});
test("preserves SDK ids even when descriptions are weak", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Recommended model",
},
] satisfies ModelInfo[]);
const client = new ClaudeAgentClient({
logger,
queryFactory: vi.fn(() => queryMock) as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toEqual([
expect.objectContaining({
id: "default",
label: "Default (recommended)",
description: "Recommended model",
}),
]);
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[]);
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
let promptClosedBeforeModelsResolved = false;
queryMock.supportedModels = vi.fn(async () => {
promptNextPromise = promptIterator?.next() ?? null;
if (!promptNextPromise) {
throw new Error("Prompt iterator not captured");
}
promptNextPromise.then(() => {
promptClosedBeforeModelsResolved = true;
});
await Promise.resolve();
expect(promptClosedBeforeModelsResolved).toBe(false);
return [
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[];
});
const queryFactory = vi.fn(({ prompt }) => {
promptIterator = prompt[Symbol.asyncIterator]();
return queryMock;
});
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toHaveLength(1);
expect(promptNextPromise).not.toBeNull();
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
expect(queryMock.return).toHaveBeenCalledTimes(1);
const defaultModel = models.find((m) => m.isDefault);
expect(defaultModel?.id).toBe("claude-opus-4-6");
});
});
@@ -9,7 +9,7 @@ import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type ModelInfo,
type Options,
type PermissionMode,
type PermissionResult,
@@ -34,9 +34,9 @@ import {
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
import {
normalizeClaudeModelIdFromText,
resolveClaudeModelsFromSdkModels,
} from "./claude/sdk-model-resolver.js";
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
@@ -1046,35 +1046,8 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const input = createAsyncMessageInput<SDKUserMessage>();
const claudeQuery = this.queryFactory({
prompt: input.iterable,
options: applyRuntimeSettingsToClaudeOptions(
{
cwd: options?.cwd ?? process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: CLAUDE_SETTING_SOURCES,
},
this.runtimeSettings,
),
});
try {
const supportedModels = await claudeQuery.supportedModels();
return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]);
} catch (error) {
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
throw error;
} finally {
input.end();
try {
await claudeQuery.return?.();
} catch {
// ignore control-plane shutdown errors
}
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return getClaudeModels();
}
async listPersistedAgents(
@@ -2695,7 +2668,7 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = message.permissionMode;
this.persistence = null;
if (message.model) {
const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model);
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
this.logger.debug(
{ runtimeModel: message.model, normalizedRuntimeModel },
"Captured runtime model from SDK init",
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-4-6[1m]",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]);
});
it("marks exactly one model as default", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0]!.id).toBe("claude-opus-4-6");
});
it("returns fresh copies each call", () => {
const a = getClaudeModels();
const b = getClaudeModels();
expect(a).not.toBe(b);
expect(a[0]).not.toBe(b[0]);
});
});
describe("normalizeClaudeRuntimeModelId", () => {
it("returns exact match for known model IDs", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
});
it("normalizes dated model IDs to base model", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
});
it("preserves [1m] suffix from runtime model strings", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
});
it("returns null for empty/null/undefined", () => {
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();
expect(normalizeClaudeRuntimeModelId("")).toBeNull();
expect(normalizeClaudeRuntimeModelId(" ")).toBeNull();
});
it("returns null for unrecognized strings", () => {
expect(normalizeClaudeRuntimeModelId("gpt-5")).toBeNull();
expect(normalizeClaudeRuntimeModelId("random")).toBeNull();
});
});
@@ -0,0 +1,74 @@
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
const CLAUDE_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [
{
provider: "claude",
id: "claude-opus-4-6[1m]",
label: "Opus 4.6 1M",
description: "Opus 4.6 with 1M context window",
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6",
label: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
isDefault: true,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-haiku-4-5",
label: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
},
];
export function getClaudeModels(): AgentModelDefinition[] {
return CLAUDE_MODELS.map((model) => ({ ...model }));
}
/**
* Normalize a runtime model string (from SDK init message) to a known model ID.
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
*/
export function normalizeClaudeRuntimeModelId(
value: string | null | undefined,
): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
}
// Check for exact match first (handles claude-opus-4-6[1m] directly)
if (CLAUDE_MODELS.some((model) => model.id === trimmed)) {
return trimmed;
}
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
const runtimeMatch = trimmed.match(
/(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
return null;
}
const family = runtimeMatch[1]!.toLowerCase();
const major = runtimeMatch[2]!;
const minor = runtimeMatch[3]!;
const suffix = runtimeMatch[4] ?? "";
return `claude-${family}-${major}-${minor}${suffix}`;
}
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
parseClaudeSdkModelDescriptorForTest,
resolveClaudeModelsFromSdkModels,
} from "./sdk-model-resolver.js";
describe("resolveClaudeModelsFromSdkModels", () => {
const sdkModels: ModelInfo[] = [
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "opus",
displayName: "Opus",
description: "Opus 4.6 · Most capable for complex work",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
supportsFastMode: true,
},
{
value: "sonnet",
displayName: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
supportsAdaptiveThinking: true,
},
{
value: "haiku",
displayName: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers",
},
];
it("parses family and version from SDK descriptions", () => {
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({
family: "sonnet",
version: "4.6",
});
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({
family: "opus",
version: "4.6",
});
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({
family: "sonnet",
version: "4.6",
});
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({
family: "haiku",
version: "4.5",
});
});
it("maps SDK models to parsed Claude model ids", () => {
const models = resolveClaudeModelsFromSdkModels(sdkModels);
expect(models).toEqual([
expect.objectContaining({
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
}),
expect.objectContaining({
provider: "claude",
id: "claude-opus-4-6",
label: "Opus 4.6",
}),
expect.objectContaining({
provider: "claude",
id: "claude-haiku-4-5",
label: "Haiku 4.5",
}),
]);
});
});
@@ -1,145 +0,0 @@
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
type ParsedClaudeSdkModelDescriptor = {
family: "opus" | "sonnet" | "haiku";
version: string;
};
// Claude may advertise effort levels that are not usable for all account types.
const DISABLED_CLAUDE_THINKING_EFFORT_LEVELS: readonly string[] = ["max"];
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function normalizeClaudeVersionId(version: string): string {
return version.replace(/\./g, "-");
}
function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string {
return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`;
}
function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null {
const description = normalizeWhitespace(model.description ?? "");
if (!description) {
return null;
}
const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i);
if (!match) {
return null;
}
const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
const version = match[2]!;
return { family, version };
}
export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null {
const normalized = normalizeWhitespace(value ?? "");
if (!normalized) {
return null;
}
const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i);
if (!runtimeMatch) {
return null;
}
const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
const version = runtimeMatch[2]!.replace(/-/g, ".");
return buildClaudeModelId({ family, version });
}
function buildModelLabel(model: ModelInfo): string {
const parsed = parseClaudeSdkDescriptor(model);
if (!parsed) {
return normalizeWhitespace(model.displayName || model.value);
}
return `${titleCase(parsed.family)} ${parsed.version}`;
}
function buildThinkingOptions(model: ModelInfo): {
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
} {
const effortLevels = (model.supportedEffortLevels ?? []).filter(
(level) => !DISABLED_CLAUDE_THINKING_EFFORT_LEVELS.includes(level),
);
if (!model.supportsEffort || effortLevels.length === 0) {
return {};
}
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
id: level,
label: titleCase(level),
}));
return {
thinkingOptions,
};
}
export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] {
const resolved = new Map<string, AgentModelDefinition>();
for (const model of models) {
const thinking = buildThinkingOptions(model);
const parsed = parseClaudeSdkDescriptor(model);
const id = parsed ? buildClaudeModelId(parsed) : model.value;
const existing = resolved.get(id);
resolved.set(id, {
provider: "claude",
id,
label: buildModelLabel(model),
description: normalizeWhitespace(model.description ?? model.displayName ?? model.value),
isDefault:
existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined,
...(thinking.thinkingOptions || existing?.thinkingOptions
? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions }
: {}),
...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId
? {
defaultThinkingOptionId:
thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId,
}
: {}),
metadata: {
sdkValues: Array.from(
new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]),
),
sdkDisplayNames: Array.from(
new Set([
...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []),
model.displayName,
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
),
sdkDescriptions: Array.from(
new Set([
...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []),
model.description,
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
),
supportsEffort: model.supportsEffort === true,
supportedEffortLevels: model.supportedEffortLevels,
supportsAdaptiveThinking: model.supportsAdaptiveThinking === true,
supportsFastMode: model.supportsFastMode === true,
},
});
}
return Array.from(resolved.values());
}
export function parseClaudeSdkModelDescriptorForTest(
model: ModelInfo,
): ParsedClaudeSdkModelDescriptor | null {
return parseClaudeSdkDescriptor(model);
}
@@ -0,0 +1,54 @@
import type { Logger } from "pino";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { ACPAgentClient } from "./acp-agent.js";
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
const COPILOT_MODES: AgentMode[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
},
];
type CopilotACPAgentClientOptions = {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
};
export class CopilotACPAgentClient extends ACPAgentClient {
constructor(options: CopilotACPAgentClientOptions) {
super({
provider: "copilot",
logger: options.logger,
runtimeSettings: options.runtimeSettings,
defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES,
capabilities: COPILOT_CAPABILITIES,
});
}
override async isAvailable(): Promise<boolean> {
return super.isAvailable();
}
}
-22
View File
@@ -111,7 +111,6 @@ import { ScheduleService } from "./schedule/service.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
@@ -318,27 +317,6 @@ export async function createPaseoDaemon(
});
});
app.get("/pairing", async (_req, res) => {
try {
const offer = await generateLocalPairingOffer({
paseoHome: config.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
logger,
});
res.json(offer);
} catch (error) {
logger.error({ err: error }, "Failed to generate pairing offer");
res.status(500).json({
relayEnabled: false,
url: null,
qr: null,
});
}
});
app.get("/api/files/download", async (req, res) => {
const token =
typeof req.query.token === "string" && req.query.token.trim().length > 0
+1 -1
View File
@@ -65,7 +65,7 @@ export function loadConfig(
options?.cli?.allowedHosts,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
@@ -14,7 +14,7 @@ const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
export interface AgentTestConfig {
provider: "claude" | "codex" | "opencode";
provider: string;
model: string;
thinkingOptionId?: string;
modes: {
@@ -32,6 +32,14 @@ export const agentConfigs = {
ask: "default",
},
},
"claude-acp": {
provider: "claude-acp",
model: "haiku",
modes: {
full: "bypassPermissions",
ask: "default",
},
},
codex: {
provider: "codex",
model: "gpt-5.1-codex-mini",
@@ -41,6 +49,14 @@ export const agentConfigs = {
ask: "auto",
},
},
copilot: {
provider: "copilot",
model: "claude-haiku-4.5",
modes: {
full: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
ask: "https://agentclientprotocol.com/protocol/session-modes#agent",
},
},
opencode: {
provider: "opencode",
model: "opencode/glm-5-free",
@@ -96,11 +112,15 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
isCommandAvailable("claude") &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "claude-acp":
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
case "codex":
return (
isCommandAvailable("codex") &&
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
);
case "copilot":
return isCommandAvailable("copilot");
case "opencode":
return isCommandAvailable("opencode");
}
@@ -109,4 +129,10 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode"];
export const allProviders: AgentProvider[] = [
"claude",
"claude-acp",
"codex",
"copilot",
"opencode",
];
@@ -0,0 +1,156 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ClaudeACPAgentClient } from "../agent/providers/claude-acp-agent.js";
import type { SessionOutboundMessage } from "../messages.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getAskModeConfig, getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-acp-"));
}
describe("daemon E2E (real claude-acp)", () => {
test.runIf(isProviderAvailable("claude-acp"))(
"smoke test in full-access mode",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-acp-real-smoke" },
});
const agent = await client.createAgent({
cwd,
title: "claude-acp-real-smoke",
...getFullAccessConfig("claude-acp"),
});
await client.sendMessage(
agent.id,
"Reply with exactly: PINEAPPLE",
);
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("claude-acp");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(assistantText).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
test.runIf(isProviderAvailable("claude-acp"))(
"permission flow in ask mode",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const messages: SessionOutboundMessage[] = [];
const targetFile = path.join(cwd, "permission-target.txt");
try {
writeFileSync(targetFile, "ACP_PERMISSION_CONTENT\n", "utf8");
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-acp-real-permission" },
});
const unsubscribe = client.subscribeRawMessages((message) => {
messages.push(message);
});
try {
const agent = await client.createAgent({
cwd,
title: "claude-acp-real-permission",
...getAskModeConfig("claude-acp"),
});
await client.sendMessage(
agent.id,
[
`Use the Bash tool to run exactly: cat ${JSON.stringify(targetFile)}.`,
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: ACP_PERMISSION_DONE",
].join(" "),
);
const permissionState = await client.waitForFinish(agent.id, 30_000);
expect(permissionState.status).toBe("permission");
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
const permission = permissionState.final!.pendingPermissions[0]!;
await client.respondToPermission(agent.id, permission.id, {
behavior: "allow",
});
const finalState = await client.waitForFinish(agent.id, 60_000);
expect(finalState.status).toBe("idle");
const hasPermissionResolved = messages.some((message) => {
if (message.type !== "agent_stream") {
return false;
}
if (message.payload.agentId !== agent.id) {
return false;
}
return (
message.payload.event.type === "permission_resolved" &&
message.payload.event.requestId === permission.id &&
message.payload.event.resolution.behavior === "allow"
);
});
expect(hasPermissionResolved).toBe(true);
} finally {
unsubscribe();
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
});
+6
View File
@@ -37,6 +37,12 @@ export {
quoteWindowsCommand,
} from "./agent/provider-launch-config.js";
// Provider manifest (source of truth for provider definitions)
export {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "./agent/provider-manifest.js";
// Agent SDK types for CLI commands
export type {
AgentMode,
@@ -96,4 +96,25 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
});
@@ -210,11 +210,25 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
const requestedPath = path.resolve(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
return requestedPath;
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
throw new Error("Access outside of workspace is not allowed");
}
throw new Error("Access outside of workspace is not allowed");
const realRoot = await fs.realpath(normalizedRoot);
try {
const realPath = await fs.realpath(requestedPath);
const realRelative = path.relative(realRoot, realPath);
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
throw new Error("Access outside of workspace is not allowed");
}
return requestedPath;
} catch (error) {
if (isMissingEntryError(error)) {
return requestedPath;
}
throw error;
}
}
async function buildEntryPayload({
+3 -3
View File
@@ -71,11 +71,11 @@ const LoopRecordSchema = z.object({
name: z.string().nullable(),
prompt: z.string(),
cwd: z.string(),
provider: z.enum(["claude", "codex", "opencode"]),
provider: z.string(),
model: z.string().nullable(),
workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
workerProvider: z.string().nullable(),
workerModel: z.string().nullable(),
verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
verifierProvider: z.string().nullable(),
verifierModel: z.string().nullable(),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
@@ -1,9 +1,44 @@
import type pino from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
error(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
*/
export function attachAgentStoragePersistence(
logger: LoggerLike,
agentManager: AgentManagerStateSource,
storage: AgentStoragePersistence,
): () => void {
const log = getLogger(logger);
const unsubscribe = agentManager.subscribe((event) => {
if (event.type !== "agent_state") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
});
return unsubscribe;
}
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
cwd: record.cwd,
+15 -8
View File
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { watch, type FSWatcher } from "node:fs";
import { readFile } from "fs/promises";
import { exec } from "child_process";
import { exec, execFile } from "node:child_process";
import { promisify } from "util";
import { join, resolve, sep } from "path";
import { homedir } from "node:os";
@@ -172,6 +172,7 @@ import {
} from "./worktree-session.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
@@ -2877,6 +2878,9 @@ export class Session {
}
private assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
throw new Error(`Invalid ${label}: ${ref}`);
}
assertWorktreeSafeGitRef(ref, label);
}
@@ -3048,7 +3052,7 @@ export class Session {
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd });
} catch (error) {
throw new Error(`Branch not found: ${branch}`);
}
@@ -3062,7 +3066,7 @@ export class Session {
}
await this.ensureCleanWorkingTree(cwd);
await execAsync(`git checkout ${branch}`, { cwd });
await execFileAsync("git", ["checkout", branch], { cwd });
}
private async createBranchFromBase(params: {
@@ -3072,9 +3076,10 @@ export class Session {
}): Promise<void> {
const { cwd, baseBranch, newBranchName } = params;
this.assertSafeGitRef(baseBranch, "base branch");
this.assertSafeGitRef(newBranchName, "new branch");
try {
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd });
} catch (error) {
throw new Error(`Base branch not found: ${baseBranch}`);
}
@@ -3085,14 +3090,15 @@ export class Session {
}
await this.ensureCleanWorkingTree(cwd);
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
cwd,
});
}
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
cwd,
});
return true;
@@ -3495,10 +3501,11 @@ export class Session {
try {
const resolvedCwd = expandTilde(cwd);
this.assertSafeGitRef(branchName, "branch");
// Try local branch first
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
@@ -3519,7 +3526,7 @@ export class Session {
// Try remote branch (origin/{branchName})
try {
await execAsync(`git rev-parse --verify origin/${branchName}`, {
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
@@ -14,7 +14,7 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
const speechTest = shouldRun ? test : test.skip;
type VoiceRoundtripProvider = "claude" | "codex" | "opencode";
type VoiceRoundtripProvider = string;
function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): {
provider: VoiceRoundtripProvider;
+1 -1
View File
@@ -609,7 +609,7 @@ program
// Agent runner
interface AgentConfig {
cli: "claude" | "codex";
cli: string;
model?: string;
effort?: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type TaskStatus = "draft" | "open" | "in_progress" | "done" | "failed";
export type AgentType = "claude" | "codex";
export type AgentType = string;
export type ModelName = "haiku" | "sonnet" | "opus" | `gpt-${string}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.43-rc.1",
"version": "0.1.43",
"private": true,
"type": "module",
"scripts": {
@@ -171,7 +171,7 @@
"additionalProperties": false
},
"propertyNames": {
"enum": ["claude", "codex", "opencode"]
"type": "string"
}
}
},
@@ -223,8 +223,7 @@
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["claude", "codex", "opencode"]
"type": "string"
},
"model": {
"type": "string",