diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8fcf70..0c764f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.16] - 2026-07-31 + +### Added + +- 🧰 **Tool servers can now carry their own environment settings.** Add extra environment values for local tool servers from Admin, with matching translations across supported languages. +- 🌐 **OpenCode over Docker is easier to set up.** The README now shows how to connect a host-running OpenCode server from a Docker install. + +### Changed + +- 🤖 **Claude Code conversations continue more smoothly.** Computer keeps Claude Code sessions warm between turns when the setup is unchanged, so follow-up prompts feel more natural. +- 🧭 **Agent model choices are less fussy.** Saved agent models and newly detected models are combined, so custom setups are less likely to disappear from the picker. +- 💬 **OpenAI-style chat requests return a full answer unless streaming is requested.** This better matches clients that expect a single response by default. + +### Fixed + +- 🔐 **Trusted-header sign-in is more dependable.** Computer now recognizes the configured header during session checks and keeps signed-in users attached to their account. +- 🧹 **Deleted agent profiles stay deleted after saving.** Removing a saved agent no longer gets skipped just because no new profiles were added. +- 🏠 **Workspace names are preserved when workspace details update.** Updating tabs or layout no longer quietly falls back to the folder name. +- 📝 **Lists appear correctly while writing.** Bulleted and numbered lists now show their markers in chat and rich text editors. +- 🗂️ **Git paths are easier to read.** File names with non-English characters now appear as themselves instead of escaped text. +- 🧩 **Quoted tool-server commands work more reliably.** Local tool servers can start even when the saved command was wrapped in quotes. +- 📦 **Optional tool-server support stays on the supported version range.** Fresh installs avoid incompatible future releases. + ## [0.9.15] - 2026-07-23 ### Fixed diff --git a/README.md b/README.md index 9a7302db..8f7c7502 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,14 @@ The default image includes Git and GitHub CLI (`gh`). Use `ghcr.io/open-webui/co If you bind-mount a host directory to `/data`, make sure that directory is writable by the container user. SQLite needs to create and update `/data/app.db`, and host directory permissions take precedence over the image's built-in `/data` ownership. +To connect a host-running OpenCode server from Docker, start OpenCode on a host-reachable interface and set the OpenCode agent Server URL to the Docker host address: + +```bash +opencode serve --hostname 0.0.0.0 --port 4096 +``` + +Use `http://host.docker.internal:4096` on Docker Desktop. On Linux, add `--add-host=host.docker.internal:host-gateway` to `docker run` if that hostname is not already available. + The `:dev` image is also available and tracks the `main` branch. ## Air-gapped installation diff --git a/cptr/frontend/src/lib/apis/index.ts b/cptr/frontend/src/lib/apis/index.ts index c4d81d5f..2892fa65 100644 --- a/cptr/frontend/src/lib/apis/index.ts +++ b/cptr/frontend/src/lib/apis/index.ts @@ -23,7 +23,7 @@ export async function fetchJSON(path: string, init?: RequestInit): const res = await fetchHandler(path, init); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new ApiError(res.status, data.detail || data.error || res.statusText); + throw new ApiError(res.status, data.detail || data.error || data.message || res.statusText); } return res.json(); } diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte index f0819519..889acea7 100644 --- a/cptr/frontend/src/lib/components/Admin/Agents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte @@ -27,6 +27,7 @@ let available = $state>({}); let saved = $state>({}); let modal = $state(null); + let deletedProfile = $state(false); const statusLabelKey: Record = { ready: 'admin.agentsStatusReady', @@ -62,6 +63,7 @@ detected = Object.fromEntries(data.profiles.map((entry) => [entry.id, entry.detected])); available = Object.fromEntries(data.profiles.map((entry) => [entry.id, entry.available])); saved = Object.fromEntries(data.profiles.map((entry) => [entry.id, !entry.implicit])); + deletedProfile = false; } function newProfile(agent: AgentProfile['agent'] = 'codex'): AgentProfile { @@ -176,6 +178,7 @@ if (profile) { const { [profile.id]: _removed, ...rest } = saved; saved = rest; + deletedProfile = true; } modal = null; } @@ -189,8 +192,10 @@ } async function save() { - const savedProfiles = profiles.filter((profile) => saved[profile.id]); - if (savedProfiles.length === 0) { + const savedProfiles = deletedProfile + ? profiles + : profiles.filter((profile) => saved[profile.id]); + if (savedProfiles.length === 0 && !deletedProfile) { toast.success($t('settings.saved')); return; } diff --git a/cptr/frontend/src/lib/components/Admin/ToolServers.svelte b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte index 1dac2f95..0e1f7022 100644 --- a/cptr/frontend/src/lib/components/Admin/ToolServers.svelte +++ b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte @@ -36,6 +36,7 @@ let formCommand = $state(''); let formArgs = $state(''); let formCwd = $state(''); + let formEnv = $state(''); let saving = $state(false); let verifying = $state(false); @@ -43,6 +44,46 @@ // Verify result let verifyResult = $state<{ ok: boolean; tools?: any[]; message?: string } | null>(null); + function splitArgs(value: string): string[] { + const args: string[] = []; + let current = ''; + let quote: string | null = null; + let started = false; + + const input = value.trim(); + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]; + const next = input[i + 1]; + if (quote) { + started = true; + if (ch === quote) quote = null; + else if (ch === '\\' && (next === quote || next === '\\')) current += input[++i]; + else current += ch; + } else if (ch === '"' || ch === "'") { + started = true; + quote = ch; + } else if (/\s/.test(ch)) { + if (started) { + args.push(current); + current = ''; + started = false; + } + } else { + started = true; + current += ch; + } + } + + if (started) args.push(current); + return args; + } + + function joinArgs(args: string[]): string { + return args + .map((arg) => (!arg || /\s|["']/.test(arg) ? `"${arg.replace(/(["\\])/g, '\\$1')}"` : arg)) + .join(' '); + } + async function load() { try { servers = await listToolServers(); @@ -67,6 +108,7 @@ formCommand = ''; formArgs = ''; formCwd = ''; + formEnv = ''; verifyResult = null; showModal = true; @@ -84,8 +126,9 @@ formDescription = s.description; formHeaders = s.headers ? JSON.stringify(s.headers, null, 2) : ''; formCommand = s.command || ''; - formArgs = (s.args || []).join(' '); + formArgs = joinArgs(s.args || []); formCwd = s.cwd || ''; + formEnv = s.env ? JSON.stringify(s.env, null, 2) : ''; verifyResult = null; showModal = true; @@ -119,6 +162,17 @@ return; } } + let parsedEnv: Record | null = null; + if (isStdio && formEnv.trim()) { + try { + const v = JSON.parse(formEnv); + if (typeof v !== 'object' || v === null || Array.isArray(v)) throw 0; + parsedEnv = v; + } catch { + toast.error($t('toolServers.envInvalid')); + return; + } + } saving = true; try { const data: Record = { @@ -140,8 +194,9 @@ description: formDescription.trim(), headers: parsedHeaders || null, command: formCommand.trim(), - args: formArgs.trim() ? formArgs.trim().split(/\s+/) : [], - cwd: formCwd.trim() || null + args: splitArgs(formArgs), + cwd: formCwd.trim() || null, + env: parsedEnv }; if (editServer) { if (formKey.trim()) data.key = formKey.trim(); @@ -360,6 +415,21 @@ spellcheck="false" class="block w-full bg-transparent text-[0.8125rem] text-gray-700 dark:text-gray-300 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none py-0.5 font-mono" /> + +

+ {$t('toolServers.envHint')} +

+ {/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index e7458b6d..79a8e94a 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -1607,6 +1607,12 @@ .chat-editor-mount :global(.chat-prosemirror ol) { @apply mb-1 pl-4.5 text-sm; } + .chat-editor-mount :global(.chat-prosemirror ul) { + list-style-type: disc; + } + .chat-editor-mount :global(.chat-prosemirror ol) { + list-style-type: decimal; + } .chat-editor-mount :global(.chat-prosemirror li) { @apply my-0.5; } diff --git a/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte b/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte index 9fbf7113..1f8d5f43 100644 --- a/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte +++ b/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte @@ -289,6 +289,14 @@ padding-left: 1.25rem; } + .rte-container :global(.rte-prosemirror ul) { + list-style-type: disc; + } + + .rte-container :global(.rte-prosemirror ol) { + list-style-type: decimal; + } + .rte-container :global(.rte-prosemirror li) { margin: 0.25rem 0; } diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index c4b10b94..c0fbd75a 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -868,6 +868,9 @@ "toolServers.args": "Argumente", "toolServers.cwd": "Arbeitsverzeichnis", "toolServers.cwdPlaceholder": "Optional (standardmäßig System)", + "toolServers.env": "Umgebung", + "toolServers.envHint": "Zusätzliche Umgebungsvariablen als JSON-Objekt. Werden mit dem geerbten PATH beim Start des Prozesses zusammengeführt.", + "toolServers.envInvalid": "Umgebung muss ein gültiges JSON-Objekt sein", "toolServers.commandRequired": "Befehl ist für stdio-Server erforderlich", "toolServers.connectionFailed": "Verbindung fehlgeschlagen", "toolServers.apiKeyKeep": "•••••••• (leer lassen zum Beibehalten)", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 1ba8c82a..da0acd33 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -905,6 +905,9 @@ "toolServers.args": "Arguments", "toolServers.cwd": "Working Directory", "toolServers.cwdPlaceholder": "Optional (defaults to system)", + "toolServers.env": "Environment", + "toolServers.envHint": "Extra environment variables as JSON object. Merged with the inherited PATH when spawning the process.", + "toolServers.envInvalid": "Environment must be a valid JSON object", "toolServers.commandRequired": "Command is required for stdio servers", "toolServers.connectionFailed": "Connection failed", "toolServers.apiKeyKeep": "•••••••• (leave blank to keep)", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index b63ab8b1..c43b0c2e 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -868,6 +868,9 @@ "toolServers.args": "Argumentos", "toolServers.cwd": "Directorio de trabajo", "toolServers.cwdPlaceholder": "Opcional (predeterminado del sistema)", + "toolServers.env": "Entorno", + "toolServers.envHint": "Variables de entorno adicionales como objeto JSON. Se combinan con el PATH heredado al lanzar el proceso.", + "toolServers.envInvalid": "El entorno debe ser un objeto JSON válido", "toolServers.commandRequired": "El comando es obligatorio para servidores stdio", "toolServers.connectionFailed": "Conexión fallida", "toolServers.apiKeyKeep": "•••••••• (dejar vacío para mantener)", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index f9945fd5..059e1204 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -868,6 +868,9 @@ "toolServers.args": "Paramètres", "toolServers.cwd": "Répertoire de travail", "toolServers.cwdPlaceholder": "Facultatif (système par défaut)", + "toolServers.env": "Environnement", + "toolServers.envHint": "Variables d'environnement supplémentaires en JSON. Fusionnées avec le PATH hérité au lancement du processus.", + "toolServers.envInvalid": "L'environnement doit être un objet JSON valide", "toolServers.commandRequired": "La commande est requise pour les serveurs stdio", "toolServers.connectionFailed": "Échec de la connexion", "toolServers.apiKeyKeep": "•••••••• (laisser vide pour conserver)", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 561b5f68..3993cdb2 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -868,6 +868,9 @@ "toolServers.args": "引数", "toolServers.cwd": "作業ディレクトリ", "toolServers.cwdPlaceholder": "任意(既定はシステム)", + "toolServers.env": "環境変数", + "toolServers.envHint": "JSON オブジェクト形式の追加環境変数。プロセス起動時に継承された PATH とマージされます。", + "toolServers.envInvalid": "環境変数は有効なJSONオブジェクトである必要があります", "toolServers.commandRequired": "stdio サーバーにはコマンドが必要です", "toolServers.connectionFailed": "接続に失敗しました", "toolServers.apiKeyKeep": "••••••••(空のままで保持)", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index e0dc7b54..340a194b 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -868,6 +868,9 @@ "toolServers.args": "인수", "toolServers.cwd": "작업 디렉터리", "toolServers.cwdPlaceholder": "선택 사항(기본값은 시스템)", + "toolServers.env": "환경 변수", + "toolServers.envHint": "추가 환경 변수를 JSON 객체로 입력합니다. 프로세스를 시작할 때 상속된 PATH 와 병합됩니다.", + "toolServers.envInvalid": "환경 변수는 유효한 JSON 객체여야 합니다", "toolServers.commandRequired": "stdio 서버에는 명령이 필요합니다", "toolServers.connectionFailed": "연결 실패", "toolServers.apiKeyKeep": "•••••••• (유지하려면 비워두세요)", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index c87e2111..d9cb79a2 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -868,6 +868,9 @@ "toolServers.args": "Argumentos", "toolServers.cwd": "Diretório de trabalho", "toolServers.cwdPlaceholder": "Opcional (padrão do sistema)", + "toolServers.env": "Ambiente", + "toolServers.envHint": "Variáveis de ambiente adicionais como objeto JSON. Mescladas com o PATH herdado ao iniciar o processo.", + "toolServers.envInvalid": "O ambiente deve ser um objeto JSON válido", "toolServers.commandRequired": "Comando é obrigatório para servidores stdio", "toolServers.connectionFailed": "Falha na conexão", "toolServers.apiKeyKeep": "•••••••• (deixe vazio para manter)", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 974b4afc..33a92d76 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -868,6 +868,9 @@ "toolServers.args": "Аргументы", "toolServers.cwd": "Рабочий каталог", "toolServers.cwdPlaceholder": "Необязательно (по умолчанию системный)", + "toolServers.env": "Окружение", + "toolServers.envHint": "Дополнительные переменные окружения в виде JSON-объекта. Объединяются с унаследованным PATH при запуске процесса.", + "toolServers.envInvalid": "Окружение должно быть корректным JSON-объектом", "toolServers.commandRequired": "Команда обязательна для stdio-серверов", "toolServers.connectionFailed": "Ошибка подключения", "toolServers.apiKeyKeep": "•••••••• (оставьте пустым для сохранения)", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index f0a8299f..0a34144f 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -868,6 +868,9 @@ "toolServers.args": "参数", "toolServers.cwd": "工作目录", "toolServers.cwdPlaceholder": "可选(默认为系统)", + "toolServers.env": "环境变量", + "toolServers.envHint": "以 JSON 对象形式提供的附加环境变量。启动进程时会与继承的 PATH 合并。", + "toolServers.envInvalid": "环境变量必须是有效的 JSON 对象", "toolServers.commandRequired": "stdio 服务器需要命令", "toolServers.connectionFailed": "连接失败", "toolServers.apiKeyKeep": "••••••••(留空以保持不变)", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index bf455b50..67c0a202 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -868,6 +868,9 @@ "toolServers.args": "引數", "toolServers.cwd": "工作目錄", "toolServers.cwdPlaceholder": "選填(預設為系統)", + "toolServers.env": "環境變數", + "toolServers.envHint": "以 JSON 物件形式提供的附加環境變數。啟動進程時會與繼承的 PATH 合併。", + "toolServers.envInvalid": "環境變數必須是有效的 JSON 物件", "toolServers.commandRequired": "stdio 伺服器需要命令", "toolServers.connectionFailed": "連線失敗", "toolServers.apiKeyKeep": "••••••••(留空以保持不變)", diff --git a/cptr/routers/auth.py b/cptr/routers/auth.py index 881818f2..af88c685 100644 --- a/cptr/routers/auth.py +++ b/cptr/routers/auth.py @@ -17,6 +17,7 @@ get_or_create_user, has_any_user, hash_password, + load_config, now_ms, pam_authenticate, record_attempt, @@ -54,9 +55,18 @@ async def get_auth(request: Request): client_host = request.client.host if request.client else "127.0.0.1" token = request.cookies.get(COOKIE_NAME) - auth = check_access(client_host=client_host, jwt_token=token) + remote_user = None + if get_auth_mode() == AuthMode.TRUSTED_HEADER: + header_name = load_config().get("auth", {}).get("header", "Remote-User") + remote_user = request.headers.get(header_name) + auth = check_access(client_host=client_host, jwt_token=token, remote_user_header=remote_user) + + if auth is not None and auth.username and not auth.user_id: + auth.user_id = await get_or_create_user(auth.username) if auth is not None and auth.user_id: + if not auth.exp: + auth.exp = time.time() + SESSION_MAX_AGE user = await User.get_by_id(auth.user_id) if user is None: from starlette.responses import JSONResponse as StarletteJSONResponse @@ -77,7 +87,7 @@ async def get_auth(request: Request): # Sliding session: refresh token if past halfway to expiry remaining = auth.exp - time.time() - if remaining < SESSION_MAX_AGE / 2: + if remote_user or remaining < SESSION_MAX_AGE / 2: new_token = create_token(auth.user_id, auth.username, user.role) return _ok_with_cookie(new_token, data) diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index ea92f5e0..3f62d0d5 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -142,7 +142,7 @@ class ChatCompletionMessage(BaseModel): class ChatCompletionRequest(BaseModel): model: str messages: list[dict] - stream: bool = True + stream: bool = False # Other OpenAI params are accepted but ignored temperature: float | None = None max_tokens: int | None = None diff --git a/cptr/routers/state.py b/cptr/routers/state.py index e944a647..58ee1427 100644 --- a/cptr/routers/state.py +++ b/cptr/routers/state.py @@ -174,7 +174,15 @@ async def put_workspace(request: Request, path: str = Query(...)): return {"status": "skipped"} workspace_path = _resolve_workspace_path(path) workspace_data = await request.json() - name = workspace_data.pop("name", _workspace_display_name(workspace_path)) + existing_workspace = _newest_workspace(await _workspaces_at_path(user_id, workspace_path)) + if "name" in workspace_data: + name = workspace_data.pop("name") + else: + name = ( + existing_workspace.name + if existing_workspace + else _workspace_display_name(workspace_path) + ) workspace_data.pop("path", None) # Everything else is workspace data (groups, tabs, etc.) await Workspace.upsert(user_id, workspace_path, name, workspace_data) @@ -351,7 +359,7 @@ class MEMORYSTATUSEX(ctypes.Structure): # Load average try: load = os.getloadavg() - system["load_avg"] = [round(l, 2) for l in load] + system["load_avg"] = [round(value, 2) for value in load] except Exception: pass diff --git a/cptr/utils/agents/claude_code.py b/cptr/utils/agents/claude_code.py index d868dbf2..7ded3876 100644 --- a/cptr/utils/agents/claude_code.py +++ b/cptr/utils/agents/claude_code.py @@ -20,6 +20,9 @@ from cptr.utils.agents.prompts import latest_user_text +_claude_clients: dict[str, tuple[Any, tuple[Any, ...]]] = {} + + def _claude_query_input( prompt: str, attachments: PreparedAgentAttachments ) -> str | AsyncIterator[dict[str, Any]]: @@ -66,6 +69,19 @@ def _permission_mode(chat_approval_mode: str) -> str: return "default" +async def _disconnect_cached_claude_client(session_id: str) -> None: + cached = _claude_clients.pop(session_id, None) + if cached: + await cached[0].disconnect() + + +async def _clear_claude_client_cache() -> None: + cached_clients = list(_claude_clients.values()) + _claude_clients.clear() + for client, _ in cached_clients: + await client.disconnect() + + def _tool_update_from_claude_start( event: dict[str, Any], ) -> tuple[int | None, AgentToolUpdate | None]: @@ -111,15 +127,19 @@ async def run_claude_code_agent( prompt = latest_user_text(messages) env = os.environ.copy() - if profile.get("home"): - env["HOME"] = os.path.expanduser(str(profile["home"])) + home = os.path.expanduser(str(profile["home"])) if profile.get("home") else None + if home: + env["HOME"] = home permission_mode = _permission_mode(_chat_approval_mode(chat_params)) launch_args = str(profile.get("launch_args") or "").strip() - extra_args = shlex.split(launch_args) if launch_args else [] + extra_args = tuple(shlex.split(launch_args)) if launch_args else () + command = str(profile["command"]) + cache_key = (workspace, command, home, extra_args, model, permission_mode) + session_id = None + client = None try: - session_id = None if resume_state: value = resume_state.get("session_id") session_id = value if isinstance(value, str) and value else None @@ -139,12 +159,22 @@ async def run_claude_code_agent( options_kwargs["extra_args"] = {arg: None for arg in extra_args} options = sdk.ClaudeAgentOptions(**options_kwargs) - options.cli_path = str(profile["command"]) + options.cli_path = command if model != "default": options.model = model - client = sdk.ClaudeSDKClient(options) - await client.connect() + cached = None + if session_id: + cached = _claude_clients.get(session_id) + if cached and cached[1] != cache_key: + await _disconnect_cached_claude_client(session_id) + cached = None + + if cached: + client = cached[0] + else: + client = sdk.ClaudeSDKClient(options) + await client.connect() try: query_input = _claude_query_input(prompt, attachments) @@ -229,6 +259,15 @@ async def run_claude_code_agent( } break + if observed_session_id: + old_client = _claude_clients.pop(session_id, (None,))[0] if session_id else None + if old_client is not None and old_client is not client: + await old_client.disconnect() + _claude_clients[observed_session_id] = (client, cache_key) + session_id = observed_session_id + else: + await client.disconnect() + yield AgentDone( usage=usage, resume_state={ @@ -238,9 +277,17 @@ async def run_claude_code_agent( "model": model, }, ) - finally: - await client.disconnect() + except Exception: + if session_id: + await _disconnect_cached_claude_client(session_id) + else: + await client.disconnect() + raise except asyncio.CancelledError: + if session_id: + await _disconnect_cached_claude_client(session_id) + elif client is not None: + await client.disconnect() raise except Exception as exc: # noqa: BLE001 - surfaced in the chat. yield AgentError(str(exc)) diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index 98dbb567..cbdd0c9f 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -23,7 +23,7 @@ ) DETECTION_TTL_SECONDS = 30 -CLAUDE_MODELS = [ +CLAUDE_MODEL_FALLBACKS = [ "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", @@ -117,16 +117,49 @@ async def _run_probe( async def detect_profile(profile: dict[str, Any]) -> AgentDetection: raw_command = str(profile.get("command") or "").strip() command = _resolve_command(raw_command) - if command is None and profile.get("agent") == "claude_code" and raw_command == "claude": + default_claude_command = profile.get("agent") == "claude_code" and raw_command == "claude" + if profile.get("agent") == "opencode" and str(profile.get("server_url") or "").strip(): + version = None + if command is not None: + code, version_text = await _run_probe([command, "--version"]) + version = version_text.splitlines()[0] if code == 0 and version_text else None + models = await _probe_opencode_models(command or raw_command or "opencode", profile) + if not models: + return AgentDetection( + "auth_unknown", + command, + version, + "Could not discover OpenCode models. Check connected OpenCode providers.", + [], + ) + return AgentDetection("ready", command, version, None, models) + + if command is None and default_claude_command: command = _find_claude_desktop_command() if command is None: return AgentDetection("not_found", None, None, "Command not found") code, version_text = await _run_probe([command, "--version"]) + if code != 0 and default_claude_command: + desktop_command = _find_claude_desktop_command() + if desktop_command and desktop_command != command: + desktop_code, desktop_version_text = await _run_probe([desktop_command, "--version"]) + if desktop_code == 0: + command = desktop_command + code = desktop_code + version_text = desktop_version_text version = version_text.splitlines()[0] if code == 0 and version_text else None if profile.get("agent") == "claude_code": models = _claude_models_for_version(version) + if code != 0: + return AgentDetection( + "error", + command, + version, + version_text or "Failed to run Claude Code health check.", + models, + ) if importlib.util.find_spec("claude_agent_sdk") is None: return AgentDetection( "missing_dependency", @@ -257,7 +290,7 @@ def _version_at_least(version: tuple[int, int, int] | None, minimum: tuple[int, def _claude_models_for_version(version: str | None) -> list[str]: parsed = _parse_version_tuple(version) models = [] - for model in CLAUDE_MODELS: + for model in CLAUDE_MODEL_FALLBACKS: if model == "claude-fable-5" and not _version_at_least(parsed, MIN_CLAUDE_FABLE_5): continue if model == "claude-opus-4-8" and not _version_at_least(parsed, MIN_CLAUDE_OPUS_4_8): @@ -578,17 +611,14 @@ async def get_agent_status(app_state=None, refresh: bool = False) -> dict[str, A if implicit_defaults and detected.status in {"not_found", "error"}: continue mode = profile.get("mode", "auto") - models = detected.models or profile.get("models") or [] - available = ( - mode != "disabled" - and bool(models) - and (mode != "auto" or detected.status == "ready") - ) + models = list(dict.fromkeys([*(detected.models or []), *(profile.get("models") or [])])) + available = mode != "disabled" and (mode != "auto" or detected.status == "ready") effective_profile = dict(profile) + resolved_profile_command = _resolve_command(str(profile.get("command") or "")) if ( detected.command and profile.get("agent") == "claude_code" - and _resolve_command(str(profile.get("command") or "")) is None + and detected.command != resolved_profile_command ): effective_profile["command"] = detected.command effective_profile["models"] = models diff --git a/cptr/utils/config.py b/cptr/utils/config.py index 3a729ba0..273c4c7f 100644 --- a/cptr/utils/config.py +++ b/cptr/utils/config.py @@ -392,6 +392,8 @@ def check_access( return None if remote_user_header: return AuthResult(username=remote_user_header) + if jwt_token: + return verify_token(jwt_token) return None return None diff --git a/cptr/utils/git.py b/cptr/utils/git.py index 072caf34..4baf14fb 100644 --- a/cptr/utils/git.py +++ b/cptr/utils/git.py @@ -20,6 +20,8 @@ async def _run( """Run a git command and return (returncode, stdout, stderr).""" proc = await asyncio.create_subprocess_exec( "git", + "-c", + "core.quotePath=false", *args, cwd=cwd, stdout=asyncio.subprocess.PIPE, diff --git a/cptr/utils/mcp/client.py b/cptr/utils/mcp/client.py index 425ce431..877ab3a6 100644 --- a/cptr/utils/mcp/client.py +++ b/cptr/utils/mcp/client.py @@ -81,6 +81,9 @@ async def connect_stdio( env: Optional environment variables for the process. cwd: Optional working directory. """ + command = command.strip() + if len(command) > 1 and command[0] == command[-1] and command[0] in ("'", '"'): + command = command[1:-1] params = StdioServerParameters( command=command, args=args or [], diff --git a/cptr/utils/model_targets.py b/cptr/utils/model_targets.py index 07695313..26b2854e 100644 --- a/cptr/utils/model_targets.py +++ b/cptr/utils/model_targets.py @@ -45,9 +45,6 @@ async def resolve_agent_model_target(model_id: str, app_state=None) -> AgentMode raise HTTPException(400, f"agent profile not found: {profile_id}") profile = entry["config"] - if model not in (profile.get("models") or []): - raise HTTPException(400, f"model '{model}' is not configured for agent profile {profile_id}") - if not entry["available"]: detection = entry.get("detected") or {} raise HTTPException( diff --git a/pyproject.toml b/pyproject.toml index 73b1fe24..5386e5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.15" +version = "0.9.16" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" @@ -25,11 +25,11 @@ dependencies = [ [project.optional-dependencies] pam = ["python-pam>=2.0"] -mcp = ["mcp>=1.8"] +mcp = ["mcp>=1.8,<2"] docs = ["pypdf>=4.0", "python-docx>=1.0", "openpyxl>=3.1"] agents = ["claude-agent-sdk>=0.1.62"] all = [ - "mcp>=1.8", + "mcp>=1.8,<2", "pypdf>=4.0", "python-docx>=1.0", "openpyxl>=3.1", diff --git a/uv.lock b/uv.lock index 20634b5a..84ceba83 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.15" +version = "0.9.16" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -345,8 +345,8 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.128.8" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "mcp", marker = "extra == 'all'", specifier = ">=1.8" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.8,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8,<2" }, { name = "openpyxl", marker = "extra == 'all'", specifier = ">=3.1" }, { name = "openpyxl", marker = "extra == 'docs'", specifier = ">=3.1" }, { name = "pyjwt", specifier = ">=2.8" },