Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ 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.19] - 2026-07-31

### Changed

- 🌐 **Open WebUI setup is clearer.** The gateway setup snippet now includes the user details Open WebUI can pass along, making connected chats easier to recognize.
- 🧰 **Action labels are easier to follow.** Connected chats and Claude Code now show names that better match the action being taken.
- 🤖 **Codex asks before taking more actions in Auto mode.** Auto approval now follows the same review path as ask-first sessions, so approval prompts appear more consistently.

### Fixed

- 💬 **Chats recover better when a model service is busy.** If a connection asks Computer to try again before anything has started, Computer now waits the requested time and retries instead of failing right away.
- 🧰 **Claude Code progress stays accurate.** Action details are kept together from start to finish, including when Claude Code only reports the final action.

## [0.9.18] - 2026-07-31

### Added
Expand Down
4 changes: 4 additions & 0 deletions cptr/frontend/src/lib/components/Admin/Gateway.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
/** Newly created key, shown once, then hidden */
let revealedKey = $state('');
const openWebUIHeaders = `{
"X-OpenWebUI-User-Name": "{{USER_NAME}}",
"X-OpenWebUI-User-Id": "{{USER_ID}}",
"X-OpenWebUI-User-Email": "{{USER_EMAIL}}",
"X-OpenWebUI-User-Role": "{{USER_ROLE}}",
"X-OpenWebUI-Chat-Id": "{{CHAT_ID}}",
"X-OpenWebUI-Message-Id": "{{MESSAGE_ID}}",
"X-OpenWebUI-User-Message-Id": "{{USER_MESSAGE_ID}}",
Expand Down
5 changes: 4 additions & 1 deletion cptr/routers/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ def _format_tool_call(item: dict) -> str | None:
if item.get("type") != "function_call" or item.get("status") != "in_progress":
return None

return f"\n\n`{item.get('name', 'tool')}`\n\n"
arguments = item.get("arguments")
title = arguments.get("title") if isinstance(arguments, dict) else None
name = str(title or item.get("name") or "tool").strip() or "tool"
return f"\n\n`{name}`\n\n"


async def _authenticate(request: Request) -> str:
Expand Down
50 changes: 48 additions & 2 deletions cptr/utils/agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import json
import os
import shlex
from typing import Any, AsyncIterator
Expand Down Expand Up @@ -103,7 +104,7 @@ def _tool_update_from_claude_start(
call_id=call_id.strip(),
name="agent_tool",
status="in_progress",
arguments={"title": name, **arguments},
arguments={**arguments, "title": name},
),
)

Expand Down Expand Up @@ -188,6 +189,8 @@ async def run_claude_code_agent(
usage: dict[str, Any] | None = None
observed_session_id = session_id
tool_calls: dict[int, AgentToolUpdate] = {}
tool_input_json: dict[int, str] = {}
received_tool_call_ids: set[str] = set()
received_text_delta = False
received_thinking_delta = False

Expand All @@ -212,21 +215,42 @@ async def run_claude_code_agent(
yield AgentReasoningDelta(thinking)
if thinking:
received_thinking_delta = True
elif delta.get("type") == "input_json_delta" and isinstance(
delta.get("partial_json"), str
):
index = event.get("index")
if isinstance(index, int):
tool_input_json[index] = (
tool_input_json.get(index, "") + delta["partial_json"]
)
elif event_type == "content_block_start":
index, tool = _tool_update_from_claude_start(event)
if tool:
received_tool_call_ids.add(tool.call_id)
if index is not None:
tool_calls[index] = tool
yield tool
elif event_type == "content_block_stop":
index = event.get("index")
tool = tool_calls.get(index) if isinstance(index, int) else None
if tool:
arguments = dict(tool.arguments or {})
title = str(arguments.pop("title", None) or "Claude action")
input_json = (
tool_input_json.get(index) if isinstance(index, int) else None
)
if input_json and input_json.strip():
try:
parsed = json.loads(input_json)
if isinstance(parsed, dict):
arguments = parsed
except json.JSONDecodeError:
pass
yield AgentToolUpdate(
call_id=tool.call_id,
name=tool.name,
status="completed",
arguments=tool.arguments,
arguments={**arguments, "title": title},
output="",
)
continue
Expand All @@ -246,6 +270,28 @@ async def run_claude_code_agent(
text = getattr(block, "thinking", "")
if text:
yield AgentReasoningDelta(text)
elif block.__class__.__name__ == "ToolUseBlock":
call_id = getattr(block, "id", None)
if (
isinstance(call_id, str)
and call_id.strip()
and call_id not in received_tool_call_ids
):
title = str(
getattr(block, "name", None)
or getattr(block, "type", None)
or "Claude action"
).strip()
raw_input = getattr(block, "input", None)
arguments = raw_input if isinstance(raw_input, dict) else {}
received_tool_call_ids.add(call_id)
yield AgentToolUpdate(
call_id=call_id.strip(),
name="agent_tool",
status="completed",
arguments={**arguments, "title": title},
output="",
)
elif class_name == "ResultMessage":
observed_session_id = (
getattr(message, "session_id", None) or observed_session_id
Expand Down
2 changes: 1 addition & 1 deletion cptr/utils/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def _exit_message(self) -> str:


def _approval_policy(value: str) -> str:
return {"ask": "on-request", "auto": "on-failure", "full": "never"}.get(value, "on-failure")
return {"ask": "on-request", "auto": "on-request", "full": "never"}.get(value, "on-request")


def _chat_approval_mode(chat_params: dict[str, Any]) -> str:
Expand Down
49 changes: 40 additions & 9 deletions cptr/utils/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
import copy
import json
import logging
import time
import uuid
from collections.abc import AsyncIterator
from email.utils import parsedate_to_datetime
from typing import Dict, List

import httpx
Expand Down Expand Up @@ -51,6 +53,8 @@ def _openrouter_headers(url: str) -> dict[str, str]:


_STREAM_RETRY_ATTEMPTS = 3
_STREAM_RETRY_MAX_DELAY_SECONDS = 30
_STREAM_RETRY_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504}
_STREAM_TIMEOUT = httpx.Timeout(
STREAM_CONNECT_TIMEOUT_SECONDS,
read=STREAM_READ_TIMEOUT_SECONDS,
Expand All @@ -59,6 +63,7 @@ def _openrouter_headers(url: str) -> dict[str, str]:
_STREAM_RETRY_ERRORS = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.HTTPStatusError,
httpx.ReadError,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
Expand All @@ -75,6 +80,32 @@ class ChatCompletionForm(BaseModel):
tools: List[Dict] = []


def _is_retryable_stream_error(exc: BaseException) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in _STREAM_RETRY_STATUS_CODES
return isinstance(exc, _STREAM_RETRY_ERRORS)


def _stream_retry_delay(exc: BaseException, attempt: int) -> float:
delay = 0.5 * (attempt + 1)
if not isinstance(exc, httpx.HTTPStatusError):
return delay

retry_after = exc.response.headers.get("retry-after")
if not retry_after:
return delay

try:
delay = float(retry_after)
except ValueError:
try:
delay = parsedate_to_datetime(retry_after).timestamp() - time.time()
except (TypeError, ValueError, OverflowError):
return delay

return min(max(delay, 0), _STREAM_RETRY_MAX_DELAY_SECONDS)


# ── Non-streaming completion ────────────────────────────────


Expand Down Expand Up @@ -377,16 +408,16 @@ async def stream_anthropic(
emitted = True
yield {"type": "done"}
return
except _STREAM_RETRY_ERRORS:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1:
except _STREAM_RETRY_ERRORS as exc:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc):
raise
logger.warning(
"[stream] anthropic transient stream failure before first event; retrying (%s/%s)",
attempt + 1,
_STREAM_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(0.5 * (attempt + 1))
await asyncio.sleep(_stream_retry_delay(exc, attempt))


# ── OpenAI Chat Completions ──────────────────────────────────
Expand Down Expand Up @@ -657,16 +688,16 @@ def complete_reasoning_item() -> dict | None:
emitted = True
yield {"type": "done"}
return
except _STREAM_RETRY_ERRORS:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1:
except _STREAM_RETRY_ERRORS as exc:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc):
raise
logger.warning(
"[stream] openai completions transient stream failure before first event; retrying (%s/%s)",
attempt + 1,
_STREAM_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(0.5 * (attempt + 1))
await asyncio.sleep(_stream_retry_delay(exc, attempt))


# ── OpenAI Responses API ─────────────────────────────────────
Expand Down Expand Up @@ -974,13 +1005,13 @@ def get_reasoning_item(event: dict) -> dict:
emitted = True
yield {"type": "done"}
return
except _STREAM_RETRY_ERRORS:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1:
except _STREAM_RETRY_ERRORS as exc:
if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc):
raise
logger.warning(
"[stream] openai responses transient stream failure before first event; retrying (%s/%s)",
attempt + 1,
_STREAM_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(0.5 * (attempt + 1))
await asyncio.sleep(_stream_retry_delay(exc, attempt))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cptr"
version = "0.9.18"
version = "0.9.19"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading