Skip to content
Merged

refac #131

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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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.4] - 2026-07-11

### Fixed

- ⚡ **Your model choices are ready sooner.** Computer now gets them ready as it opens and keeps the last working list if a check fails.
- 👋 **More dependable restarts.** Computer now finishes its open work more reliably before it closes.
## [0.9.3] - 2026-07-11

### Changed
Expand Down
88 changes: 51 additions & 37 deletions cptr/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import asyncio
import time
from contextlib import asynccontextmanager, suppress
from pathlib import Path

from fastapi import FastAPI, Request
Expand Down Expand Up @@ -31,11 +33,10 @@
from cptr.utils.db import init_db

START_TIME = time.time()
app = FastAPI()


@app.on_event("startup")
async def startup():
@asynccontextmanager
async def lifespan(app: FastAPI):
from cptr.utils.logger import setup_logging

setup_logging()
Expand All @@ -49,6 +50,7 @@ async def startup():
_logging.getLogger(__name__).info("truststore: using system certificate store")

await init_db()
app.state.MODELS = {}
from cptr.env import STARTUP_TOKEN

app.state.startup_token = STARTUP_TOKEN
Expand All @@ -60,50 +62,62 @@ async def startup():

await reconcile_chat_state()

from cptr.routers.chat import warm_model_cache

await warm_model_cache(app.state)

# Start automation scheduler
import asyncio
from cptr.utils.automations import scheduler_worker_loop

asyncio.create_task(scheduler_worker_loop(app))
app.state.scheduler_task = asyncio.create_task(scheduler_worker_loop(app))

# Start messaging bots
from cptr.utils.bridge import BotManager

app.state.bot_manager = BotManager()
asyncio.create_task(app.state.bot_manager.start_all())


@app.on_event("shutdown")
async def shutdown():
bot_manager = getattr(app.state, "bot_manager", None)
if bot_manager:
await bot_manager.stop_all()
try:
from cptr.utils.async_subagents import cancel_all_async_subagents
await app.state.bot_manager.start_all()

await cancel_all_async_subagents(reason="shutdown")
except Exception:
pass
# Clean up browser sessions and launched Chrome used by agent tools.
try:
from cptr.utils.browser.session import session_manager
from cptr.utils.browser.launcher import shutdown_browser
from cptr.utils.browser.proxy import manager as browser_proxy_manager
from cptr.utils.browser.viewer import manager as chrome_viewer_manager

await chrome_viewer_manager.close_all()
await browser_proxy_manager.close_all()
await session_manager.close_all()
await shutdown_browser()
except Exception:
pass
# Clean up stdio MCP server processes
try:
from cptr.utils.mcp.stdio_manager import stdio_manager

await stdio_manager.disconnect_all()
except Exception:
pass
yield
finally:
scheduler_task = getattr(app.state, "scheduler_task", None)
if scheduler_task:
scheduler_task.cancel()
with suppress(asyncio.CancelledError):
await scheduler_task

bot_manager = getattr(app.state, "bot_manager", None)
if bot_manager:
await bot_manager.stop_all()
try:
from cptr.utils.async_subagents import cancel_all_async_subagents

await cancel_all_async_subagents(reason="shutdown")
except Exception:
pass
# Clean up browser sessions and launched Chrome used by agent tools.
try:
from cptr.utils.browser.session import session_manager
from cptr.utils.browser.launcher import shutdown_browser
from cptr.utils.browser.proxy import manager as browser_proxy_manager
from cptr.utils.browser.viewer import manager as chrome_viewer_manager

await chrome_viewer_manager.close_all()
await browser_proxy_manager.close_all()
await session_manager.close_all()
await shutdown_browser()
except Exception:
pass
# Clean up stdio MCP server processes
try:
from cptr.utils.mcp.stdio_manager import stdio_manager

await stdio_manager.disconnect_all()
except Exception:
pass


app = FastAPI(lifespan=lifespan)


# Auth middleware
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "Gute Zeit, etwas abzuschließen, {{name}}.",
"home.greeting.night.0": "Nachtschicht, {{name}}?",
"home.greeting.night.1": "Der Tag ist noch nicht vorbei, {{name}}.",
"home.greeting.night.2": "Noch eine Sache, {{name}}?",
"home.greeting.night.2": "Noch immer dran, {{name}}?",
"home.greeting.lateNight.0": "Noch wach, {{name}}?",
"home.greeting.lateNight.1": "Die Nachteule ist wieder da, {{name}}.",
"home.greeting.lateNight.2": "Speichere deine Arbeit, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "A good time to finish something, {{name}}.",
"home.greeting.night.0": "Night shift, {{name}}?",
"home.greeting.night.1": "The day is not over yet, {{name}}.",
"home.greeting.night.2": "One more thing, {{name}}?",
"home.greeting.night.2": "Still going, {{name}}?",
"home.greeting.lateNight.0": "Still here, {{name}}?",
"home.greeting.lateNight.1": "The night owl is back, {{name}}.",
"home.greeting.lateNight.2": "Save your work, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "Buen momento para terminar algo, {{name}}.",
"home.greeting.night.0": "Turno de noche, {{name}}?",
"home.greeting.night.1": "El día aún no termina, {{name}}.",
"home.greeting.night.2": "Una cosa más, {{name}}?",
"home.greeting.night.2": "¿Sigues con ello, {{name}}?",
"home.greeting.lateNight.0": "Sigues aquí, {{name}}?",
"home.greeting.lateNight.1": "El búho nocturno volvió, {{name}}.",
"home.greeting.lateNight.2": "Guarda tu trabajo, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "Bon moment pour finir quelque chose, {{name}}.",
"home.greeting.night.0": "Service de nuit, {{name}} ?",
"home.greeting.night.1": "La journée n'est pas finie, {{name}}.",
"home.greeting.night.2": "Encore une chose, {{name}} ?",
"home.greeting.night.2": "Toujours à l’œuvre, {{name}} ?",
"home.greeting.lateNight.0": "Toujours là, {{name}} ?",
"home.greeting.lateNight.1": "Le hibou de nuit est de retour, {{name}}.",
"home.greeting.lateNight.2": "Sauvegarde ton travail, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "何かを終えるのにいい時間です、{{name}}さん。",
"home.greeting.night.0": "夜の時間ですね、{{name}}さん?",
"home.greeting.night.1": "まだ一日は終わっていません、{{name}}さん。",
"home.greeting.night.2": "あと一つだけ、{{name}}さん?",
"home.greeting.night.2": "まだ続けてるんですか、{{name}}さん?",
"home.greeting.lateNight.0": "まだ起きてますか、{{name}}さん?",
"home.greeting.lateNight.1": "夜型ですね、{{name}}さん。",
"home.greeting.lateNight.2": "保存を忘れずに、{{name}}さん。",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "무언가 마무리하기 좋은 때예요, {{name}}님.",
"home.greeting.night.0": "밤 시간이에요, {{name}}님?",
"home.greeting.night.1": "아직 하루가 끝나지 않았어요, {{name}}님.",
"home.greeting.night.2": "한 가지만 더, {{name}}님?",
"home.greeting.night.2": "아직도 하고 계시네요, {{name}}님?",
"home.greeting.lateNight.0": "아직 여기 있네요, {{name}}님?",
"home.greeting.lateNight.1": "밤 올빼미군요, {{name}}님.",
"home.greeting.lateNight.2": "저장하는 것 잊지 마세요, {{name}}님.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "Boa hora para terminar algo, {{name}}.",
"home.greeting.night.0": "Turno da noite, {{name}}?",
"home.greeting.night.1": "O dia ainda não acabou, {{name}}.",
"home.greeting.night.2": "Só mais uma coisa, {{name}}?",
"home.greeting.night.2": "Ainda nessa, {{name}}?",
"home.greeting.lateNight.0": "Ainda por aqui, {{name}}?",
"home.greeting.lateNight.1": "A coruja noturna apareceu, {{name}}.",
"home.greeting.lateNight.2": "Não esqueça de salvar, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "Хорошее время что-то закончить, {{name}}.",
"home.greeting.night.0": "Ночная смена, {{name}}?",
"home.greeting.night.1": "День ещё не закончился, {{name}}.",
"home.greeting.night.2": "Ещё одно дело, {{name}}?",
"home.greeting.night.2": "Всё ещё в деле, {{name}}?",
"home.greeting.lateNight.0": "Всё ещё здесь, {{name}}?",
"home.greeting.lateNight.1": "Ночная сова снова здесь, {{name}}.",
"home.greeting.lateNight.2": "Не забудьте сохранить, {{name}}.",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "是完成一件事的好时候,{{name}}。",
"home.greeting.night.0": "夜间模式,{{name}}?",
"home.greeting.night.1": "这一天还没结束,{{name}}。",
"home.greeting.night.2": "再做一件事,{{name}}?",
"home.greeting.night.2": "还在忙吗,{{name}}?",
"home.greeting.lateNight.0": "还在这里,{{name}}?",
"home.greeting.lateNight.1": "夜猫子来了,{{name}}。",
"home.greeting.lateNight.2": "别忘了保存,{{name}}。",
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"home.greeting.evening.2": "是完成一件事的好時候,{{name}}。",
"home.greeting.night.0": "夜間模式,{{name}}?",
"home.greeting.night.1": "這一天還沒結束,{{name}}。",
"home.greeting.night.2": "再做一件事,{{name}}?",
"home.greeting.night.2": "還在忙嗎,{{name}}?",
"home.greeting.lateNight.0": "還在這裡,{{name}}?",
"home.greeting.lateNight.1": "夜貓子來了,{{name}}。",
"home.greeting.lateNight.2": "別忘了儲存,{{name}}。",
Expand Down
31 changes: 22 additions & 9 deletions cptr/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,21 @@ async def _get_connection_models(conn: dict, app_state) -> list[str]:
return cache[conn_id]

models = await _fetch_provider_models(conn)
cache[conn_id] = models
return models
if models is not None:
cache[conn_id] = models
return models
return cache.get(conn_id, [])


async def warm_model_cache(app_state) -> None:
"""Pre-fetch chat model sources before the application accepts requests."""
connections = [c for c in await _get_connections() if c.get("enabled", True)]
tasks = [_get_connection_models(conn, app_state) for conn in connections]

from cptr.utils.agents.detection import get_available_agent_model_entries

tasks.append(get_available_agent_model_entries(app_state))
await asyncio.gather(*tasks)


def invalidate_model_cache(app_state):
Expand Down Expand Up @@ -294,18 +307,18 @@ async def get_models(request: Request):
return {"models": models, "default": default_model}


async def _fetch_provider_models(conn: dict) -> list[str]:
async def _fetch_provider_models(conn: dict) -> list[str] | None:
"""Discover models from a provider's /models endpoint."""
import httpx

from cptr.utils.ai import _openrouter_headers

secret = _get_jwt_secret()
api_key = decrypt_key(conn.get("api_key", ""), secret) if conn.get("api_key") else None
provider = conn.get("provider", "")
base_url = conn.get("base_url")

try:
secret = _get_jwt_secret()
api_key = decrypt_key(conn.get("api_key", ""), secret) if conn.get("api_key") else None
provider = conn.get("provider", "")
base_url = conn.get("base_url")

if provider == "anthropic":
url = (base_url or "https://api.anthropic.com/v1") + "/models"
async with httpx.AsyncClient(timeout=10) as client:
Expand Down Expand Up @@ -357,7 +370,7 @@ async def _fetch_provider_models(conn: dict) -> list[str]:
except Exception:
log.exception("Model auto-discovery error for connection %s", conn.get("id", "?"))

return []
return None


# ── Get a chat with all messages ────────────────────────────
Expand Down
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.3"
version = "0.9.4"
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