diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a97f1ea..d1c22d94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.1.48 - 2026-04-05 + +### Added +- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available. +- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline. +- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow. +- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app. + +### Improved +- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile. +- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow. +- Removed noisy agent lifecycle toasts that fired on every state change. + +### Fixed +- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution. +- Input field on running agent screens now correctly receives keyboard focus. +- Mobile model selector alignment and sizing. + ## 0.1.47 - 2026-04-05 ### Fixed diff --git a/nix/package.nix b/nix/package.nix index eec96c76f..b136d5e96 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-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs="; + npmDepsHash = "sha256-epuepM6DMEpOfzvPiS6K/qKJqGM89FqdsKHovWi2LS8="; # 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 e9cce9cb5..6686b0a1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -16455,18 +16455,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-shell": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz", - "integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -19693,35 +19681,6 @@ "dev": true, "license": "MIT" }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -23028,18 +22987,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -23757,15 +23704,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -24494,6 +24432,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -27733,6 +27672,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -28651,18 +28591,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -28888,6 +28816,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -32350,50 +32279,6 @@ "node": ">=8" } }, - "node_modules/shell-env": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz", - "integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==", - "license": "MIT", - "dependencies": { - "default-shell": "^2.0.0", - "execa": "^5.1.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/shell-env/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/shell-env/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -33369,15 +33254,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -36345,16 +36221,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/highlight": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/expo-two-way-audio": "0.1.48", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/server": "0.1.48", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -36471,11 +36347,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/relay": "0.1.48", + "@getpaseo/server": "0.1.48", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36516,11 +36392,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.47", + "version": "0.1.48", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/cli": "0.1.48", + "@getpaseo/server": "0.1.48", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -36554,7 +36430,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.47", + "version": "0.1.48", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -36755,7 +36631,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -36781,7 +36657,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -36797,14 +36673,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/relay": "0.1.47", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/relay": "0.1.48", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -36827,7 +36703,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", @@ -37208,7 +37083,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 12e0c3357..6ccb4d380 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index bbf1ee2ca..cdaf19c1b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/highlight": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/expo-two-way-audio": "0.1.48", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/server": "0.1.48", "@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/composer.tsx b/packages/app/src/components/composer.tsx index 0636c26d4..a30d59139 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -425,6 +425,10 @@ export function Composer({ } switch (action.id) { + case "message-input.send": + return messageInputRef.current?.runKeyboardAction("send") ?? false; + case "message-input.dictation-confirm": + return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.focus": if (Platform.OS !== "web") { messageInputRef.current?.focus(); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 42b095786..a88b12427 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1136,6 +1136,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider hostname: serverInfo.hostname, version: serverInfo.version, ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), + ...(serverInfo.features ? { features: serverInfo.features } : {}), }); return; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index b72daa6cc..09cecb11c 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -8,10 +8,11 @@ import type { AgentMode, AgentModelDefinition, AgentProvider, + ProviderSnapshotEntry, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useProviderModels } from "@/hooks/use-provider-models"; +import { useSessionForServer } from "./use-session-directory"; import { useFormPreferences, mergeProviderPreferences, @@ -85,6 +86,7 @@ export type UseAgentFormStateResult = { providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; + allProviderEntries?: ProviderSnapshotEntry[]; modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; allProviderModels: Map; @@ -320,6 +322,10 @@ function combineInitialValues( return initialValues; } +function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) { + return ["providersSnapshot", serverId, cwd ?? ""] as const; +} + export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, @@ -337,6 +343,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } = useFormPreferences(); const daemons = useHosts(); + const queryClient = useQueryClient(); // Build a set of valid server IDs for preference validation const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); @@ -372,11 +379,138 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // Session state for provider model listing const client = useHostRuntimeClient(formState.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); + const supportsProvidersSnapshot = useSessionForServer( + formState.serverId, + (session) => session?.serverInfo?.features?.providersSnapshot === true, + ); + const [debouncedCwd, setDebouncedCwd] = useState(undefined); + useEffect(() => { + const trimmed = formState.workingDir.trim(); + const next = trimmed.length > 0 ? trimmed : undefined; + const timer = setTimeout(() => setDebouncedCwd(next), 180); + return () => clearTimeout(timer); + }, [formState.workingDir]); + + const snapshotQueryKey = useMemo( + () => providersSnapshotQueryKey(formState.serverId, debouncedCwd), + [debouncedCwd, formState.serverId], + ); + + const providersSnapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + enabled: Boolean( + supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, + ), + staleTime: 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getProvidersSnapshot({ cwd: debouncedCwd }); + }, + }); + + useEffect(() => { + if ( + !supportsProvidersSnapshot || + !client || + !isConnected || + !isVisible || + !isTargetDaemonReady || + !formState.serverId + ) { + return; + } + + return client.on("providers_snapshot_update", (message) => { + if (message.type !== "providers_snapshot_update") { + return; + } + if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) { + return; + } + queryClient.setQueryData(snapshotQueryKey, { + entries: message.payload.entries, + generatedAt: message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); + }); + }, [ + client, + debouncedCwd, + formState.serverId, + isConnected, + isTargetDaemonReady, + isVisible, + queryClient, + snapshotQueryKey, + supportsProvidersSnapshot, + ]); + + const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined; + const allProviderEntries = useMemo( + () => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined), + [snapshotEntries, supportsProvidersSnapshot], + ); + const snapshotProviderDefinitions = useMemo(() => { + if (!supportsProvidersSnapshot) { + return []; + } + const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); + return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); + }, [snapshotEntries, supportsProvidersSnapshot]); + const snapshotProviderDefinitionMap = useMemo( + () => + new Map( + snapshotProviderDefinitions.map((definition) => [definition.id, definition]), + ), + [snapshotProviderDefinitions], + ); + const snapshotSelectableProviderDefinitionMap = useMemo(() => { + const readyProviders = new Set( + (snapshotEntries ?? []) + .filter((entry) => entry.status === "ready") + .map((entry) => entry.provider), + ); + return new Map( + snapshotProviderDefinitions + .filter((definition) => readyProviders.has(definition.id)) + .map((definition) => [definition.id, definition]), + ); + }, [snapshotEntries, snapshotProviderDefinitions]); + const snapshotAllProviderModels = useMemo(() => { + const map = new Map(); + for (const entry of snapshotEntries ?? []) { + map.set(entry.provider, entry.models ?? []); + } + return map; + }, [snapshotEntries]); + const snapshotSelectedEntry = useMemo( + () => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null, + [formState.provider, snapshotEntries], + ); + const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; + const snapshotSelectedProviderModes = + snapshotSelectedEntry?.modes ?? + snapshotProviderDefinitionMap.get(formState.provider)?.modes ?? + []; + + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], enabled: Boolean( - isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, + !supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, ), staleTime: 60 * 1000, queryFn: async () => { @@ -390,31 +524,133 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); }, }); - - const providerDefinitions = useMemo(() => { + const legacyProviderDefinitions = useMemo(() => { const availableProviders = availableProvidersQuery.data; if (!availableProviders) { return []; } const available = new Set(availableProviders); - return allProviderDefinitions.filter((definition) => - available.has(definition.id as AgentProvider), - ); + return allProviderDefinitions.filter((definition) => available.has(definition.id)); }, [availableProvidersQuery.data]); - - const providerDefinitionMap = useMemo( + const legacyProviderDefinitionMap = useMemo( () => new Map( - providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]), + legacyProviderDefinitions.map((definition) => [definition.id, definition]), ), - [providerDefinitions], + [legacyProviderDefinitions], ); - const { allProviderModels, isLoading: isAllModelsLoading } = useProviderModels( - formState.serverId ?? "", - ); + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots + const legacySelectedProviderModelsQuery = useQuery({ + queryKey: ["providerModels", formState.serverId, formState.provider], + enabled: Boolean( + !supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected && + legacyProviderDefinitionMap.has(formState.provider), + ), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModels(formState.provider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.models ?? []; + }, + }); + const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null; - const availableModels = allProviderModels.get(formState.provider) ?? null; + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots + const providerModesQuery = useQuery({ + queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd], + enabled: Boolean( + !supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected && + legacyProviderDefinitionMap.has(formState.provider), + ), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModes(formState.provider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.modes ?? []; + }, + }); + + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots + const allProviderModelQueries = useQueries({ + queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({ + queryKey: ["providerModels", formState.serverId, def.id], + enabled: Boolean( + isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, + ), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModels(def.id as AgentProvider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.models ?? []; + }, + })), + }); + const legacyAllProviderModels = useMemo(() => { + const map = new Map(); + for (let i = 0; i < legacyProviderDefinitions.length; i++) { + const query = allProviderModelQueries[i]; + if (query?.data) { + map.set(legacyProviderDefinitions[i]!.id, query.data); + } + } + return map; + }, [allProviderModelQueries, legacyProviderDefinitions]); + const legacySelectedProviderModes = + providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? []; + + const providerDefinitions = supportsProvidersSnapshot + ? snapshotProviderDefinitions + : legacyProviderDefinitions; + const providerDefinitionMap = supportsProvidersSnapshot + ? snapshotProviderDefinitionMap + : legacyProviderDefinitionMap; + const selectableProviderDefinitionMap = supportsProvidersSnapshot + ? snapshotSelectableProviderDefinitionMap + : legacyProviderDefinitionMap; + const allProviderModels = supportsProvidersSnapshot + ? snapshotAllProviderModels + : legacyAllProviderModels; + const availableModels = supportsProvidersSnapshot + ? snapshotSelectedProviderModels + : legacySelectedProviderModels; + const modeOptions = supportsProvidersSnapshot + ? snapshotSelectedProviderModes + : legacySelectedProviderModes; + const isAllModelsLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : allProviderModelQueries.some((q) => q.isLoading); // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -439,7 +675,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg userModified, formStateRef.current, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ); // Only update if something changed @@ -464,7 +700,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg availableModels, userModified, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ]); // Auto-select the first online host when: @@ -504,8 +740,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setProviderFromUser = useCallback( (provider: AgentProvider) => { + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } const providerModels = allProviderModels.get(provider) ?? null; - const providerDef = providerDefinitionMap.get(provider); + const providerDef = selectableProviderDefinitionMap.get(provider); const providerPrefs = preferences?.providerPreferences?.[provider]; const isValidModel = (m: string) => @@ -543,12 +782,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg thinkingOptionId: nextThinkingOptionId, })); }, - [allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences], + [ + allProviderModels, + preferences?.providerPreferences, + selectableProviderDefinitionMap, + updatePreferences, + ], ); const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { - const providerDef = providerDefinitionMap.get(provider); + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } + const providerDef = selectableProviderDefinitionMap.get(provider); const providerModels = allProviderModels.get(provider) ?? null; const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); @@ -568,7 +815,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setUserModified((prev) => ({ ...prev, provider: true, model: true })); void updatePreferences({ provider }); }, - [allProviderModels, providerDefinitionMap, updatePreferences], + [allProviderModels, selectableProviderDefinitionMap, updatePreferences], ); const setModeFromUser = useCallback( @@ -624,12 +871,16 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setFormState((prev) => ({ ...prev, serverId: value })); }, []); - const queryClient = useQueryClient(); const refreshProviderModels = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: ["providerModels", formState.serverId, formState.provider], - }); - }, [queryClient, formState.serverId, formState.provider]); + if (supportsProvidersSnapshot) { + if (!client) { + return; + } + void client.refreshProvidersSnapshot({ cwd: debouncedCwd }); + return; + } + void legacySelectedProviderModelsQuery.refetch(); + }, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -662,12 +913,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ]); const agentDefinition = providerDefinitionMap.get(formState.provider); - const modeOptions = agentDefinition?.modes ?? []; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = !availableModels && isAllModelsLoading; - const modelError: string | null = null; + const isModelLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; + const modelError = + supportsProvidersSnapshot + ? providersSnapshotQuery.error instanceof Error + ? providersSnapshotQuery.error.message + : null + : legacySelectedProviderModelsQuery.error instanceof Error + ? legacySelectedProviderModelsQuery.error.message + : null; const workingDirIsEmpty = !formState.workingDir.trim(); @@ -690,6 +949,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels: availableModels ?? [], allProviderModels, @@ -720,6 +980,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels, allProviderModels, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index ff41fbd72..f098f6ca7 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -540,7 +540,6 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { ); } - interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 3a08b4acd..c9aa3f093 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -21,6 +21,7 @@ import type { import type { FileDownloadTokenResponse, GitSetupOptions, + ServerInfoStatusPayload, ProjectPlacementPayload, ServerCapabilities, AgentSnapshotPayload, @@ -195,6 +196,7 @@ export type DaemonServerInfo = { hostname: string | null; version: string | null; capabilities?: ServerCapabilities; + features?: ServerInfoStatusPayload["features"]; }; export interface AgentTimelineCursorState { @@ -416,6 +418,13 @@ function areServerCapabilitiesEqual( return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); } +function areServerInfoFeaturesEqual( + current: ServerInfoStatusPayload["features"] | undefined, + next: ServerInfoStatusPayload["features"] | undefined, +): boolean { + return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); +} + export const useSessionStore = create()( subscribeWithSelector((set, get) => { const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { @@ -531,12 +540,15 @@ export const useSessionStore = create()( const prevVersion = session.serverInfo?.version?.trim() || null; const nextCapabilities = info.capabilities; const prevCapabilities = session.serverInfo?.capabilities; + const nextFeatures = info.features; + const prevFeatures = session.serverInfo?.features; if ( session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname && prevVersion === nextVersion && - areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) + areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) && + areServerInfoFeaturesEqual(prevFeatures, nextFeatures) ) { return prev; } @@ -552,6 +564,7 @@ export const useSessionStore = create()( hostname: nextHostname, version: nextVersion, ...(nextCapabilities ? { capabilities: nextCapabilities } : {}), + ...(nextFeatures ? { features: nextFeatures } : {}), }, }, }, diff --git a/packages/cli/package.json b/packages/cli/package.json index a0008a23b..0219ade5d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/relay": "0.1.48", + "@getpaseo/server": "0.1.48", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 651eeca73..39fd3571f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/cli": "0.1.48", + "@getpaseo/server": "0.1.48", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/desktop/src/login-shell-env.ts b/packages/desktop/src/login-shell-env.ts new file mode 100644 index 000000000..448ed3c24 --- /dev/null +++ b/packages/desktop/src/login-shell-env.ts @@ -0,0 +1,113 @@ +// Shell environment resolution adapted from VS Code +// https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { userInfo } from "node:os"; +import { basename } from "node:path"; + +const RESOLVE_TIMEOUT_MS = 10_000; + +function getSystemShell(): string { + const shell = process.env.SHELL; + if (shell) return shell; + + try { + const info = userInfo(); + if (info.shell && info.shell !== "/bin/false") return info.shell; + } catch {} + + return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"; +} + +function resolveShellEnv(): Record | undefined { + if (process.platform === "win32") return undefined; + + const savedRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const savedNoAttach = process.env.ELECTRON_NO_ATTACH_CONSOLE; + + const mark = randomUUID().replace(/-/g, "").slice(0, 12); + const regex = new RegExp(mark + "({.*})" + mark); + + const shell = getSystemShell(); + const name = basename(shell); + + let command: string; + let shellArgs: string[]; + + if (/^(?:pwsh|powershell)(?:-preview)?$/.test(name)) { + command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; + shellArgs = ["-Login", "-Command"]; + } else if (name === "nu") { + command = `^'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + shellArgs = ["-i", "-l", "-c"]; + } else if (name === "xonsh") { + command = `import os, json; print("${mark}", json.dumps(dict(os.environ)), "${mark}")`; + shellArgs = ["-i", "-l", "-c"]; + } else { + command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + if (name === "tcsh" || name === "csh") { + shellArgs = ["-ic"]; + } else { + shellArgs = ["-i", "-l", "-c"]; + } + } + + const result = spawnSync(shell, [...shellArgs, command], { + encoding: "utf8", + timeout: RESOLVE_TIMEOUT_MS, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + }, + }); + + if (result.status !== 0 && result.status !== null) return undefined; + if (!result.stdout) return undefined; + + const match = regex.exec(result.stdout); + if (!match?.[1]) return undefined; + + try { + const env = JSON.parse(match[1]) as Record; + + if (savedRunAsNode) { + env.ELECTRON_RUN_AS_NODE = savedRunAsNode; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + + if (savedNoAttach) { + env.ELECTRON_NO_ATTACH_CONSOLE = savedNoAttach; + } else { + delete env.ELECTRON_NO_ATTACH_CONSOLE; + } + + delete env.XDG_RUNTIME_DIR; + + return env; + } catch { + return undefined; + } +} + +/** + * On macOS/Linux, Electron inherits a minimal environment when launched from + * Finder/Dock. Spawn the user's login shell and capture its full environment + * via Node's JSON.stringify(process.env), so the daemon and all child processes + * see the same tools and variables as a normal terminal session. + * + * Approach borrowed from VS Code (src/vs/platform/shell/node/shellEnv.ts). + */ +export function inheritLoginShellEnv(): void { + try { + const env = resolveShellEnv(); + if (env) { + Object.assign(process.env, env); + } + } catch { + // Keep inherited environment if shell lookup fails. + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 70f3f5489..d5e4106c5 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,6 +1,9 @@ import log from "electron-log/main"; log.initialize({ spyRendererConsole: true }); +import { inheritLoginShellEnv } from "./login-shell-env.js"; +inheritLoginShellEnv(); + import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 3319eb6c2..43e2e1144 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.47", + "version": "0.1.48", "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 ce858d240..0704b7a31 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.47", + "version": "0.1.48", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index a91b06fd6..aaa606040 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.47", + "version": "0.1.48", "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 8cbe3ba3e..e36d87c34 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.47", + "version": "0.1.48", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -65,8 +65,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.47", - "@getpaseo/relay": "0.1.47", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/relay": "0.1.48", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -89,7 +89,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 009d256f7..950ed22d9 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -47,6 +47,9 @@ import type { ProviderDiagnosticResponseMessage, ListTerminalsResponse, CreateTerminalResponse, + GetProvidersSnapshotResponseMessage, + ProviderDiagnosticResponseMessage, + RefreshProvidersSnapshotResponseMessage, SubscribeTerminalResponse, TerminalState, CloseItemsResponse, diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 8cb7aca71..4d96d37b9 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -66,7 +66,7 @@ describe("applyProviderEnv", () => { }, }; - const env = applyProviderEnv(base, runtime, {}); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/usr/bin"); expect(env.HOME).toBe("/custom/home"); @@ -74,21 +74,11 @@ describe("applyProviderEnv", () => { expect(Object.keys(env).length).toBeGreaterThanOrEqual(3); }); - test("shell env PATH wins over base env PATH", () => { - const base = { PATH: "/usr/bin:/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin" }; - - const env = applyProviderEnv(base, undefined, shellEnv); - - expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin"); - }); - - test("runtimeSettings env wins over shell env", () => { + test("runtimeSettings env wins over base env", () => { const base = { PATH: "/usr/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin" }; const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } }; - const env = applyProviderEnv(base, runtime, shellEnv); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/custom/path"); }); @@ -103,7 +93,7 @@ describe("applyProviderEnv", () => { CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true", }; - const env = applyProviderEnv(base, undefined, {}); + const env = applyProviderEnv(base); expect(env.PATH).toBe("/usr/bin"); expect(env.CLAUDECODE).toBeUndefined(); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index ae80f1d97..80019cc1d 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -2,7 +2,6 @@ import { execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; import path from "node:path"; -import { shellEnvSync } from "shell-env"; import { z } from "zod"; import type { AgentProvider } from "./agent-sdk-types.js"; @@ -144,12 +143,10 @@ export function resolveProviderCommandPrefix( let cachedShellEnv: Record | null = null; export function resolveShellEnv(): Record { - if (cachedShellEnv) return cachedShellEnv; - try { - cachedShellEnv = shellEnvSync(); - } catch { - cachedShellEnv = { ...process.env } as Record; + if (cachedShellEnv) { + return cachedShellEnv; } + cachedShellEnv = { ...process.env } as Record; return cachedShellEnv; } @@ -167,11 +164,9 @@ const PARENT_SESSION_ENV_VARS = [ export function applyProviderEnv( baseEnv: Record, runtimeSettings?: ProviderRuntimeSettings, - shellEnv?: Record, ): Record { const merged: Record = { ...baseEnv, - ...(shellEnv ?? resolveShellEnv()), ...(runtimeSettings?.env ?? {}), }; for (const key of PARENT_SESSION_ENV_VARS) { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts new file mode 100644 index 000000000..4ea80bf3b --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { + AgentClient, + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +type MockProviderOptions = { + provider: AgentProvider; + isAvailable?: () => Promise; + fetchModels?: (cwd?: string) => Promise; + fetchModes?: (cwd?: string) => Promise; +}; + +type MockProviderHandle = { + definition: ProviderDefinition; + isAvailable: ReturnType; + fetchModels: ReturnType; + fetchModes: ReturnType; +}; + +const TEST_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +describe("ProviderSnapshotManager", () => { + test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + const snapshot = manager.getSnapshot("/tmp/project"); + + expect(snapshot).toEqual([ + { provider: "claude", status: "loading" }, + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + }); + + manager.destroy(); + codexModels.resolve([]); + claudeModels.resolve([]); + }); + + test("after warmUp completes, getSnapshot returns ready entries with models", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.2")], + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [createModel("claude", "sonnet")], + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + const snapshot = manager.getSnapshot("/tmp/project"); + expect(getProviderEntry(snapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.2")], + modes: [createMode("auto")], + }); + expect(getProviderEntry(snapshot, "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet")], + modes: [createMode("default")], + }); + expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String)); + + manager.destroy(); + }); + + test("provider that fails isAvailable shows as unavailable", async () => { + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: async () => false, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "unavailable" }, + ]); + }); + + expect(handles.codex?.fetchModels).not.toHaveBeenCalled(); + expect(handles.codex?.fetchModes).not.toHaveBeenCalled(); + + manager.destroy(); + }); + + test("provider that fails fetchModels shows as error with error message", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => { + throw new Error("model lookup failed"); + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "codex", + status: "error", + error: "model lookup failed", + }, + ]); + }); + + manager.destroy(); + }); + + test("change event fires for each provider as it resolves", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const codexModes = deferred(); + const claudeModes = deferred(); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + fetchModes: async () => codexModes.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + fetchModes: async () => claudeModes.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + const changes: Array<{ cwd: string; entries: ProviderSnapshotEntry[] }> = []; + const listener = (entries: ProviderSnapshotEntry[], cwd: string) => { + changes.push({ cwd, entries }); + }; + manager.on("change", listener); + + manager.getSnapshot("/tmp/project"); + + claudeModels.resolve([createModel("claude", "sonnet")]); + claudeModes.resolve([createMode("default")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(1); + }); + + expect(changes[0]?.cwd).toBe("/tmp/project"); + expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); + expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + codexModes.resolve([createMode("auto")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(2); + }); + + expect(getProviderEntry(changes[1]?.entries ?? [], "codex")?.status).toBe("ready"); + expect(getProviderEntry(changes[1]?.entries ?? [], "claude")?.status).toBe("ready"); + + manager.off("change", listener); + manager.destroy(); + }); + + test("refresh re-fetches and updates entries", async () => { + const codexFetchModels = vi + .fn<(options?: { cwd?: string }) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => codexFetchModels({ cwd }), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + manager.refresh("/tmp/project"); + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { + const codexModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1); + }); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + manager.destroy(); + }); + + test("different cwd keys get independent snapshots", async () => { + const seenCwds: string[] = []; + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => { + seenCwds.push(cwd ?? "__missing__"); + return [createModel("codex", `model:${cwd}`)]; + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project-a"); + manager.getSnapshot("/tmp/project-b"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-a", + ); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-b", + ); + expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); + + manager.destroy(); + }); +}); + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createRegistry(handles: MockProviderHandle[]): { + registry: Record; + handles: Record; +} { + return { + registry: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle.definition]), + ) as Record, + handles: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle]), + ) as Record, + }; +} + +function createMockProvider(options: MockProviderOptions): MockProviderHandle { + const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true); + const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)], + ); + const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)], + ); + + const definition: ProviderDefinition = { + id: options.provider, + label: options.provider, + description: `${options.provider} test provider`, + defaultModeId: null, + modes: [], + createClient: () => + ({ + provider: options.provider, + capabilities: TEST_CAPABILITIES, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + async listModels() { + return []; + }, + async isAvailable() { + return isAvailable(); + }, + }) satisfies AgentClient, + fetchModels, + fetchModes, + }; + + return { + definition, + isAvailable, + fetchModels, + fetchModes, + }; +} + +function createModel(provider: AgentProvider, id: string): AgentModelDefinition { + return { + provider, + id, + label: id, + }; +} + +function createMode(id: string): AgentMode { + return { + id, + label: id, + }; +} + +function getProviderEntry( + entries: ProviderSnapshotEntry[], + provider: AgentProvider, +): ProviderSnapshotEntry | undefined { + return entries.find((entry) => entry.provider === provider); +} diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index db84e2260..417b9099f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -39,6 +39,12 @@ import { } from "./claude/claude-models.js"; import { parsePartialJsonObject } from "./claude/partial-json.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; import type { AgentCapabilityFlags, @@ -1085,6 +1091,42 @@ export class ClaudeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const resolvedBinary = findExecutable("claude") ?? "not found"; + const available = await this.isAvailable(); + const version = resolveClaudeVersion(this.runtimeSettings); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + return { + diagnostic: formatProviderDiagnostic("Claude Code", [ + { label: "Binary", value: resolvedBinary }, + ...(version ? [{ label: "Version", value: version }] : []), + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Claude Code", error), + }; + } + } + private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig { if (config.provider !== "claude") { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); @@ -1093,6 +1135,31 @@ export class ClaudeAgentClient implements AgentClient { } } +function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null { + const command = runtimeSettings?.command; + + try { + if (command?.mode === "replace") { + return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } + + const executable = findExecutable("claude"); + if (!executable) { + return null; + } + + return execFileSync(executable, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } catch { + return null; + } +} + class ClaudeAgentSession implements AgentSession { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bf8d3e086..1b49b62df 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,6 +52,13 @@ import { } from "../../../utils/executable.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -3947,6 +3954,49 @@ export class CodexAppServerAgentClient implements AgentClient { } return true; } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("codex"); + const entries: Array<{ label: string; value: string }> = [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + ]; + let status = formatDiagnosticStatus(available); + + if (!available) { + entries.push({ label: "Models", value: "Not checked" }); + } else { + try { + const models = await this.listModels(); + entries.push({ label: "Models", value: String(models.length) }); + } catch (error) { + entries.push({ + label: "Models", + value: `Error - ${toDiagnosticErrorMessage(error)}`, + }); + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + entries.push({ label: "Status", value: status }); + + return { + diagnostic: formatProviderDiagnostic("Codex", entries), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Codex", error), + }; + } + } } export const __codexAppServerInternals = { diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index e282c51cf..395508336 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -2,7 +2,15 @@ import type { Logger } from "pino"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { findExecutable } from "../../../utils/executable.js"; import { ACPAgentClient } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const COPILOT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -51,4 +59,53 @@ export class CopilotACPAgentClient extends ACPAgentClient { override async isAvailable(): Promise { return super.isAvailable(); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("copilot"); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Copilot", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Copilot", error), + }; + } + } } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d0f47b3e5..c022caa57 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -43,6 +43,13 @@ import { quoteWindowsCommand, } from "../../../utils/executable.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -615,6 +622,63 @@ export class OpenCodeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("opencode"); + let serverStatus = "Not running"; + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + try { + const { url } = await this.serverManager.ensureRunning(); + serverStatus = `Running (${url})`; + } catch (error) { + serverStatus = `Unavailable (${normalizeTurnFailureError(error)})`; + } + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("OpenCode", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Server", value: serverStatus }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("OpenCode", error), + }; + } + } private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index baab3b48a..7b3d99ad1 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -29,12 +29,19 @@ import type { AgentFeatureSelect, } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse, } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const require = createRequire(import.meta.url); const resolvedPiAcpPath = require.resolve("pi-acp"); @@ -329,4 +336,85 @@ export class PiACPAgentClient extends ACPAgentClient { existsSync(join(homedir(), ".pi", "agent", "auth.json")) ); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi"; + const piCliPath = findExecutable(piCommand); + const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown"; + const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); + const available = await this.isAvailable(); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Pi", [ + { + label: "pi-acp module", + value: existsSync(resolvedPiAcpPath) ? "found" : "not found", + }, + { + label: "Binary", + value: piCliPath ?? "not found", + }, + { + label: "Version", + value: piVersion, + }, + { + label: "OPENAI_API_KEY", + value: process.env.OPENAI_API_KEY ? "set" : "not set", + }, + { + label: "ANTHROPIC_API_KEY", + value: process.env.ANTHROPIC_API_KEY ? "set" : "not set", + }, + { + label: "OPENROUTER_API_KEY", + value: process.env.OPENROUTER_API_KEY ? "set" : "not set", + }, + { + label: "Auth config (~/.pi/agent/auth.json)", + value: existsSync(authConfigPath) ? "found" : "not found", + }, + { + label: "Models", + value: modelsValue, + }, + { + label: "Status", + value: status, + }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Pi", error), + }; + } + } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 93cd4411c..4368fdadc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -682,6 +682,7 @@ export class Session { } if (this.providerSnapshotManager) { const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. const visibleEntries = entries.filter((entry) => this.isProviderVisibleToClient(entry.provider), ); @@ -3140,6 +3141,7 @@ export class Session { private async handleGetProvidersSnapshotRequest( msg: Extract, ): Promise { + // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. const entries = this.providerSnapshotManager ? this.providerSnapshotManager .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) @@ -6981,6 +6983,10 @@ export class Session { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } + if (this.unsubscribeProviderSnapshotEvents) { + this.unsubscribeProviderSnapshotEvents(); + this.unsubscribeProviderSnapshotEvents = null; + } // Abort any ongoing operations this.abortController.abort(); @@ -7017,10 +7023,6 @@ export class Session { this.unsubscribeTerminalsChanged(); this.unsubscribeTerminalsChanged = null; } - if (this.unsubscribeProviderSnapshotEvents) { - this.unsubscribeProviderSnapshotEvents(); - this.unsubscribeProviderSnapshotEvents = null; - } this.subscribedTerminalDirectories.clear(); for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0573c655e..df5f5a523 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -830,6 +830,10 @@ export class VoiceAssistantWebSocketServer { hostname: getHostname(), version: this.daemonVersion, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), + features: { + // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. + providersSnapshot: true, + }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1a801833e..65fe21fd3 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1554,6 +1554,12 @@ export const ServerInfoStatusPayloadSchema = z hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), capabilities: ServerCapabilitiesFromUnknownSchema, + // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot + features: z + .object({ + providersSnapshot: z.boolean().optional(), + }) + .optional(), }) .passthrough() .transform((payload) => ({ @@ -2323,6 +2329,7 @@ export const ListAvailableProvidersResponseSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot export const GetProvidersSnapshotResponseMessageSchema = z.object({ type: z.literal("get_providers_snapshot_response"), payload: z.object({ @@ -2332,6 +2339,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ @@ -2341,6 +2349,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot export const RefreshProvidersSnapshotResponseMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_response"), payload: z.object({ @@ -2349,6 +2358,7 @@ export const RefreshProvidersSnapshotResponseMessageSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot export const ProviderDiagnosticResponseMessageSchema = z.object({ type: z.literal("provider_diagnostic_response"), payload: z.object({ diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 37812924f..3745731ca 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -6,7 +6,7 @@ import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; -import { findExecutable, resolveShellEnv } from "./executable.js"; +import { findExecutable } from "./executable.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; @@ -1845,7 +1845,7 @@ export async function createPullRequest( await execAsync(`git push -u origin ${head}`, { cwd }); - const ghEnv = { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" }; + const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; args.push("-f", `head=${head}`); args.push("-f", `base=${normalizedBase}`); @@ -1912,7 +1912,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise[ function createFindExecutableDependencies(): FindExecutableDependencies { return { execFileSync: vi.fn(), - execSync: vi.fn(), existsSync: vi.fn(), platform: vi.fn(() => "darwin"), - shell: undefined, }; } @@ -77,25 +75,25 @@ describe("findExecutable", () => { expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); }); - test("uses the last line from login-shell which output", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue( - "echo from profile\n/usr/local/bin/codex\n", + test("on Unix, uses the last line from which output", () => { + findExecutableDependencies.execFileSync.mockReturnValue( + "/usr/local/bin/codex\n", ); expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce(); - expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled(); + expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( + "which", + ["codex"], + { encoding: "utf8" }, + ); }); test("warns and returns null when the final which line is not an absolute path", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n"); findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(findExecutable("codex", findExecutableDependencies)).toBeNull(); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 4c5fd61a8..57bf5162d 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,15 +1,11 @@ -import { execFileSync, execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; import path from "node:path"; -import { shellEnvSync } from "shell-env"; - export interface FindExecutableDependencies { - execSync: typeof execSync; execFileSync: typeof execFileSync; existsSync: typeof existsSync; platform: typeof platform; - shell: string | undefined; } function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { @@ -42,7 +38,7 @@ function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { function resolveExecutableFromWhichOutput( name: string, output: string, - source: "login-shell" | "which", + source: "which", ): string | null { const lines = output .split(/\r?\n/) @@ -65,10 +61,8 @@ function resolveExecutableFromWhichOutput( } /** - * On Unix we first try `$SHELL -lic "which "` so that rc-file PATH - * additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the - * user opened a terminal and typed the command. If that fails (e.g. the login - * shell itself errors) we fall back to a plain `which`. + * On Unix we use plain `which` — the daemon's process.env.PATH is enriched + * with the login shell environment at Electron desktop startup. * * On Windows we augment the daemon PATH with machine/user registry PATH values * and return the first `where.exe` match. Launch-time execution decides whether @@ -85,11 +79,9 @@ export function findExecutable( } const deps: FindExecutableDependencies = { - execSync, execFileSync, existsSync, platform, - shell: process.env["SHELL"], ...dependencies, }; @@ -125,25 +117,6 @@ export function findExecutable( } } - // Unix: try the user's login shell so rc-file PATH entries are visible. - const shell = deps.shell; - if (shell) { - try { - const out = deps - .execSync(`${shell} -lic "which ${trimmed}"`, { - encoding: "utf8", - timeout: 5000, - }) - .trim(); - const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell"); - if (resolved) { - return resolved; - } - } catch { - // Login shell failed (broken rc, etc.) — fall through to plain which. - } - } - try { return resolveExecutableFromWhichOutput( trimmed, @@ -184,14 +157,3 @@ export function quoteWindowsArgument(argument: string): string { return `"${argument}"`; } -let cachedShellEnv: Record | null = null; - -export function resolveShellEnv(): Record { - if (cachedShellEnv) return cachedShellEnv; - try { - cachedShellEnv = shellEnvSync(); - } catch { - cachedShellEnv = { ...process.env } as Record; - } - return cachedShellEnv; -} diff --git a/packages/website/package.json b/packages/website/package.json index ba65a9033..9e82cb856 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.47", + "version": "0.1.48", "private": true, "type": "module", "scripts": {