Merge branch 'main' into merge/main-into-dev
Brings in 8 main commits (0.1.48 release, provider overhaul, keyboard shortcuts, desktop login shell env, question form Enter key) with proper git history. Resolves conflicts by keeping dev branch architecture where it subsumes main's changes, and main's provider snapshot COMPAT comments.
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
Generated
+22
-147
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.48",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AgentProvider, AgentProviderDefinition>;
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
allProviderEntries?: ProviderSnapshotEntry[];
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
@@ -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<string | undefined>(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<AgentProvider, AgentProviderDefinition>(
|
||||
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<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
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<AgentProvider, AgentProviderDefinition>(
|
||||
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<string, AgentModelDefinition[]>();
|
||||
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,
|
||||
|
||||
@@ -540,7 +540,6 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface DiagnosticsSectionProps {
|
||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||
isPlaybackTestRunning: boolean;
|
||||
|
||||
@@ -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<SessionStore>()(
|
||||
subscribeWithSelector((set, get) => {
|
||||
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
||||
@@ -531,12 +540,15 @@ export const useSessionStore = create<SessionStore>()(
|
||||
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<SessionStore>()(
|
||||
hostname: nextHostname,
|
||||
version: nextVersion,
|
||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||
...(nextFeatures ? { features: nextFeatures } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, string> | 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<string, string>;
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.48",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -47,6 +47,9 @@ import type {
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalState,
|
||||
CloseItemsResponse,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string> | null = null;
|
||||
|
||||
export function resolveShellEnv(): Record<string, string> {
|
||||
if (cachedShellEnv) return cachedShellEnv;
|
||||
try {
|
||||
cachedShellEnv = shellEnvSync();
|
||||
} catch {
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
if (cachedShellEnv) {
|
||||
return cachedShellEnv;
|
||||
}
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
@@ -167,11 +164,9 @@ const PARENT_SESSION_ENV_VARS = [
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
shellEnv?: Record<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
...(shellEnv ?? resolveShellEnv()),
|
||||
...(runtimeSettings?.env ?? {}),
|
||||
};
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
|
||||
@@ -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<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type MockProviderOptions = {
|
||||
provider: AgentProvider;
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
|
||||
};
|
||||
|
||||
type MockProviderHandle = {
|
||||
definition: ProviderDefinition;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
fetchModels: ReturnType<typeof vi.fn>;
|
||||
fetchModes: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<AgentModelDefinition[]>();
|
||||
const claudeModels = deferred<AgentModelDefinition[]>();
|
||||
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<AgentModelDefinition[]>();
|
||||
const claudeModels = deferred<AgentModelDefinition[]>();
|
||||
const codexModes = deferred<AgentMode[]>();
|
||||
const claudeModes = deferred<AgentMode[]>();
|
||||
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<AgentModelDefinition[]>>()
|
||||
.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<AgentModelDefinition[]>();
|
||||
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<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createRegistry(handles: MockProviderHandle[]): {
|
||||
registry: Record<AgentProvider, ProviderDefinition>;
|
||||
handles: Record<AgentProvider, MockProviderHandle>;
|
||||
} {
|
||||
return {
|
||||
registry: Object.fromEntries(
|
||||
handles.map((handle) => [handle.definition.id, handle.definition]),
|
||||
) as Record<AgentProvider, ProviderDefinition>,
|
||||
handles: Object.fromEntries(
|
||||
handles.map((handle) => [handle.definition.id, handle]),
|
||||
) as Record<AgentProvider, MockProviderHandle>,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
|
||||
): Promise<void> {
|
||||
// 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()) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PullRequestSta
|
||||
"--json",
|
||||
"url,title,state,baseRefName,headRefName,mergedAt",
|
||||
],
|
||||
{ cwd, env: { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" } },
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
);
|
||||
const pr = JSON.parse(stdout.trim());
|
||||
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
|
||||
|
||||
@@ -11,10 +11,8 @@ type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 <name>"` 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<string, string> | null = null;
|
||||
|
||||
export function resolveShellEnv(): Record<string, string> {
|
||||
if (cachedShellEnv) return cachedShellEnv;
|
||||
try {
|
||||
cachedShellEnv = shellEnvSync();
|
||||
} catch {
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
}
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user