diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 26f2a0870..73ad4e2e5 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -130,6 +130,11 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + - name: Resolve release tag shell: bash run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4f05f1e..996e547ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.1.43 - 2026-04-02 + +### Added +- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type. +- Searchable model favorites — quickly find and pin preferred models. +- Slash command support for OpenCode agents. + +### Improved +- Refined model selector UX with better mobile sheet behavior. +- Workspace status now uses amber alert styling for "needs input" state. +- Themed scrollbar on message input for consistent styling. + +### Fixed +- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux. +- Shell arguments with spaces are now properly quoted on Windows. +- Claude models with 1M context support are now correctly reported. + ## 0.1.42 - 2026-04-01 ### Fixed diff --git a/SECURITY.md b/SECURITY.md index 393b044a9..6ad5fdeb2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent ECDH keypair and stores it locally 2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key 3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes. -4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM. +4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box). The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. @@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot: - **Send commands** — Without your phone's private key, it cannot complete the handshake -- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake -- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected -- **Replay old messages** — Each session derives fresh encryption keys +- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake +- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected +- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. ### Trust model The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. +## Local daemon trust boundary + +By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token. + +Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. + +If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. + +For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted. + +Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation. + ## DNS rebinding protection CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 000000000..6de30f82b --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,359 @@ +# Adding a New Provider to Paseo + +This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both. + +## Two Integration Patterns + +### ACP (Agent Client Protocol) -- recommended + +Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks. + +Existing ACP providers: `claude-acp`, `copilot`. + +### Direct + +Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. + +Existing direct providers: `claude`, `codex`, `opencode`. + +--- + +## ACP Provider Checklist + +### 1. Create the provider class + +Create `packages/server/src/server/agent/providers/{name}-agent.ts`. + +Define capabilities, modes, and a thin subclass of `ACPAgentClient`: + +```ts +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 MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const MY_PROVIDER_MODES: AgentMode[] = [ + { + id: "default", + label: "Default", + description: "Standard agent mode", + }, + // Add more modes as needed +]; + +type MyProviderClientOptions = { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; +}; + +export class MyProviderACPAgentClient extends ACPAgentClient { + constructor(options: MyProviderClientOptions) { + super({ + provider: "my-provider", // Must match the ID used everywhere else + logger: options.logger, + runtimeSettings: options.runtimeSettings, + defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn + defaultModes: MY_PROVIDER_MODES, + capabilities: MY_PROVIDER_CAPABILITIES, + }); + } + + // Override isAvailable() if the provider needs specific auth/env vars + override async isAvailable(): Promise { + if (!(await super.isAvailable())) { + return false; // Binary not found + } + return Boolean(process.env["MY_PROVIDER_API_KEY"]); + } +} +``` + +The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top. + +For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself: + +```ts +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 { + return super.isAvailable(); + } +} +``` + +### 2. Add to the provider manifest + +In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry. + +First, define the modes with visual metadata: + +```ts +const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [ + { + id: "default", + label: "Default", + description: "Standard agent mode", + icon: "ShieldCheck", + colorTier: "safe", + }, + { + id: "autonomous", + label: "Autonomous", + description: "Runs without prompting", + icon: "ShieldOff", + colorTier: "dangerous", + }, +]; +``` + +Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`. +Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`. + +Then add to the `AGENT_PROVIDER_DEFINITIONS` array: + +```ts +export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ + // ... existing providers ... + { + id: "my-provider", + label: "My Provider", + description: "Short description of the provider", + defaultModeId: "default", + modes: MY_PROVIDER_MODES, + // Optional: enable voice + voice: { + enabled: true, + defaultModeId: "default", + defaultModel: "some-model", + }, + }, +]; +``` + +### 3. Add the factory to the provider registry + +In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry: + +```ts +import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js"; + +const PROVIDER_CLIENT_FACTORIES: Record = { + // ... existing factories ... + "my-provider": (logger, runtimeSettings) => + new MyProviderACPAgentClient({ + logger, + runtimeSettings: runtimeSettings?.["my-provider"], + }), +}; +``` + +### 4. Add a provider icon (app) + +Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`): + +```tsx +import Svg, { Path } from "react-native-svg"; + +interface MyProviderIconProps { + size?: number; + color?: string; +} + +export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) { + return ( + + + + ); +} +``` + +Then register it in `packages/app/src/components/provider-icons.ts`: + +```ts +import { MyProviderIcon } from "@/components/icons/my-provider-icon"; + +const PROVIDER_ICONS: Record = { + claude: ClaudeIcon as unknown as typeof Bot, + codex: CodexIcon as unknown as typeof Bot, + "my-provider": MyProviderIcon as unknown as typeof Bot, +}; +``` + +If no icon is registered, the app falls back to a generic `Bot` icon from lucide. + +### 5. Add E2E test config + +In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider: + +```ts +export const agentConfigs = { + // ... existing configs ... + "my-provider": { + provider: "my-provider", + model: "default-model-id", + modes: { + full: "autonomous", // Mode with no permission prompts + ask: "default", // Mode that requires permission approval + }, + }, +} as const satisfies Record; +``` + +Add an availability check in `isProviderAvailable()`: + +```ts +case "my-provider": + return ( + isCommandAvailable("my-agent-binary") && + Boolean(process.env.MY_PROVIDER_API_KEY) + ); +``` + +Add to the `allProviders` array: + +```ts +export const allProviders: AgentProvider[] = [ + "claude", + "claude-acp", + "codex", + "copilot", + "opencode", + "my-provider", +]; +``` + +### 6. Run typecheck + +```bash +npm run typecheck +``` + +This is required after every change per project rules. + +--- + +## Direct Provider Checklist + +If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly. + +### Interfaces to implement + +**`AgentClient`** -- factory for sessions and model listing: + +```ts +interface AgentClient { + readonly provider: AgentProvider; + readonly capabilities: AgentCapabilityFlags; + createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise; + resumeSession(handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext): Promise; + listModels(options?: ListModelsOptions): Promise; + isAvailable(): Promise; + // Optional: + listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; +} +``` + +**`AgentSession`** -- a running agent conversation: + +```ts +interface AgentSession { + readonly provider: AgentProvider; + readonly id: string | null; + readonly capabilities: AgentCapabilityFlags; + run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; + startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; + subscribe(callback: (event: AgentStreamEvent) => void): () => void; + streamHistory(): AsyncGenerator; + getRuntimeInfo(): Promise; + getAvailableModes(): Promise; + getCurrentMode(): Promise; + setMode(modeId: string): Promise; + getPendingPermissions(): AgentPermissionRequest[]; + respondToPermission(requestId: string, response: AgentPermissionResponse): Promise; + describePersistence(): AgentPersistenceHandle | null; + interrupt(): Promise; + close(): Promise; + // Optional: + listCommands?(): Promise; + setModel?(modelId: string | null): Promise; + setThinkingOption?(thinkingOptionId: string | null): Promise; +} +``` + +### Steps + +1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces +2. Add to the provider manifest (same as ACP step 2 above) +3. Add factory to the registry (same as ACP step 3 above) +4. Add icon (same as ACP step 4 above) +5. Add E2E config (same as ACP step 5 above) +6. Run typecheck + +--- + +## Testing + +### Manual testing with the CLI + +Start the daemon if not already running, then: + +```bash +# Launch an agent with your provider +paseo run --provider my-provider + +# Launch with a specific model and mode +paseo run --provider my-provider --model some-model --mode default + +# List running agents +paseo ls -a -g + +# Check if the provider reports models +paseo models --provider my-provider +``` + +### E2E test patterns + +The E2E configs in `agent-configs.ts` expose two helpers: + +- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts +- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests + +Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed. + +--- + +## Gotchas + +**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly. + +**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth. + +**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level. + +**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed. + +**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync. + +**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process. + +**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor. diff --git a/nix/package.nix b/nix/package.nix index 496f865ec..52c101bcf 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-AUlfnXntcLfa6ufCQJJoNnz7QwJ1AHVoo94PIKW9ogc="; + npmDepsHash = "sha256-daWF5ntco0CDCG7es8BYijBs7LE3427AsajlWLP7THo="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index f2d41da9b..94d6fae67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.43-rc.1", + "version": "0.1.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.43-rc.1", + "version": "0.1.43", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -48,6 +48,15 @@ } } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz", + "integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@ai-sdk/gateway": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz", @@ -36285,16 +36294,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.43-rc.1", + "version": "0.1.43", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@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", @@ -36411,11 +36420,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.43-rc.1", + "version": "0.1.43", "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", @@ -36456,11 +36465,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.43-rc.1", + "version": "0.1.43", "license": "AGPL-3.0-or-later", "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" @@ -36494,7 +36503,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.43-rc.1", + "version": "0.1.43", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -36695,7 +36704,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.43-rc.1", + "version": "0.1.43", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -36721,7 +36730,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.43-rc.1", + "version": "0.1.43", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -36737,13 +36746,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.43-rc.1", + "version": "0.1.43", "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", @@ -37145,7 +37155,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.43-rc.1", + "version": "0.1.43", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 4a4f5654f..6898a7ff4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.43-rc.1", + "version": "0.1.43", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index f73a78665..c1f5c3c69 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index d15f3aea9..afbd7d866 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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; + canSelectModelProvider?: (providerId: string) => boolean; + favoriteKeys?: Set; + 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(), + 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(); + 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( () => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })), [thinkingOptions], @@ -289,49 +329,36 @@ function ControlledStatusBar({ ) : null} {canSelectModel ? ( - <> - - - + + + { + 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" - > - - {displayModel} - - - - - {getStatusSelectorHint("model")} - - - onSelectModel?.(id)} - searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} - open={openSelector === "model"} - onOpenChange={handleOpenChange("model")} - anchorRef={modelAnchorRef} - desktopPlacement="top-start" - /> - + /> + + + + {getStatusSelectorHint("model")} + + ) : null} {thinkingOptions && thinkingOptions.length > 0 ? ( @@ -454,73 +481,38 @@ function ControlledStatusBar({ stackBehavior="replace" testID="agent-preferences-sheet" > - {providerOptions && providerOptions.length > 0 ? ( - - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - (disabled || !canSelectProvider) && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent provider" - testID="agent-preferences-provider" - > - {displayProvider} - - - - {providerOptions.map((provider) => ( - onSelectProvider?.(provider.id)} - > - {provider.label} - - ))} - - - - ) : null} - {canSelectModel ? ( - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - modelDisabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-preferences-model" - > - {displayModel} - - - - {(modelOptions ?? []).map((model) => ( - onSelectModel?.(model.id)} - > - {model.label} - - ))} - - + { + if (selectedProviderId !== provider) { + onSelectProvider?.(selectedProviderId); + } + onSelectModel?.(modelId); + }} + favoriteKeys={favoriteKeys} + onToggleFavorite={onToggleFavoriteModel} + isLoading={isModelLoading} + disabled={modelDisabled} + renderTrigger={({ selectedModelLabel }) => ( + + {selectedModelLabel} + + + )} + /> ) : 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(); + 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(() => { 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(() => { 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(() => { if (modeOptions.length === 0) { @@ -789,6 +823,10 @@ export function DraftAgentStatusBar({ const mappedThinkingOptions = useMemo(() => { 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 ( 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} diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts new file mode 100644 index 000000000..9da2b44c1 --- /dev/null +++ b/packages/app/src/components/combined-model-selector.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index d9edea21f..1d62aa555 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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; + 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; + selectedProvider: string; + selectedModel: string; + searchQuery: string; + onSearchChange: (query: string) => void; + favoriteKeys: Set; + 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, +): { 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(); + + 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 = ( + } + trailingSlot={ + onToggleFavorite && !disabled ? ( + [ + styles.favoriteButton, + hovered && styles.favoriteButtonHovered, + pressed && styles.favoriteButtonPressed, + ]} + accessibilityRole="button" + accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} + testID={`favorite-model-${row.provider}-${row.modelId}`} + > + {({ hovered }) => ( + + )} + + ) : null + } + /> + ); + + if (!isWeb || !row.description) { + return item; + } + + return ( + + + {item} + + + {row.description} + + + ); +} + +function FavoritesSection({ + favoriteRows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + canSelectProvider, + onToggleFavorite, +}: { + favoriteRows: SelectorModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + 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 ( + + + Favorites + + {favoriteRows.map((row) => ( + onSelect(row.provider, row.modelId)} + onToggleFavorite={onToggleFavorite} + /> + ))} + + + ); +} + +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; + 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 ( + + {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 ( + + {index > 0 ? : null} + {isInline ? ( + <> + + + {providerDefinition?.label ?? group.providerLabel} + + + {group.rows.map((row) => ( + onSelect(row.provider, row.modelId)} + onToggleFavorite={onToggleFavorite} + /> + ))} + + ) : ( + onDrillDown(group.providerId, group.providerLabel)} + style={({ pressed, hovered }) => [ + styles.drillDownRow, + hovered && styles.drillDownRowHovered, + pressed && styles.drillDownRowPressed, + ]} + > + + {group.providerLabel} + + {group.rows.length} + + + + )} + + ); + })} + + ); +} + +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.kind === "provider" ? ( + + ) : null} + + + + + + {groupedRegularRows.length > 0 ? ( + + ) : null} + + {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( + + + No models match your search + + ) : null} + + ); +} + +function ProviderBackButton({ + providerId, + providerLabel, + onBack, +}: { + providerId: string; + providerLabel: string; + onBack?: () => void; +}) { + const { theme } = useUnistyles(); + const ProviderIcon = getProviderIcon(providerId); + + if (!onBack) { + return null; + } + + return ( + [ + styles.backButton, + hovered && styles.backButtonHovered, + pressed && styles.backButtonPressed, + ]} + > + + + {providerLabel} + + ); +} + export function CombinedModelSelector({ providerDefinitions, allProviderModels, @@ -35,48 +451,80 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, + canSelectProvider = () => true, + favoriteKeys = new Set(), + onToggleFavorite, + renderTrigger, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); + const isWeb = Platform.OS === "web"; const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const [view, setView] = useState<"groups" | DrillDownView>("groups"); + const [isContentReady, setIsContentReady] = useState(isWeb); + const [view, setView] = useState({ 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" > - - {selectedModelLabel} - + {renderTrigger ? ( + renderTrigger({ + selectedModelLabel: triggerLabel, + onPress: () => handleOpenChange(!isOpen), + disabled, + isOpen, + }) + ) : ( + <> + + {triggerLabel} + + + )} {}} open={isOpen} onOpenChange={handleOpenChange} + stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" title="Select model" > - {view === "groups" ? ( - { - setView({ provider }); - setSearchQuery(""); - }} - /> - ) : ( - { - setView("groups"); - setSearchQuery(""); + canSelectProvider={canSelectProvider} + onToggleFavorite={onToggleFavorite} + onDrillDown={(providerId, providerLabel) => { + setView({ kind: "provider", providerId, providerLabel }); }} + onBack={ + view.kind === "provider" + ? () => { + setView({ kind: "all" }); + } + : undefined + } /> + ) : ( + + + Loading model selector… + )} ); } -function GroupsView({ - providerDefinitions, - allProviderModels, - selectedProvider, - selectedModel, - onSelect, - onDrillDown, -}: { - providerDefinitions: AgentProviderDefinition[]; - allProviderModels: Map; - selectedProvider: string; - selectedModel: string; - onSelect: (provider: string, modelId: string) => void; - onDrillDown: (provider: string) => void; -}) { - const { theme } = useUnistyles(); - - return ( - - {providerDefinitions.map((def, index) => { - const models = allProviderModels.get(def.id) ?? []; - const isInline = models.length <= INLINE_MODEL_THRESHOLD; - const ProvIcon = getProviderIcon(def.id); - - return ( - - {index > 0 ? : null} - - {isInline ? ( - <> - - - {def.label} - - {models.map((model) => ( - onSelect(def.id, model.id)} - /> - ))} - - ) : ( - onDrillDown(def.id)} - style={({ pressed, hovered }) => [ - styles.drillDownRow, - hovered && styles.drillDownRowHovered, - pressed && styles.drillDownRowPressed, - ]} - > - - {def.label} - - {models.length} - - - - )} - - ); - })} - - ); -} - -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 ( - - [ - styles.backButton, - hovered && styles.backButtonHovered, - pressed && styles.backButtonPressed, - ]} - > - - - {providerLabel} - - - - - {filteredModels.map((model) => ( - onSelect(provider, model.id)} - /> - ))} - - {filteredModels.length === 0 ? ( - - No models match your search - - ) : null} - - ); -} - 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, + }, })); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts new file mode 100644 index 000000000..fa7f102d3 --- /dev/null +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -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, +): 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), + ); +} diff --git a/packages/app/src/components/icons/copilot-icon.tsx b/packages/app/src/components/icons/copilot-icon.tsx new file mode 100644 index 000000000..a9075a2a5 --- /dev/null +++ b/packages/app/src/components/icons/copilot-icon.tsx @@ -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 ( + + + + + ); +} diff --git a/packages/app/src/components/icons/opencode-icon.tsx b/packages/app/src/components/icons/opencode-icon.tsx index e5ded34f2..5f45f72ef 100644 --- a/packages/app/src/components/icons/opencode-icon.tsx +++ b/packages/app/src/components/icons/opencode-icon.tsx @@ -8,7 +8,7 @@ interface OpenCodeIconProps { export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) { return ( - + = { 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, }; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index aaf82eab3..4fde8db78 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 = { open: "Open", merged: "Merged", @@ -243,6 +248,14 @@ function WorkspaceStatusIndicator({ ); } + if (bucket === "needs_input") { + return ( + + + + ); + } + 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 ( @@ -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 ( + + + + ); + } + 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 ( @@ -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, }, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 450c46597..179f94c15 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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 ( [ styles.comboboxItem, hovered && styles.comboboxItemHovered, pressed && styles.comboboxItemPressed, active && styles.comboboxItemActive, + disabled && styles.comboboxItemDisabled, ]} > {leadingContent} @@ -197,9 +204,12 @@ export function ComboboxItem({ ) : null} - {selected ? ( - - + {selected || trailingSlot ? ( + + + {selected ? : null} + + {trailingSlot} ) : null} @@ -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: { diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index da73e4be2..55bb8fc56 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index 6031e16bc..ff04c8e84 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -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; @@ -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({ diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a9a4f58c0..c60eee592 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 { diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index cccc95e27..c5eb42922 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -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, }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index b3a055dfc..86da2a710 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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; diff --git a/packages/app/src/utils/status-dot-color.ts b/packages/app/src/utils/status-dot-color.ts index 22c24d4bd..2adcc2424 100644 --- a/packages/app/src/utils/status-dot-color.ts +++ b/packages/app/src/utils/status-dot-color.ts @@ -25,3 +25,9 @@ export function getStatusDotColor(input: { } return null; } + +export function isEmphasizedStatusDotBucket( + bucket: SidebarStateBucket | null | undefined, +): boolean { + return bucket === "needs_input" || bucket === "attention"; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cc2eef5b..01665066b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index d7b8ffd2e..fd7f90604 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -411,7 +411,7 @@ export async function runRunCommand( const callStructuredTurn = async (structuredPrompt: string): Promise => { 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, diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts index 4fa6a14d9..ab693d6bc 100644 --- a/packages/cli/src/commands/loop/run.ts +++ b/packages/cli/src/commands/loop/run.ts @@ -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[]; diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts index c951b3e95..72ca2a67a 100644 --- a/packages/cli/src/commands/loop/types.ts +++ b/packages/cli/src/commands/loop/types.ts @@ -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[]; diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index dd7bc8912..61b0803f9 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -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 = { diff --git a/packages/cli/src/commands/schedule/types.ts b/packages/cli/src/commands/schedule/types.ts index 0e3e5b11a..6f9a27b74 100644 --- a/packages/cli/src/commands/schedule/types.ts +++ b/packages/cli/src/commands/schedule/types.ts @@ -22,7 +22,7 @@ export type ScheduleTarget = | { type: "new-agent"; config: { - provider: "claude" | "codex" | "opencode"; + provider: string; cwd: string; modeId?: string; model?: string; diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 89e301c38..dda23cc66 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = []; const ctx = await createE2ETestContext({ timeout: 120000 }); async function runProviderModelsJson( - provider: "claude" | "codex" | "opencode", + provider: string, ): Promise { const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"]; diff --git a/packages/cli/tests/e2e/run-output-schema.test.ts b/packages/cli/tests/e2e/run-output-schema.test.ts index 89b4afb25..670871850 100644 --- a/packages/cli/tests/e2e/run-output-schema.test.ts +++ b/packages/cli/tests/e2e/run-output-schema.test.ts @@ -50,7 +50,7 @@ async function cleanup(): Promise { } async function runProviderCase(input: { - provider: "claude" | "codex" | "opencode"; + provider: string; mode: string; model: string; }): Promise { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d604ebf93..bdb19ca9a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index b807c08e7..1bd61bf85 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -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 { } 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."); } diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index d2b2ace8c..e630bca06 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -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 { +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)}`, + ); + } +} diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index a21e63bbe..d0fa2ea03 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -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", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 4330afae6..423aa42d3 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.43-rc.1", + "version": "0.1.43", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index b82960091..2eb7feca6 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -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": { diff --git a/packages/server/package.json b/packages/server/package.json index 80956df3a..42c3e0d9e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index f244348d9..a3d5e549c 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -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", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index fba671da7..599e65197 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -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 = { + 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 { 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; } // Deprecated: Use buildProviderRegistry instead @@ -92,14 +100,9 @@ export function createAllClients( options?: BuildProviderRegistryOptions, ): Record { 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; } export async function shutdownProviders( diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts new file mode 100644 index 000000000..55fbac92b --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts new file mode 100644 index 000000000..fe40c125d --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -0,0 +1,1936 @@ +import { + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type AgentCapabilities as ACPAgentCapabilities, + type Client as ACPClient, + type ClientCapabilities as ACPClientCapabilities, + type ConfigOptionUpdate, + type ContentBlock, + type CreateTerminalRequest, + type CurrentModeUpdate, + type EnvVariable, + type InitializeResponse, + type KillTerminalRequest, + type ListSessionsResponse, + type LoadSessionResponse, + type McpServer, + type NewSessionResponse, + type PermissionOption, + type Plan, + type PromptResponse, + type ReadTextFileRequest, + type RequestPermissionRequest, + type RequestPermissionResponse, + type ResumeSessionResponse, + type SessionConfigOption, + type SessionInfoUpdate, + type SessionMode, + type SessionModelState, + type SessionNotification, + type SessionUpdate, + type TerminalOutputRequest, + type TerminalOutputResponse, + type ToolCall, + type ToolCallContent, + type ToolCallLocation, + type ToolCallStatus, + type ToolCallUpdate, + type ToolKind, + type Usage, + type UsageUpdate, + type WaitForTerminalExitRequest, + type WriteTextFileRequest, +} from "@agentclientprotocol/sdk"; +import type { Logger } from "pino"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentLaunchContext, + AgentMetadata, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionRequestKind, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptContentBlock, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentRuntimeInfo, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + AgentUsage, + ListModelsOptions, + ListPersistedAgentsOptions, + McpServerConfig, + PersistedAgentDescriptor, + ToolCallDetail, + ToolCallTimelineItem, +} from "../agent-sdk-types.js"; +import { + applyProviderEnv, + findExecutable, + resolveProviderCommandPrefix, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; + +const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, +}; + +type ACPAgentClientOptions = { + provider: string; + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + defaultCommand: [string, ...string[]]; + defaultModes?: AgentMode[]; + capabilities?: AgentCapabilityFlags; +}; + +type ACPAgentSessionOptions = { + provider: string; + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + defaultCommand: [string, ...string[]]; + defaultModes: AgentMode[]; + capabilities: AgentCapabilityFlags; + handle?: AgentPersistenceHandle; + launchEnv?: Record; +}; + +type SpawnedACPProcess = { + child: ChildProcessWithoutNullStreams; + connection: ClientSideConnection; + initialize: InitializeResponse; +}; + +type ACPToolSnapshot = { + toolCallId: string; + title: string; + kind?: ToolKind | null; + status?: ToolCallStatus | null; + content?: ToolCallContent[] | null; + locations?: ToolCallLocation[] | null; + rawInput?: unknown; + rawOutput?: unknown; +}; + +type PendingPermission = { + request: AgentPermissionRequest; + options: PermissionOption[]; + resolve: (response: RequestPermissionResponse) => void; + reject: (error: Error) => void; + turnId: string | null; +}; + +type MessageAssemblyState = { + text: string; +}; + +type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse; + +type TerminalExit = { + exitCode?: number | null; + signal?: string | null; +}; + +type TerminalEntry = { + id: string; + child: ChildProcess; + output: string; + truncated: boolean; + outputByteLimit: number | null; + exit: TerminalExit | null; + waitForExit: Promise; + resolveExit: (exit: TerminalExit) => void; + rejectExit: (error: Error) => void; +}; + +type ConfigOptionSelector = { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: AgentMetadata; +}; + +export function mapACPUsage(usage: Usage | null | undefined): AgentUsage | undefined { + if (!usage) { + return undefined; + } + + return { + inputTokens: usage.inputTokens ?? undefined, + outputTokens: usage.outputTokens ?? undefined, + cachedInputTokens: usage.cachedReadTokens ?? undefined, + }; +} + +export function deriveModesFromACP( + fallbackModes: AgentMode[], + modeState?: { availableModes?: SessionMode[] | null; currentModeId?: string | null } | null, + configOptions?: SessionConfigOption[] | null, +): { modes: AgentMode[]; currentModeId: string | null } { + if (modeState?.availableModes?.length) { + return { + modes: modeState.availableModes.map((mode) => ({ + id: mode.id, + label: mode.name, + description: mode.description ?? undefined, + })), + currentModeId: modeState.currentModeId ?? null, + }; + } + + const modeOption = configOptions?.find( + (option) => option.type === "select" && option.category === "mode", + ); + if (modeOption?.type === "select") { + const flatOptions = flattenSelectOptions(modeOption.options); + return { + modes: flatOptions.map((option) => ({ + id: option.value, + label: option.name, + description: option.description ?? undefined, + })), + currentModeId: modeOption.currentValue, + }; + } + + return { + modes: fallbackModes, + currentModeId: null, + }; +} + +export function deriveModelDefinitionsFromACP( + provider: string, + models: SessionModelState | null | undefined, + configOptions?: SessionConfigOption[] | null, +): AgentModelDefinition[] { + const thinkingOptions = deriveSelectorOptions(configOptions, "thought_level"); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id ?? null; + + if (models?.availableModels?.length) { + return models.availableModels.map((model) => ({ + provider, + id: model.modelId, + label: model.name, + description: model.description ?? undefined, + isDefault: model.modelId === models.currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, + })); + } + + const modelOptions = deriveSelectorOptions(configOptions, "model"); + return modelOptions.map((option) => ({ + provider, + id: option.id, + label: option.label, + description: option.description, + isDefault: option.isDefault, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: defaultThinkingOptionId ?? undefined, + metadata: option.metadata, + })); +} + +export class ACPAgentClient implements AgentClient { + readonly provider: string; + readonly capabilities: AgentCapabilityFlags; + + protected readonly logger: Logger; + protected readonly runtimeSettings?: ProviderRuntimeSettings; + protected readonly defaultCommand: [string, ...string[]]; + protected readonly defaultModes: AgentMode[]; + + constructor(options: ACPAgentClientOptions) { + this.provider = options.provider; + this.capabilities = options.capabilities ?? DEFAULT_ACP_CAPABILITIES; + this.logger = options.logger.child({ module: "agent", provider: options.provider }); + this.runtimeSettings = options.runtimeSettings; + this.defaultCommand = options.defaultCommand; + this.defaultModes = options.defaultModes ?? []; + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.assertProvider(config); + const session = new ACPAgentSession( + { ...config, provider: this.provider }, + { + provider: this.provider, + logger: this.logger, + runtimeSettings: this.runtimeSettings, + defaultCommand: this.defaultCommand, + defaultModes: this.defaultModes, + capabilities: this.capabilities, + launchEnv: launchContext?.env, + }, + ); + await session.initializeNewSession(); + return session; + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + if (handle.provider !== this.provider) { + throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); + } + + const storedConfig = coerceSessionConfigMetadata(handle.metadata); + const cwd = overrides?.cwd ?? storedConfig.cwd; + if (!cwd) { + throw new Error(`${this.provider} resume requires the original working directory`); + } + + const mergedConfig: AgentSessionConfig = { + ...storedConfig, + ...overrides, + provider: this.provider, + cwd, + }; + const session = new ACPAgentSession(mergedConfig, { + provider: this.provider, + logger: this.logger, + runtimeSettings: this.runtimeSettings, + defaultCommand: this.defaultCommand, + defaultModes: this.defaultModes, + capabilities: this.capabilities, + handle, + launchEnv: launchContext?.env, + }); + await session.initializeResumedSession(); + return session; + } + + async listModels(options?: ListModelsOptions): Promise { + const cwd = options?.cwd ?? process.cwd(); + const probe = await this.spawnProcess(undefined); + try { + const response = await probe.connection.newSession({ + cwd, + mcpServers: [], + }); + return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions); + } finally { + await this.closeProbe(probe); + } + } + + async listPersistedAgents( + options?: ListPersistedAgentsOptions, + ): Promise { + const probe = await this.spawnProcess(undefined); + try { + if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { + return []; + } + + const sessions: PersistedAgentDescriptor[] = []; + let cursor: string | null | undefined; + do { + const page: ListSessionsResponse = await probe.connection.listSessions({ + ...(cursor ? { cursor } : {}), + }); + for (const session of page.sessions) { + sessions.push({ + provider: this.provider, + sessionId: session.sessionId, + cwd: session.cwd, + title: session.title ?? null, + lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), + persistence: { + provider: this.provider, + sessionId: session.sessionId, + nativeHandle: session.sessionId, + metadata: { + provider: this.provider, + cwd: session.cwd, + title: session.title ?? null, + }, + }, + timeline: [], + }); + } + cursor = page.nextCursor ?? null; + } while (cursor && (!options?.limit || sessions.length < options.limit)); + + return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions; + } finally { + await this.closeProbe(probe); + } + } + + async isAvailable(): Promise { + try { + this.resolveLaunchCommand(); + return true; + } catch { + return false; + } + } + + protected async spawnProcess( + launchEnv?: Record, + ): Promise { + const { command, args } = this.resolveLaunchCommand(); + const child = spawn(command, args, { + cwd: process.cwd(), + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...(launchEnv ?? {}), + }, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + + const spawnErrorPromise = new Promise((_, reject) => { + child.once("error", (error) => { + const stderr = stderrChunks.join("").trim(); + reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); + }); + }); + + if (!child.stdin || !child.stdout) { + throw new Error(`${this.provider} ACP process did not expose stdio pipes`); + } + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const initialize = (await Promise.race([ + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientInfo: { name: "Paseo", version: "dev" }, + }), + spawnErrorPromise, + ])) as InitializeResponse; + + return { child, connection, initialize }; + } + + protected buildProbeClient(): ACPClient { + return { + async requestPermission(): Promise { + return { outcome: { outcome: "cancelled" } }; + }, + async sessionUpdate(): Promise {}, + async readTextFile(params: ReadTextFileRequest) { + const content = await fs.readFile(params.path, "utf8"); + return { content }; + }, + async writeTextFile(params: WriteTextFileRequest) { + await fs.mkdir(path.dirname(params.path), { recursive: true }); + await fs.writeFile(params.path, params.content, "utf8"); + return {}; + }, + async createTerminal() { + throw new Error("ACP model probe does not support terminal execution"); + }, + }; + } + + protected async closeProbe(probe: SpawnedACPProcess): Promise { + try { + if (probe.initialize.agentCapabilities?.sessionCapabilities?.close) { + // No active session to close here; ignore capability. + } + } finally { + probe.child.kill("SIGTERM"); + await waitForChildExit(probe.child, 2_000); + } + } + + protected resolveLaunchCommand(): { command: string; args: string[] } { + const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { + const resolved = findExecutable(this.defaultCommand[0]); + if (!resolved) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } + return resolved; + }); + return { + command: prefix.command, + args: [...prefix.args, ...this.defaultCommand.slice(1)], + }; + } + + private assertProvider(config: AgentSessionConfig): void { + if (config.provider !== this.provider) { + throw new Error(`Expected ${this.provider} config, received ${config.provider}`); + } + } +} + +export class ACPAgentSession implements AgentSession, ACPClient { + readonly provider: string; + readonly capabilities: AgentCapabilityFlags; + + private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly defaultCommand: [string, ...string[]]; + private readonly defaultModes: AgentMode[]; + private readonly launchEnv?: Record; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly pendingPermissions = new Map(); + private readonly messageAssemblies = new Map(); + private readonly toolCalls = new Map(); + private readonly terminalEntries = new Map(); + private readonly persistedHistory: AgentTimelineItem[] = []; + private readonly initialHandle?: AgentPersistenceHandle; + + private readonly config: AgentSessionConfig; + private child: ChildProcessWithoutNullStreams | null = null; + private connection: ClientSideConnection | null = null; + private agentCapabilities: ACPAgentCapabilities | null = null; + private sessionId: string | null = null; + private currentMode: string | null = null; + private availableModes: AgentMode[]; + private currentModel: string | null = null; + private thinkingOptionId: string | null = null; + private currentTitle: string | null = null; + private lastActivityAt: string | null = null; + private configOptions: SessionConfigOption[] = []; + private currentTurnUsage: AgentUsage | undefined; + private activeForegroundTurnId: string | null = null; + private closed = false; + private historyPending = false; + private replayingHistory = false; + private suppressUserEchoMessageId: string | null = null; + private suppressUserEchoText: string | null = null; + private bootstrapThreadEventPending = false; + + constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) { + this.provider = options.provider; + this.capabilities = options.capabilities; + this.logger = options.logger.child({ module: "agent", provider: options.provider }); + this.runtimeSettings = options.runtimeSettings; + this.defaultCommand = options.defaultCommand; + this.defaultModes = options.defaultModes; + this.availableModes = options.defaultModes; + this.launchEnv = options.launchEnv; + this.initialHandle = options.handle; + this.config = { ...config, provider: options.provider }; + this.currentMode = config.modeId ?? null; + this.currentModel = config.model ?? null; + this.thinkingOptionId = config.thinkingOptionId ?? null; + this.currentTitle = config.title ?? null; + } + + get id(): string | null { + return this.sessionId; + } + + async initializeNewSession(): Promise { + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + + const response = await this.connection.newSession({ + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.sessionId = response.sessionId; + this.bootstrapThreadEventPending = true; + this.applySessionState(response); + await this.applyConfiguredOverrides(); + } + + async initializeResumedSession(): Promise { + const handle = this.initialHandle; + if (!handle) { + throw new Error("Resume requested without persistence handle"); + } + + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + this.sessionId = handle.sessionId; + this.bootstrapThreadEventPending = true; + + const sessionCapabilities = this.agentCapabilities?.sessionCapabilities; + if (this.agentCapabilities?.loadSession) { + this.replayingHistory = true; + const response = await this.connection.loadSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.replayingHistory = false; + this.historyPending = this.persistedHistory.length > 0; + this.applySessionState(response); + } else if (sessionCapabilities?.resume) { + const response = await this.connection.unstable_resumeSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: normalizeMcpServers(this.config.mcpServers), + }); + this.applySessionState(response); + } else { + throw new Error(`${this.provider} does not support ACP session resume`); + } + + await this.applyConfiguredOverrides(); + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const timeline: AgentTimelineItem[] = []; + let finalText = ""; + let usage: AgentUsage | undefined; + let turnId: string | null = null; + let settled = false; + let resolveCompletion!: () => void; + let rejectCompletion!: (error: Error) => void; + const buffered: AgentStreamEvent[] = []; + + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + const processEvent = (event: AgentStreamEvent) => { + if (settled) { + return; + } + if (turnId && "turnId" in event && event.turnId && event.turnId !== turnId) { + return; + } + if (event.type === "timeline") { + timeline.push(event.item); + if (event.item.type === "assistant_message") { + finalText = event.item.text.startsWith(finalText) + ? event.item.text + : `${finalText}${event.item.text}`; + } + return; + } + if (event.type === "turn_completed") { + usage = event.usage; + settled = true; + resolveCompletion(); + return; + } + if (event.type === "turn_failed") { + settled = true; + rejectCompletion(new Error(event.error)); + return; + } + if (event.type === "turn_canceled") { + settled = true; + resolveCompletion(); + } + }; + + const unsubscribe = this.subscribe((event) => { + if (!turnId) { + buffered.push(event); + return; + } + processEvent(event); + }); + + try { + const started = await this.startTurn(prompt, options); + turnId = started.turnId; + for (const event of buffered) { + processEvent(event); + } + if (!settled) { + await completion; + } + } finally { + unsubscribe(); + } + + if (!this.sessionId) { + throw new Error("ACP session did not expose a session id"); + } + + return { + sessionId: this.sessionId, + finalText, + usage, + timeline, + }; + } + + async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> { + if (this.closed) { + throw new Error(`${this.provider} session is closed`); + } + if (!this.connection || !this.sessionId) { + throw new Error(`${this.provider} session is not initialized`); + } + if (this.activeForegroundTurnId) { + throw new Error("A foreground turn is already active"); + } + + const turnId = randomUUID(); + const messageId = randomUUID(); + this.activeForegroundTurnId = turnId; + this.suppressUserEchoMessageId = messageId; + this.suppressUserEchoText = extractPromptText(prompt); + this.emitBootstrapThreadEvent(); + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + + void this.connection + .prompt({ + sessionId: this.sessionId, + messageId, + prompt: toACPContentBlocks(prompt), + }) + .then((response) => { + this.handlePromptResponse(response, turnId); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.finishTurn({ + type: "turn_failed", + provider: this.provider, + error: message, + diagnostic: this.collectDiagnostic(message), + turnId, + }); + }); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + if (this.sessionId) { + callback({ + type: "thread_started", + provider: this.provider, + sessionId: this.sessionId, + }); + } + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + if (!this.historyPending || this.persistedHistory.length === 0) { + return; + } + const history = [...this.persistedHistory]; + this.persistedHistory.length = 0; + this.historyPending = false; + for (const item of history) { + yield { type: "timeline", provider: this.provider, item }; + } + } + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.sessionId, + model: this.currentModel, + thinkingOptionId: this.thinkingOptionId, + modeId: this.currentMode, + extra: { + title: this.currentTitle, + updatedAt: this.lastActivityAt, + }, + }; + } + + async getAvailableModes(): Promise { + return [...this.availableModes]; + } + + async getCurrentMode(): Promise { + return this.currentMode; + } + + async setMode(modeId: string): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + + const modeExists = this.availableModes.some((mode) => mode.id === modeId); + if (!modeExists && this.availableModes.length > 0) { + throw new Error(`Unknown ${this.provider} mode '${modeId}'`); + } + + if (this.availableModes.length > 0) { + await this.connection.setSessionMode({ sessionId: this.sessionId, modeId }); + this.currentMode = modeId; + return; + } + + const modeOption = this.getSelectConfigOption("mode"); + if (!modeOption) { + throw new Error(`${this.provider} does not expose ACP mode switching`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: modeOption.id, + value: modeId, + }); + this.currentMode = modeId; + } + + async setModel(modelId: string | null): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + if (!modelId) { + this.currentModel = null; + return; + } + + if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) { + try { + await this.connection.unstable_setSessionModel({ + sessionId: this.sessionId, + modelId, + }); + this.currentModel = modelId; + return; + } catch { + // Fall through to config option path. + } + } + + const modelOption = this.getSelectConfigOption("model"); + if (!modelOption) { + throw new Error(`${this.provider} does not expose ACP model selection`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: modelOption.id, + value: modelId, + }); + this.currentModel = modelId; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + if (!thinkingOptionId) { + this.thinkingOptionId = null; + return; + } + + const option = this.getSelectConfigOption("thought_level"); + if (!option) { + throw new Error(`${this.provider} does not expose ACP thought-level selection`); + } + await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: option.id, + value: thinkingOptionId, + }); + this.thinkingOptionId = thinkingOptionId; + } + + getPendingPermissions(): AgentPermissionRequest[] { + return Array.from(this.pendingPermissions.values(), (entry) => entry.request); + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + const pending = this.pendingPermissions.get(requestId); + if (!pending) { + throw new Error(`No pending permission request with id '${requestId}'`); + } + + this.pendingPermissions.delete(requestId); + const selectedOption = selectPermissionOption(pending.options, response); + pending.resolve( + selectedOption + ? { + outcome: { + outcome: "selected", + optionId: selectedOption.optionId, + }, + } + : { outcome: { outcome: "cancelled" } }, + ); + + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId, + resolution: response, + turnId: pending.turnId ?? undefined, + }); + + if (response.behavior === "deny" && response.interrupt && this.connection && this.sessionId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } + + describePersistence(): AgentPersistenceHandle | null { + if (!this.sessionId) { + return null; + } + return { + provider: this.provider, + sessionId: this.sessionId, + nativeHandle: this.sessionId, + metadata: { + ...this.config, + title: this.currentTitle, + }, + }; + } + + async interrupt(): Promise { + if (!this.connection || !this.sessionId) { + return; + } + + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + + if (this.activeForegroundTurnId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + + if (this.connection && this.sessionId) { + try { + if (this.activeForegroundTurnId) { + await this.connection.cancel({ sessionId: this.sessionId }); + } + } catch {} + + try { + if (this.agentCapabilities?.sessionCapabilities?.close) { + await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + } + } catch (error) { + this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); + } + } + + for (const terminal of this.terminalEntries.values()) { + terminal.child.kill("SIGTERM"); + } + this.terminalEntries.clear(); + + if (this.child) { + this.child.kill("SIGTERM"); + await waitForChildExit(this.child, 2_000); + } + + this.subscribers.clear(); + this.connection = null; + this.child = null; + this.activeForegroundTurnId = null; + } + + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const requestId = randomUUID(); + const request = mapPermissionRequest( + this.provider, + requestId, + params, + this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall), + ); + + const promise = new Promise((resolve, reject) => { + this.pendingPermissions.set(requestId, { + request, + options: params.options, + resolve, + reject, + turnId: this.activeForegroundTurnId, + }); + }); + + this.pushEvent({ + type: "permission_requested", + provider: this.provider, + request, + turnId: this.activeForegroundTurnId ?? undefined, + }); + return promise; + } + + async sessionUpdate(params: SessionNotification): Promise { + if (params.sessionId !== this.sessionId) { + return; + } + + const events = this.translateSessionUpdate(params.update); + if (this.replayingHistory) { + for (const event of events) { + if (event.type === "timeline") { + this.persistedHistory.push(event.item); + } + } + return; + } + + for (const event of events) { + this.pushEvent(event); + } + } + + async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { + const raw = await fs.readFile(params.path, "utf8"); + if (!params.line && !params.limit) { + return { content: raw }; + } + const lines = raw.split(/\r?\n/); + const start = Math.max((params.line ?? 1) - 1, 0); + const end = params.limit ? start + params.limit : undefined; + return { content: lines.slice(start, end).join("\n") }; + } + + async writeTextFile(params: WriteTextFileRequest): Promise> { + await fs.mkdir(path.dirname(params.path), { recursive: true }); + await fs.writeFile(params.path, params.content, "utf8"); + return {}; + } + + async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { + const terminalId = randomUUID(); + const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value])); + const child = spawn(params.command, params.args ?? [], { + cwd: params.cwd ?? this.config.cwd, + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...env, + }, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + + let resolveExit!: (exit: TerminalExit) => void; + let rejectExit!: (error: Error) => void; + const waitForExit = new Promise((resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }); + + const entry: TerminalEntry = { + id: terminalId, + child, + output: "", + truncated: false, + outputByteLimit: params.outputByteLimit ?? null, + exit: null, + waitForExit, + resolveExit, + rejectExit, + }; + + child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); + child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error)))); + child.once("exit", (code, signal) => { + const exit = { exitCode: code, signal }; + entry.exit = exit; + resolveExit(exit); + }); + + this.terminalEntries.set(terminalId, entry); + return { terminalId }; + } + + async terminalOutput(params: TerminalOutputRequest): Promise { + const entry = this.getTerminalEntry(params.terminalId); + return { + output: entry.output, + truncated: entry.truncated, + exitStatus: entry.exit ?? undefined, + }; + } + + async waitForTerminalExit(params: WaitForTerminalExitRequest): Promise { + const entry = this.getTerminalEntry(params.terminalId); + return entry.waitForExit; + } + + async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { + const entry = this.getTerminalEntry(params.terminalId); + if (!entry.exit) { + entry.child.kill("SIGTERM"); + } + this.terminalEntries.delete(params.terminalId); + } + + async killTerminal(params: KillTerminalRequest): Promise> { + const entry = this.getTerminalEntry(params.terminalId); + if (!entry.exit) { + entry.child.kill("SIGTERM"); + } + return {}; + } + + private async spawnProcess(): Promise { + const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { + const resolved = findExecutable(this.defaultCommand[0]); + if (!resolved) { + throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); + } + return resolved; + }); + + const command = prefix.command; + const args = [...prefix.args, ...this.defaultCommand.slice(1)]; + const child = spawn(command, args, { + cwd: this.config.cwd, + env: { + ...applyProviderEnv(process.env as Record, this.runtimeSettings), + ...(this.launchEnv ?? {}), + }, + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk: Buffer | string) => { + stderrChunks.push(chunk.toString()); + }); + child.once("exit", (code, signal) => { + if (this.closed) { + return; + } + if (this.activeForegroundTurnId) { + this.synthesizeCanceledToolCalls(); + this.finishTurn({ + type: "turn_failed", + provider: this.provider, + error: `ACP agent exited unexpectedly (${code ?? "null"}${signal ? `, ${signal}` : ""})`, + diagnostic: stderrChunks.join("").trim() || undefined, + turnId: this.activeForegroundTurnId, + }); + } + }); + + if (!child.stdin || !child.stdout) { + throw new Error(`${this.provider} ACP process did not expose stdio pipes`); + } + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ); + const connection = new ClientSideConnection(() => this, stream); + const initialize = await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientInfo: { name: "Paseo", version: "dev" }, + }); + + return { child, connection, initialize }; + } + + private applySessionState(response: SessionStateResponse): void { + this.configOptions = response.configOptions ?? []; + + const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions); + this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes; + this.currentMode = modeInfo.currentModeId ?? this.currentMode; + + this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model"); + this.thinkingOptionId = + deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; + } + + private async applyConfiguredOverrides(): Promise { + if (this.config.modeId && this.config.modeId !== this.currentMode) { + await this.setMode(this.config.modeId); + } + if (this.config.model && this.config.model !== this.currentModel) { + await this.setModel(this.config.model); + } + if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { + await this.setThinkingOption(this.config.thinkingOptionId); + } + } + + private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { + switch (update.sessionUpdate) { + case "user_message_chunk": { + const item = this.createMessageTimelineItem("user_message", update); + if (!item) { + return []; + } + const shouldSuppress = + this.suppressUserEchoMessageId && + update.messageId === this.suppressUserEchoMessageId && + this.suppressUserEchoText && + item.text === this.suppressUserEchoText; + if (shouldSuppress) { + return []; + } + return [this.wrapTimeline(item)]; + } + case "agent_message_chunk": { + const item = this.createMessageTimelineItem("assistant_message", update); + return item ? [this.wrapTimeline(item)] : []; + } + case "agent_thought_chunk": { + const item = this.createMessageTimelineItem("reasoning", update); + return item ? [this.wrapTimeline(item)] : []; + } + case "tool_call": { + const snapshot = mergeToolSnapshot(update.toolCallId, update); + this.toolCalls.set(update.toolCallId, snapshot); + return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; + } + case "tool_call_update": { + const previous = this.toolCalls.get(update.toolCallId); + const snapshot = mergeToolSnapshot(update.toolCallId, update, previous); + this.toolCalls.set(update.toolCallId, snapshot); + return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; + } + case "plan": + return [this.wrapTimeline(mapPlanToTimeline(update))]; + case "current_mode_update": + this.handleCurrentModeUpdate(update); + return []; + case "config_option_update": + this.handleConfigOptionUpdate(update); + return []; + case "session_info_update": + this.handleSessionInfoUpdate(update); + return []; + case "usage_update": + this.handleUsageUpdate(update); + return []; + case "available_commands_update": + return []; + default: + return []; + } + } + + private createMessageTimelineItem( + type: "user_message" | "assistant_message" | "reasoning", + update: Extract< + SessionUpdate, + { sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" } + >, + ): + | { type: "user_message"; text: string; messageId?: string } + | { type: "assistant_message"; text: string } + | { type: "reasoning"; text: string } + | null { + const chunkText = contentBlockToText(update.content); + if (!chunkText) { + return null; + } + const key = `${type}:${update.messageId ?? "default"}`; + const state = this.messageAssemblies.get(key) ?? { text: "" }; + state.text += chunkText; + this.messageAssemblies.set(key, state); + + if (type === "user_message") { + return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined }; + } + if (type === "assistant_message") { + return { type: "assistant_message", text: chunkText }; + } + return { type: "reasoning", text: chunkText }; + } + + private handleCurrentModeUpdate(update: CurrentModeUpdate): void { + this.currentMode = update.currentModeId; + } + + private handleConfigOptionUpdate(update: ConfigOptionUpdate): void { + this.configOptions = update.configOptions; + const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions); + if (modeInfo.modes.length > 0) { + this.availableModes = modeInfo.modes; + } + this.currentMode = modeInfo.currentModeId ?? this.currentMode; + this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel; + this.thinkingOptionId = + deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId; + } + + private handleSessionInfoUpdate(update: SessionInfoUpdate): void { + if ("title" in update) { + this.currentTitle = update.title ?? null; + } + if ("updatedAt" in update) { + this.lastActivityAt = update.updatedAt ?? null; + } + } + + private handleUsageUpdate(update: UsageUpdate): void { + void update; + } + + private handlePromptResponse(response: PromptResponse, turnId: string): void { + this.currentTurnUsage = mapACPUsage(response.usage) ?? this.currentTurnUsage; + + switch (response.stopReason) { + case "cancelled": + this.synthesizeCanceledToolCalls(); + this.finishTurn({ + type: "turn_canceled", + provider: this.provider, + reason: "Interrupted", + turnId, + }); + break; + case "end_turn": + case "max_tokens": + case "max_turn_requests": + case "refusal": + default: + this.finishTurn({ + type: "turn_completed", + provider: this.provider, + usage: this.currentTurnUsage, + turnId, + }); + break; + } + } + + private wrapTimeline(item: AgentTimelineItem): AgentStreamEvent { + return { + type: "timeline", + provider: this.provider, + item, + turnId: this.activeForegroundTurnId ?? undefined, + }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private finishTurn(event: Extract): void { + this.activeForegroundTurnId = null; + this.suppressUserEchoMessageId = null; + this.suppressUserEchoText = null; + this.pushEvent(event); + } + + private emitBootstrapThreadEvent(): void { + if (!this.bootstrapThreadEventPending || !this.sessionId) { + return; + } + this.bootstrapThreadEventPending = false; + this.pushEvent({ + type: "thread_started", + provider: this.provider, + sessionId: this.sessionId, + }); + } + + private synthesizeCanceledToolCalls(): void { + for (const snapshot of this.toolCalls.values()) { + const mapped = mapToolSnapshotToTimeline(snapshot, this.terminalEntries); + if (mapped.status === "running") { + this.pushEvent( + this.wrapTimeline({ + ...mapped, + status: "canceled", + error: null, + }), + ); + } + } + } + + private collectDiagnostic(message: string): string | undefined { + const parts: string[] = [message]; + if (this.child?.exitCode != null) { + parts.push(`exitCode=${this.child.exitCode}`); + } + if (this.child?.signalCode) { + parts.push(`signal=${this.child.signalCode}`); + } + return parts.length > 0 ? parts.join(" | ") : undefined; + } + + private getSelectConfigOption(category: string): Extract | null { + const option = this.configOptions.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + return option ?? null; + } + + private getTerminalEntry(terminalId: string): TerminalEntry { + const entry = this.terminalEntries.get(terminalId); + if (!entry) { + throw new Error(`Unknown terminal '${terminalId}'`); + } + return entry; + } +} + +function flattenSelectOptions( + options: Extract["options"], +): Array<{ value: string; name: string; description?: string | null; group?: string }> { + const flattened: Array<{ value: string; name: string; description?: string | null; group?: string }> = []; + for (const option of options) { + if ("value" in option) { + flattened.push(option); + continue; + } + for (const groupOption of option.options) { + flattened.push({ ...groupOption, group: option.group }); + } + } + return flattened; +} + +function deriveSelectorOptions( + configOptions: SessionConfigOption[] | null | undefined, + category: string, +): ConfigOptionSelector[] { + const option = configOptions?.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + if (!option) { + return []; + } + + return flattenSelectOptions(option.options).map((value) => ({ + id: value.value, + label: value.name, + description: value.description ?? undefined, + isDefault: value.value === option.currentValue, + metadata: value.group ? { group: value.group } : undefined, + })); +} + +function deriveCurrentConfigValue( + configOptions: SessionConfigOption[] | null | undefined, + category: string, +): string | null { + const option = configOptions?.find( + (entry): entry is Extract => + entry.type === "select" && entry.category === category, + ); + return option?.currentValue ?? null; +} + +function normalizeMcpServers(servers?: Record): McpServer[] { + if (!servers) { + return []; + } + + return Object.entries(servers).map(([name, config]) => { + if (config.type === "stdio") { + return { + name, + command: config.command, + args: config.args ?? [], + env: Object.entries(config.env ?? {}).map(([envName, value]) => ({ + name: envName, + value, + })), + } satisfies McpServer; + } + + if (config.type === "http") { + return { + type: "http", + name, + url: config.url, + headers: Object.entries(config.headers ?? {}).map(([headerName, value]) => ({ + name: headerName, + value, + })), + } satisfies McpServer; + } + + return { + type: "sse", + name, + url: config.url, + headers: Object.entries(config.headers ?? {}).map(([headerName, value]) => ({ + name: headerName, + value, + })), + } satisfies McpServer; + }); +} + +function toACPContentBlocks(prompt: AgentPromptInput): ContentBlock[] { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + + return prompt.map((block: AgentPromptContentBlock) => { + if (block.type === "text") { + return { type: "text", text: block.text }; + } + return { + type: "image", + data: block.data, + mimeType: block.mimeType, + }; + }); +} + +function extractPromptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") { + return prompt; + } + return prompt + .filter((block): block is Extract => block.type === "text") + .map((block) => block.text) + .join(""); +} + +function contentBlockToText(content: ContentBlock): string { + switch (content.type) { + case "text": + return content.text; + case "resource_link": + return content.title ?? content.uri; + case "resource": + return "text" in content.resource ? content.resource.text : `[resource:${content.resource.mimeType ?? "binary"}]`; + case "image": + return "[image]"; + case "audio": + return "[audio]"; + default: + return ""; + } +} + +function mergeToolSnapshot( + toolCallId: string, + update: ToolCall | ToolCallUpdate, + previous?: ACPToolSnapshot, +): ACPToolSnapshot { + const isFull = "title" in update && typeof update.title === "string"; + return { + toolCallId, + title: (update.title ?? previous?.title ?? toolCallId) as string, + kind: update.kind ?? previous?.kind ?? null, + status: update.status ?? previous?.status ?? null, + content: update.content !== undefined ? update.content : previous?.content ?? null, + locations: update.locations !== undefined ? update.locations : previous?.locations ?? null, + rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput, + rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput, + ...(isFull ? {} : {}), + }; +} + +function mapPlanToTimeline(plan: Plan): AgentTimelineItem { + return { + type: "todo", + items: plan.entries.map((entry) => ({ + text: entry.content, + completed: entry.status === "completed", + })), + }; +} + +function mapToolSnapshotToTimeline( + snapshot: ACPToolSnapshot, + terminals: Map, +): ToolCallTimelineItem { + const status = mapToolStatus(snapshot.status); + const detail = mapToolDetail(snapshot, terminals); + const base = { + type: "tool_call" as const, + callId: snapshot.toolCallId, + name: snapshot.kind ?? snapshot.title, + detail, + metadata: { + kind: snapshot.kind ?? undefined, + title: snapshot.title, + }, + }; + if (status === "failed") { + return { + ...base, + status: "failed", + error: { message: readErrorMessage(snapshot.rawOutput) }, + }; + } + if (status === "completed") { + return { + ...base, + status: "completed", + error: null, + }; + } + return { + ...base, + status: "running", + error: null, + }; +} + +function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimelineItem["status"] { + switch (status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "pending": + case "in_progress": + default: + return "running"; + } +} + +function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map): ToolCallDetail { + const firstLocation = snapshot.locations?.[0]?.path; + const textContent = extractToolText(snapshot.content); + const diffContent = extractDiffContent(snapshot.content); + const terminalContent = extractTerminalContent(snapshot.content, terminals); + const rawInput = readRecord(snapshot.rawInput); + const rawOutput = readRecord(snapshot.rawOutput); + + switch (snapshot.kind) { + case "read": + return { + type: "read", + filePath: + firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + content: textContent ?? readString(rawOutput, ["content", "text"]), + offset: readNumber(rawInput, ["offset", "line"]), + limit: readNumber(rawInput, ["limit"]), + }; + case "edit": + case "delete": + return { + type: "edit", + filePath: + firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]), + newString: + snapshot.kind === "delete" + ? "" + : diffContent?.newText ?? readString(rawInput, ["newText", "newString"]), + unifiedDiff: textContent ?? undefined, + }; + case "search": + return { + type: "search", + query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title, + toolName: "search", + content: textContent ?? readString(rawOutput, ["content", "text"]), + filePaths: snapshot.locations?.map((location) => location.path), + }; + case "execute": + return { + type: "shell", + command: + terminalContent?.command ?? + buildShellCommand(rawInput) ?? + readString(rawInput, ["command"]) ?? + snapshot.title, + cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]), + output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]), + exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]), + }; + case "fetch": + return { + type: "fetch", + url: readString(rawInput, ["url"]) ?? snapshot.title, + prompt: readString(rawInput, ["prompt"]), + result: textContent ?? readString(rawOutput, ["result", "text", "content"]), + code: readNumber(rawOutput, ["status", "code"]), + }; + case "think": + return { + type: "plain_text", + label: snapshot.title, + icon: "brain", + text: textContent ?? stringifyUnknown(snapshot.rawOutput), + }; + case "switch_mode": + return { + type: "plain_text", + label: snapshot.title, + icon: "sparkles", + text: textContent ?? stringifyUnknown(snapshot.rawInput), + }; + default: + if (terminalContent) { + return { + type: "shell", + command: terminalContent.command ?? snapshot.title, + cwd: terminalContent.cwd, + output: terminalContent.output, + exitCode: terminalContent.exitCode, + }; + } + if (textContent) { + return { + type: "plain_text", + label: snapshot.title, + text: textContent, + icon: "wrench", + }; + } + return { + type: "unknown", + input: snapshot.rawInput ?? null, + output: snapshot.rawOutput ?? null, + }; + } +} + +function extractToolText(content: ToolCallContent[] | null | undefined): string | undefined { + if (!content) { + return undefined; + } + const parts: string[] = []; + for (const item of content) { + if (item.type === "content") { + const text = contentBlockToText(item.content); + if (text) { + parts.push(text); + } + } + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function extractDiffContent( + content: ToolCallContent[] | null | undefined, +): { oldText?: string | null; newText: string } | null { + const diff = content?.find((item): item is Extract => item.type === "diff"); + return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null; +} + +function extractTerminalContent( + content: ToolCallContent[] | null | undefined, + terminals: Map, +): + | { + command?: string; + cwd?: string; + output?: string; + exitCode?: number | null; + } + | undefined { + const terminal = content?.find( + (item): item is Extract => item.type === "terminal", + ); + if (!terminal) { + return undefined; + } + const entry = terminals.get(terminal.terminalId); + if (!entry) { + return undefined; + } + return { + output: entry.output, + exitCode: entry.exit?.exitCode ?? null, + }; +} + +function mapPermissionRequest( + provider: string, + requestId: string, + params: RequestPermissionRequest, + snapshot: ACPToolSnapshot, +): AgentPermissionRequest { + const kind: AgentPermissionRequestKind = snapshot.kind === "switch_mode" ? "mode" : "tool"; + return { + id: requestId, + provider, + name: snapshot.kind ?? snapshot.title, + kind, + title: params.toolCall.title ?? snapshot.title, + detail: mapToolDetail(snapshot, new Map()), + metadata: { + toolCallId: params.toolCall.toolCallId, + rawRequest: params, + options: params.options, + }, + }; +} + +function selectPermissionOption( + options: PermissionOption[], + response: AgentPermissionResponse, +): PermissionOption | null { + const order = + response.behavior === "allow" + ? ["allow_once", "allow_always"] + : ["reject_once", "reject_always"]; + for (const kind of order) { + const match = options.find((option) => option.kind === kind); + if (match) { + return match; + } + } + return null; +} + +function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { + entry.output += chunk; + const limit = entry.outputByteLimit; + if (!limit) { + return; + } + while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { + entry.output = entry.output.slice(1); + entry.truncated = true; + } +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString( + record: Record | null, + keys: string[], +): string | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function readNumber( + record: Record | null, + keys: string[], +): number | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +function buildShellCommand(record: Record | null): string | undefined { + if (!record) { + return undefined; + } + const command = readString(record, ["command"]); + const args = Array.isArray(record["args"]) + ? record["args"].filter((value): value is string => typeof value === "string") + : []; + if (!command) { + return undefined; + } + return args.length > 0 ? `${command} ${args.join(" ")}` : command; +} + +function readErrorMessage(value: unknown): string { + if (typeof value === "string") { + return value; + } + const record = readRecord(value); + return readString(record, ["message", "error"]) ?? "Tool call failed"; +} + +function stringifyUnknown(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function coerceSessionConfigMetadata(metadata: AgentMetadata | undefined): Partial { + if (!metadata || typeof metadata !== "object") { + return {}; + } + return metadata as Partial; +} + +async function waitForChildExit( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } +} diff --git a/packages/server/src/server/agent/providers/claude-acp-agent.ts b/packages/server/src/server/agent/providers/claude-acp-agent.ts new file mode 100644 index 000000000..944f06fac --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-acp-agent.ts @@ -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 { + if (!(await super.isAvailable())) { + return false; + } + return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]); + } +} diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 450ec4b96..9cde504e9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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 | null = null; - let promptNextPromise: Promise> | 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"); }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index fa8fb3674..34ef0ad91 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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 { - const input = createAsyncMessageInput(); - 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 { + 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", diff --git a/packages/server/src/server/agent/providers/claude/claude-models.test.ts b/packages/server/src/server/agent/providers/claude/claude-models.test.ts new file mode 100644 index 000000000..fb72f2337 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/claude-models.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/claude-models.ts b/packages/server/src/server/agent/providers/claude/claude-models.ts new file mode 100644 index 000000000..08c14071e --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/claude-models.ts @@ -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}`; +} diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts deleted file mode 100644 index 9224d1ac2..000000000 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts +++ /dev/null @@ -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", - }), - ]); - }); -}); diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts deleted file mode 100644 index b2cf28eb9..000000000 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts +++ /dev/null @@ -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(); - - 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); -} diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts new file mode 100644 index 000000000..e282c51cf --- /dev/null +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -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 { + return super.isAvailable(); + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f39d62fd4..f045156e2 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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 diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 68ef52781..a0a5dddf8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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; diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index d4de333be..803450f26 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -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", +]; diff --git a/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts new file mode 100644 index 000000000..49f158613 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-acp.real.e2e.test.ts @@ -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, + ); +}); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index ae059eabd..cc07163ba 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -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, diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index badb1a11e..e1549e193 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -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 }); + } + }); }); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index b4ff4b5e5..50f139d74 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -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({ diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 81d48b351..1d3eccf3d 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -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()), diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index c5aa6fb33..3d16133a5 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -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): LoggerLike; + error(...args: any[]): void; +}; + +function getLogger(logger: LoggerLike): LoggerLike { + return logger.child({ module: "persistence" }); +} + +type AgentStoragePersistence = Pick; +type AgentManagerStateSource = Pick; + +/** + * 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 { return { cwd: record.cwd, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 46c84e02a..9bdc67332 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { 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 { 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 { + 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, }); diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index c36627d38..f4e56a5fe 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -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; diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index b52b4e81a..52a43383f 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -609,7 +609,7 @@ program // Agent runner interface AgentConfig { - cli: "claude" | "codex"; + cli: string; model?: string; effort?: string; } diff --git a/packages/server/src/tasks/types.ts b/packages/server/src/tasks/types.ts index 75689fd8a..5a054b550 100644 --- a/packages/server/src/tasks/types.ts +++ b/packages/server/src/tasks/types.ts @@ -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}`; diff --git a/packages/website/package.json b/packages/website/package.json index 200f49fc8..8f57d1fd3 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.43-rc.1", + "version": "0.1.43", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 1659da1b2..a4e12119a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -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",