diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8e157f1..556dc51b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ 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.21] - 2026-08-04
+
+### Added
+
+- π **Usage has its own Settings page.** See your token activity, active streaks, favorite models, busy days, and most used tools in one place.
+- π οΈ **Tool approval has its own Admin page.** Admins can choose the review model and decide which built-in tools can run automatically.
+
+### Changed
+
+- π€ **Agent chats can start from Home.** Coding agents can now answer even when a chat is not attached to a workspace.
+- π **Browser sessions recover more smoothly.** Browser streaming catches up faster when video falls behind and cleans up more reliably when a session closes.
+- π§° **OpenCode on Docker is easier to connect.** Computer now tries the right host address when needed and gives clearer setup guidance if it cannot connect.
+- π **More C and C++ files open with the right language.** Files ending in `.cc`, `.cxx`, `.hh`, and `.hxx` are now recognized.
+
+### Fixed
+
+- π¬ **Empty replies are retried instead of getting stuck.** If a chat service sends back nothing useful, Computer now tries again before showing a failure.
+- π **Text-to-speech cleans up after playback.** Listening to a message no longer leaves old audio resources around.
+- π§° **Deleted agent profiles stay deleted right away.** Removing a saved agent now saves the change immediately.
+- π» **Terminal and browser sessions close more cleanly.** Leaving a session is less likely to leave old listeners or connections behind.
+- π± **Signal message edits work more naturally.** Updated replies are now sent as edits when Signal supports it.
+
## [0.9.20] - 2026-08-01
### Added
diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts
index b5674534..bbf41519 100644
--- a/cptr/frontend/src/lib/apis/admin.ts
+++ b/cptr/frontend/src/lib/apis/admin.ts
@@ -211,6 +211,27 @@ export const updateModelConfig = (
method: 'PUT'
});
+// ββ Tool Approval βββββββββββββββββββββββββββββββββββββββββββ
+
+export type ToolApprovalPolicy = 'allow' | 'review';
+
+export interface ToolApprovalGroup {
+ id: string;
+ tools: {
+ name: string;
+ default_approval: ToolApprovalPolicy | null;
+ }[];
+}
+
+export interface ToolApprovalResponse {
+ default_approval: ToolApprovalPolicy;
+ overrides: Record;
+ groups: ToolApprovalGroup[];
+}
+
+export const getToolApproval = async (): Promise =>
+ fetchJSON('/api/admin/tools/approval');
+
// ββ Tool Servers ββββββββββββββββββββββββββββββββββββββββββββ
export interface ToolServer {
diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts
index 5809ee97..5ebf89b2 100644
--- a/cptr/frontend/src/lib/apis/chat.ts
+++ b/cptr/frontend/src/lib/apis/chat.ts
@@ -78,6 +78,41 @@ export interface CompactChatResult {
context_usage?: ContextUsage | null;
}
+export interface UsageHeatmapEntry {
+ date: string;
+ tokens: number;
+ messages: number;
+ chats: number;
+ models: Record;
+}
+
+export interface UsageResponse {
+ totals: {
+ lifetime_tokens: number;
+ peak_daily_tokens: number;
+ longest_chat_seconds: number;
+ current_streak: number;
+ longest_streak: number;
+ models_used: number;
+ user_messages: number;
+ assistant_messages: number;
+ messages: number;
+ total_chats: number;
+ };
+ insights: {
+ average_tokens_per_chat: number;
+ average_messages_per_active_day: number;
+ user_message_share: number;
+ assistant_message_share: number;
+ };
+ heatmap: UsageHeatmapEntry[];
+ weekly_heatmap: UsageHeatmapEntry[];
+ cumulative_heatmap: UsageHeatmapEntry[];
+ top_models: { model_id: string; messages: number; total_tokens: number }[];
+ top_tools: { name: string; count: number }[];
+ period: { start_date: number; end_date: number; days: number };
+}
+
// ββ Queries βββββββββββββββββββββββββββββββββββββββββββββββββ
export const getChats = (
@@ -96,6 +131,8 @@ export const getChat = (chatId: string, modelId?: string) => {
return fetchJSON(`/api/chats/${chatId}${suffix}`);
};
+export const getUsage = () => fetchJSON('/api/chats/usage');
+
export const deleteChat = (chatId: string) =>
fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' });
diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte
index 889acea7..6eaa831a 100644
--- a/cptr/frontend/src/lib/components/Admin/Agents.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte
@@ -167,7 +167,7 @@
modal = null;
}
- function deleteProfile() {
+ async function deleteProfile() {
const currentModal = modal;
if (currentModal?.mode !== 'edit') {
modal = null;
@@ -181,6 +181,7 @@
deletedProfile = true;
}
modal = null;
+ await save();
}
function toggleProfile(index: number) {
diff --git a/cptr/frontend/src/lib/components/Admin/Chat.svelte b/cptr/frontend/src/lib/components/Admin/Chat.svelte
index 2f8f1369..5afde22d 100644
--- a/cptr/frontend/src/lib/components/Admin/Chat.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Chat.svelte
@@ -12,7 +12,6 @@
let titleGenerationEnabled = $state(true);
let titleGenerationModel = $state(null);
let contextCompactionModel = $state(null);
- let toolApprovalReviewModel = $state(null);
let compactTokenThreshold = $state(80000);
onMount(async () => {
@@ -29,10 +28,6 @@
typeof config['chat.context_compaction.model'] === 'string'
? config['chat.context_compaction.model']
: null;
- toolApprovalReviewModel =
- typeof config['tool_approval.review.model'] === 'string'
- ? config['tool_approval.review.model']
- : null;
compactTokenThreshold = Number(config['chat.compact_token_threshold']) || 80000;
} catch {
toast.error($t('admin.failedToLoadConfig'));
@@ -48,7 +43,6 @@
'chat.title_generation.enabled': titleGenerationEnabled,
'chat.title_generation.model': titleGenerationModel,
'chat.context_compaction.model': contextCompactionModel,
- 'tool_approval.review.model': toolApprovalReviewModel,
'chat.compact_token_threshold': Math.max(10000, Number(compactTokenThreshold) || 80000)
});
toast.success($t('settings.saved'));
@@ -153,28 +147,6 @@
-
-
- {$t('admin.toolApproval')}
-
-
-
-
- {$t('admin.toolApprovalReviewModel')}
-
-
-
-
-
-
- {$t('admin.toolApprovalReviewModelHint')}
-
-
diff --git a/cptr/frontend/src/lib/components/Admin/Tools.svelte b/cptr/frontend/src/lib/components/Admin/Tools.svelte
new file mode 100644
index 00000000..604eba17
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Tools.svelte
@@ -0,0 +1,245 @@
+
+
+
+ {#if loading}
+
+ {:else}
+
+
+
+
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte
index 4c4c3d0a..d5885b28 100644
--- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte
+++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte
@@ -79,6 +79,7 @@
let keepKeyboardFocus = false;
let audioClockOrigin: number | undefined;
let reapplyViewportAfterConfig = true;
+ let keyframeRequested = false;
const pressedKeys = new Map
>();
const touchPoints = new Map>();
const macClient = /Mac|iPhone|iPad/.test(navigator.userAgent);
@@ -88,6 +89,52 @@
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}
+ function requestKeyframe() {
+ if (keyframeRequested) return;
+ keyframeRequested = true;
+ send({ type: 'request_keyframe' });
+ }
+
+ function closeSocket() {
+ const current = socket;
+ socket = undefined;
+ if (!current) return;
+ current.onopen = null;
+ current.onmessage = null;
+ current.onclose = null;
+ current.onerror = null;
+ if (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING) {
+ current.close();
+ }
+ }
+
+ function resetInputState() {
+ pressedKeys.clear();
+ touchPoints.clear();
+ pendingPointer = undefined;
+ if (pointerFrame) {
+ cancelAnimationFrame(pointerFrame);
+ pointerFrame = 0;
+ }
+ }
+
+ function resetMediaState() {
+ try {
+ decoder?.close();
+ } catch {}
+ try {
+ audioDecoder?.close();
+ } catch {}
+ decoder = undefined;
+ audioDecoder = undefined;
+ audioClockOrigin = undefined;
+ keyframeRequested = false;
+ if (audioContext) {
+ void audioContext.close();
+ audioContext = undefined;
+ }
+ }
+
async function collectDeviceProfile(): Promise {
const uaData = (navigator as Navigator & { userAgentData?: UserAgentData }).userAgentData;
let highEntropy: Record = {};
@@ -140,6 +187,14 @@
function connect() {
if (disposed) return;
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = undefined;
+ }
+ releaseKeys();
+ resetInputState();
+ resetMediaState();
+ closeSocket();
ready = false;
lastViewport = '';
reapplyViewportAfterConfig = true;
@@ -155,6 +210,7 @@
socket.onmessage = receive;
socket.onclose = () => {
if (disposed) return;
+ socket = undefined;
onstatus('lost', 'Chrome connection lost');
const delays = [1000, 2000, 5000];
reconnectTimer = setTimeout(connect, delays[Math.min(reconnectAttempt++, delays.length - 1)]);
@@ -212,6 +268,11 @@
const media = view.getUint8(0);
const timestamp = Number(view.getBigUint64(6));
if (media === 1 && decoder?.state === 'configured') {
+ // Drop video when WebCodecs is falling behind; a fresh keyframe catches up.
+ if (decoder.decodeQueueSize > 3) {
+ requestKeyframe();
+ return;
+ }
try {
decoder.decode(
new EncodedVideoChunk({
@@ -221,7 +282,7 @@
})
);
} catch {
- send({ type: 'request_keyframe' });
+ requestKeyframe();
}
} else if (media === 2 && audioDecoder?.state === 'configured') {
try {
@@ -248,9 +309,10 @@
hasFrame = true;
onstatus('playing');
}
+ keyframeRequested = false;
},
error() {
- send({ type: 'request_keyframe' });
+ requestKeyframe();
}
});
decoder.configure({ codec: config.codec, optimizeForLatency: true });
@@ -637,15 +699,13 @@
onDestroy(() => {
disposed = true;
releaseKeys();
+ resetInputState();
observer?.disconnect();
window.visualViewport?.removeEventListener('resize', visualViewportResize);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (viewportTimer) clearTimeout(viewportTimer);
- if (pointerFrame) cancelAnimationFrame(pointerFrame);
- socket?.close();
- decoder?.close();
- audioDecoder?.close();
- if (audioContext) void audioContext.close();
+ closeSocket();
+ resetMediaState();
});
diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte
index e8d66130..7018ed43 100644
--- a/cptr/frontend/src/lib/components/Icon.svelte
+++ b/cptr/frontend/src/lib/components/Icon.svelte
@@ -121,6 +121,13 @@
+ {:else if name === 'usage'}
+
+
+
+
+
+
{:else if name === 'half-moon'}
{
return new Promise((resolve, reject) => {
const img = new Image();
+ const url = URL.createObjectURL(file);
img.onload = () => {
+ URL.revokeObjectURL(url);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
@@ -80,8 +82,11 @@
'image/png'
);
};
- img.onerror = () => reject(new Error('Failed to load image'));
- img.src = URL.createObjectURL(file);
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error('Failed to load image'));
+ };
+ img.src = url;
});
}
diff --git a/cptr/frontend/src/lib/components/Settings/Usage.svelte b/cptr/frontend/src/lib/components/Settings/Usage.svelte
new file mode 100644
index 00000000..453d9c1f
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Settings/Usage.svelte
@@ -0,0 +1,512 @@
+
+
+
+
+
{tr('usage.title')}
+
+
+ {#if loading}
+
+
+
+ {:else if !usage}
+
+ {tr('usage.failedToLoad')}
+
+ {:else}
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index 07280d01..28009fa0 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -5,6 +5,7 @@
import General from './Settings/General.svelte';
import Notifications from './Settings/Notifications.svelte';
import Appearance from './Settings/Appearance.svelte';
+ import Usage from './Settings/Usage.svelte';
import Memory from './Settings/Memory.svelte';
import PWA from './Settings/PWA.svelte';
import Account from './Settings/Account.svelte';
@@ -14,6 +15,7 @@
import Agents from './Admin/Agents.svelte';
import Models from './Admin/Models.svelte';
import Chat from './Admin/Chat.svelte';
+ import Tools from './Admin/Tools.svelte';
import Git from './Admin/Git.svelte';
import Skills from './Admin/Skills.svelte';
import Messaging from './Admin/Messaging.svelte';
@@ -31,6 +33,7 @@
| 'general'
| 'notifications'
| 'appearance'
+ | 'usage'
| 'memory'
| 'pwa'
| 'keyboard'
@@ -40,6 +43,7 @@
| 'agents'
| 'models'
| 'chat'
+ | 'tools'
| 'git'
| 'skills'
| 'messaging'
@@ -71,6 +75,7 @@
'agents',
'models',
'chat',
+ 'tools',
'git',
'messaging',
'gateway',
@@ -88,6 +93,7 @@
const tabs: SettingsTab[] = [
{ id: 'general', label: $t('settings.general'), icon: 'settings' },
{ id: 'appearance', label: $t('settings.appearance'), icon: 'sun-light' },
+ { id: 'usage', label: 'Usage', icon: 'usage' },
{ id: 'notifications', label: $t('general.notifications'), icon: 'chat-bubble' },
{ id: 'keyboard', label: $t('settings.keyboard'), icon: 'terminal' },
{ id: 'account', label: $t('settings.account'), icon: 'user' }
@@ -102,6 +108,7 @@
{ id: 'agents', label: $t('admin.agents'), icon: 'terminal' },
{ id: 'models', label: $t('admin.models'), icon: 'cube' },
{ id: 'chat', label: $t('admin.chat'), icon: 'chat-bubble' },
+ { id: 'tools', label: $t('admin.tools'), icon: 'terminal' },
{ id: 'git', label: $t('admin.git'), icon: 'git-branch' },
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
{ id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
@@ -198,6 +205,8 @@
{:else if activeTab === 'appearance'}
+ {:else if activeTab === 'usage'}
+
{:else if activeTab === 'memory'}
{:else if activeTab === 'pwa' && showPwaSettings}
@@ -216,6 +225,8 @@
{:else if activeTab === 'chat'}
+ {:else if activeTab === 'tools'}
+
{:else if activeTab === 'git'}
{:else if activeTab === 'skills'}
diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte
index 95f78bad..12ef07be 100644
--- a/cptr/frontend/src/lib/components/Terminal.svelte
+++ b/cptr/frontend/src/lib/components/Terminal.svelte
@@ -62,6 +62,7 @@
let fitAddon: FitAddon | null = null;
let ws: WebSocket | null = null;
let resizeObserver: ResizeObserver | null = null;
+ let themeObserver: MutationObserver | null = null;
let resizeTimeout: ReturnType | null = null;
let reconnectTimer: ReturnType | null = null;
let destroyed = false;
@@ -262,12 +263,12 @@
});
// Watch for theme changes
- const observer = new MutationObserver(() => {
+ themeObserver = new MutationObserver(() => {
if (term) {
term.options.theme = terminalTheme();
}
});
- observer.observe(document.documentElement, {
+ themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style']
});
@@ -506,8 +507,14 @@
if (reconnectTimer) clearTimeout(reconnectTimer);
if (hapticTimer) clearTimeout(hapticTimer);
resizeObserver?.disconnect();
+ themeObserver?.disconnect();
releaseWakeLock();
- ws?.close();
+ if (ws) {
+ ws.onmessage = null;
+ ws.onclose = null;
+ ws.onerror = null;
+ ws.close();
+ }
term?.dispose();
});
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
index 6e38f08f..77503f02 100644
--- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
+++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
@@ -1721,6 +1721,11 @@
showTtsFailure(err?.message || 'playback failed');
}
} finally {
+ if (generation === ttsGeneration && ttsObjectUrl) {
+ URL.revokeObjectURL(ttsObjectUrl);
+ ttsObjectUrl = null;
+ }
+ if (generation === ttsGeneration) ttsAudio = null;
if (generation === ttsGeneration) ttsPlaying = false;
if (generation === ttsGeneration) speakingMessageId = null;
if (generation === ttsGeneration) ttsStopRequested = false;
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json
index c8794d55..2a8f0d04 100644
--- a/cptr/frontend/src/lib/i18n/locales/en.json
+++ b/cptr/frontend/src/lib/i18n/locales/en.json
@@ -122,6 +122,7 @@
"settings.back": "Back",
"settings.general": "General",
"settings.appearance": "Appearance",
+ "settings.usage": "Usage",
"settings.memory": "Memory",
"pwa.settingsTitle": "Progressive Web App",
"intent.chooseWorkspace": "Workspace",
@@ -152,6 +153,38 @@
"settings.save": "Save",
"settings.saved": "Saved",
"settings.saving": "Savingβ¦",
+ "usage.title": "Usage",
+ "usage.failedToLoad": "Failed to load usage",
+ "usage.overview": "Overview",
+ "usage.lifetimeTokens": "Lifetime tokens",
+ "usage.peakTokens": "Peak tokens",
+ "usage.longestActiveChat": "Longest active chat",
+ "usage.currentStreak": "Current streak",
+ "usage.longestStreak": "Longest streak",
+ "usage.tokenActivity": "Token activity",
+ "usage.daily": "Daily",
+ "usage.weekly": "Weekly",
+ "usage.cumulative": "Cumulative",
+ "usage.weekOf": "Week of",
+ "usage.through": "Through",
+ "usage.tokens": "tokens",
+ "usage.messages": "messages",
+ "usage.chats": "chats",
+ "usage.runs": "runs",
+ "usage.noModelData": "No model data",
+ "usage.activity": "Activity",
+ "usage.noData": "No usage data found",
+ "usage.activityInsights": "Activity insights",
+ "usage.models": "Models",
+ "usage.averageTokensPerChat": "Average tokens per chat",
+ "usage.averageMessagesPerActiveDay": "Average messages per active day",
+ "usage.userMessages": "User messages",
+ "usage.assistantMessages": "Assistant messages",
+ "usage.totalChats": "Total chats",
+ "usage.topModels": "Top models",
+ "usage.noModelUsage": "No model usage found",
+ "usage.mostUsedTools": "Most used tools",
+ "usage.estimateNote": "Token counts are estimates and may not reflect actual API usage",
"memory.failedToLoad": "Failed to load memory",
"memory.failedToSaveSettings": "Failed to save memory settings",
"memory.failedToUpdate": "Failed to update memory",
@@ -466,6 +499,7 @@
"admin.browserFirecrawlBaseUrlHint": "Change for self-hosted Firecrawl instances",
"admin.models": "Models",
"admin.chat": "Chat",
+ "admin.tools": "Tools",
"admin.chatTitles": "Titles",
"admin.chatTitleGeneration": "Chat title generation",
"admin.chatTitleGenerationHint": "Name new chats after the first response.",
@@ -476,6 +510,13 @@
"admin.toolApproval": "Tool approval",
"admin.toolApprovalReviewModel": "Review model",
"admin.toolApprovalReviewModelHint": "Reviews pending tool calls in Auto mode.",
+ "admin.toolApprovalDefaultBuiltinApproval": "Default built-in tool approval",
+ "admin.toolApprovalDefaultBuiltinApprovalHint": "Used when a built-in tool does not define its own policy.",
+ "admin.toolApprovalBuiltinTools": "Built-in tool approval",
+ "admin.toolApprovalDefault": "Default",
+ "admin.toolApprovalAllow": "Allow",
+ "admin.toolApprovalReview": "Review",
+ "admin.toolApprovalEffective": "Effective: {{value}}",
"admin.git": "Git",
"admin.gitCommitMessageModel": "Commit message model",
"admin.gitCommitMessageModelHint": "Drafts messages from staged changes.",
diff --git a/cptr/routers/admin.py b/cptr/routers/admin.py
index 7921e708..99c61b07 100644
--- a/cptr/routers/admin.py
+++ b/cptr/routers/admin.py
@@ -477,9 +477,7 @@ class UpdateModelConfigRequest(BaseModel):
@router.put("/models/{model_id:path}/config")
-async def update_model_config(
- model_id: str, body: UpdateModelConfigRequest, request: Request
-):
+async def update_model_config(model_id: str, body: UpdateModelConfigRequest, request: Request):
"""Update config for a specific model (or '*' for global defaults)."""
require_admin(request)
all_config = await Config.get(CONFIG_KEY_CHAT_MODELS) or {}
@@ -532,6 +530,48 @@ def _mask_tool_server(server: dict) -> dict:
return masked
+@router.get("/tools/approval")
+async def get_tool_approval(request: Request):
+ """Return built-in tool approval defaults for the Chat settings UI."""
+ require_admin(request)
+ from cptr.utils.tools import (
+ ALL_TOOLS,
+ BUILTIN_TOOL_GROUPS,
+ normalize_tool_approval,
+ )
+
+ raw_default = await Config.get("tool_approval.default_builtin_approval")
+ raw_overrides = await Config.get("tool_approval.builtin_tools") or {}
+ overrides = {}
+ if isinstance(raw_overrides, dict):
+ overrides = {
+ name: policy
+ for name, value in raw_overrides.items()
+ if (policy := normalize_tool_approval(value))
+ }
+
+ groups = []
+ for group_id, names in BUILTIN_TOOL_GROUPS.items():
+ tools = []
+ for name in names:
+ tool = ALL_TOOLS.get(name)
+ if tool:
+ tools.append(
+ {
+ "name": name,
+ "default_approval": normalize_tool_approval(tool.get("approval")),
+ }
+ )
+ if tools:
+ groups.append({"id": group_id, "tools": tools})
+
+ return {
+ "default_approval": normalize_tool_approval(raw_default) or "review",
+ "overrides": overrides,
+ "groups": groups,
+ }
+
+
@router.get("/tools/servers")
async def list_tool_servers(request: Request):
"""List all configured tool servers (keys masked)."""
diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py
index bf0ce828..e7cbd6f3 100644
--- a/cptr/routers/chat.py
+++ b/cptr/routers/chat.py
@@ -3,17 +3,21 @@
from __future__ import annotations
import asyncio
+from collections import defaultdict
from copy import deepcopy
+from datetime import date, datetime, timedelta, timezone
import json
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
+from sqlalchemy import select
from cptr.models import Chat, ChatMessage, Config, is_internal_chat
from cptr.utils.config import check_access, now_ms, _get_jwt_secret
from cptr.utils.crypto import decrypt_key
+from cptr.utils.db import get_db
from cptr.utils.workspace import ensure_cptr_gitignored
from cptr.utils.chat_export import chat_directory
@@ -324,6 +328,258 @@ async def get_models(request: Request):
return {"models": models, "default": default_model}
+@router.get("/usage")
+async def get_usage(request: Request, days: int | None = Query(None, ge=7, le=732)):
+ """Aggregate personal chat usage for the settings Usage tab."""
+ user_id = _get_user(request)
+
+ async with await get_db() as db:
+ chat_result = await db.execute(select(Chat).where(Chat.user_id == user_id))
+ chats = [
+ chat
+ for chat in chat_result.scalars().all()
+ if not is_internal_chat(chat.meta if isinstance(chat.meta, dict) else None)
+ ]
+
+ messages = []
+ if chats:
+ message_result = await db.execute(
+ select(ChatMessage)
+ .where(ChatMessage.chat_id.in_([chat.id for chat in chats]))
+ .order_by(ChatMessage.chat_id, ChatMessage.created_at)
+ )
+ messages = list(message_result.scalars().all())
+
+ chat_ids_by_day: dict[str, set[str]] = defaultdict(set)
+ day_stats: dict[str, dict] = defaultdict(lambda: {"tokens": 0, "messages": 0, "models": {}})
+ model_stats: dict[str, dict] = defaultdict(lambda: {"messages": 0, "total_tokens": 0})
+ tool_counts: dict[str, int] = defaultdict(int)
+ models_used: set[str] = set()
+ rows = []
+ last_message_at_by_chat: dict[str, int] = {}
+ active_seconds_by_chat: dict[str, int] = defaultdict(int)
+ user_messages = 0
+ assistant_messages = 0
+ lifetime_tokens = 0
+
+ for message in messages:
+ created_at = int(message.created_at or 0)
+ created_seconds = created_at / (
+ 1_000_000_000
+ if created_at > 10_000_000_000_000
+ else 1000
+ if created_at > 10_000_000_000
+ else 1
+ )
+ created_seconds = int(created_seconds)
+ day_date = datetime.fromtimestamp(created_seconds, tz=timezone.utc).date()
+ model_id = message.model or None
+ usage = message.usage if isinstance(message.usage, dict) else {}
+ try:
+ tokens = max(
+ 0,
+ int(
+ usage.get("total_tokens")
+ or (usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
+ + (usage.get("output_tokens") or usage.get("completion_tokens") or 0)
+ or 0
+ ),
+ )
+ except (TypeError, ValueError):
+ tokens = 0
+ if tokens:
+ lifetime_tokens += tokens
+
+ if message.role == "assistant" and model_id:
+ models_used.add(model_id)
+
+ last_message_at = last_message_at_by_chat.get(message.chat_id)
+ if last_message_at is not None:
+ delta = created_seconds - last_message_at
+ if 0 < delta <= 30 * 60:
+ active_seconds_by_chat[message.chat_id] += delta
+ last_message_at_by_chat[message.chat_id] = created_seconds
+ rows.append((message, day_date, tokens))
+
+ today = datetime.now(timezone.utc).date()
+ days = days or 730
+ start = today - timedelta(days=days - 1)
+ period_start = int(
+ datetime.combine(start, datetime.min.time(), tzinfo=timezone.utc).timestamp()
+ )
+ period_end = int(datetime.combine(today, datetime.max.time(), tzinfo=timezone.utc).timestamp())
+
+ for message, day_date, tokens in rows:
+ if day_date < start or day_date > today:
+ continue
+
+ day = day_date.isoformat()
+ day_stats[day]["messages"] += 1
+ chat_ids_by_day[day].add(message.chat_id)
+
+ if message.role == "user":
+ user_messages += 1
+ elif message.role == "assistant":
+ assistant_messages += 1
+
+ model_id = message.model or None
+ if tokens:
+ day_stats[day]["tokens"] += tokens
+
+ if message.role == "assistant" and model_id:
+ day_models = day_stats[day]["models"]
+ day_models[model_id] = day_models.get(model_id, 0) + 1
+ model_stats[model_id]["messages"] += 1
+ model_stats[model_id]["total_tokens"] += tokens
+
+ for value in (message.output, message.meta):
+ stack = [value]
+ while stack:
+ item = stack.pop()
+ if isinstance(item, list):
+ stack.extend(item)
+ continue
+ if not isinstance(item, dict):
+ continue
+ item_type = str(item.get("type") or "")
+ if "tool" in item_type or item_type in {"function_call", "function_call_output"}:
+ for name in (item.get("name"), item.get("tool_name")):
+ if isinstance(name, str) and name.strip() and len(name.strip()) <= 128:
+ tool_counts[name.strip()] += 1
+ if isinstance(item.get("function"), dict):
+ name = item["function"].get("name")
+ if isinstance(name, str) and name.strip() and len(name.strip()) <= 128:
+ tool_counts[name.strip()] += 1
+ for key in ("tool_calls", "tools", "output", "meta"):
+ if key in item:
+ stack.append(item[key])
+
+ heatmap = []
+ cumulative_tokens = 0
+ cumulative_messages = 0
+ cumulative_chats = 0
+ cumulative_models: dict[str, int] = {}
+ weekly: dict[str, dict] = defaultdict(
+ lambda: {"tokens": 0, "messages": 0, "chats": 0, "models": {}}
+ )
+ cumulative = []
+
+ for offset in range((today - start).days + 1):
+ current = start + timedelta(days=offset)
+ key = current.isoformat()
+ stats = day_stats.get(key, {"tokens": 0, "messages": 0, "models": {}})
+ chats_for_day = chat_ids_by_day.get(key, set())
+ entry = {
+ "date": key,
+ "tokens": stats["tokens"],
+ "messages": stats["messages"],
+ "chats": len(chats_for_day),
+ "models": stats["models"],
+ }
+ heatmap.append(entry)
+
+ week_key = (current - timedelta(days=current.weekday())).isoformat()
+ weekly_entry = weekly[week_key]
+ weekly_entry["tokens"] += stats["tokens"]
+ weekly_entry["messages"] += stats["messages"]
+ weekly_entry["chats"] += len(chats_for_day)
+ for model_id, count in stats["models"].items():
+ weekly_entry["models"][model_id] = weekly_entry["models"].get(model_id, 0) + count
+
+ cumulative_tokens += stats["tokens"]
+ cumulative_messages += stats["messages"]
+ cumulative_chats += len(chats_for_day)
+ for model_id, count in stats["models"].items():
+ cumulative_models[model_id] = cumulative_models.get(model_id, 0) + count
+ cumulative.append(
+ {
+ "date": key,
+ "tokens": cumulative_tokens,
+ "messages": cumulative_messages,
+ "chats": cumulative_chats,
+ "models": dict(cumulative_models),
+ }
+ )
+
+ active_days = [day for day, stats in day_stats.items() if stats["messages"] > 0]
+ active_dates = {date.fromisoformat(day) for day in active_days}
+ current_streak = 0
+ cursor = today
+ while cursor in active_dates:
+ current_streak += 1
+ cursor -= timedelta(days=1)
+
+ longest_streak = 0
+ run = 0
+ previous = None
+ for active_date in sorted(active_dates):
+ run = run + 1 if previous and active_date == previous + timedelta(days=1) else 1
+ previous = active_date
+ longest_streak = max(longest_streak, run)
+
+ total_chats = len(chats)
+ longest_chat_seconds = max(active_seconds_by_chat.values(), default=0)
+
+ weekly_heatmap = [
+ {
+ "date": week,
+ "tokens": stats["tokens"],
+ "messages": stats["messages"],
+ "chats": stats["chats"],
+ "models": stats["models"],
+ }
+ for week, stats in sorted(weekly.items())
+ ]
+ total_messages = user_messages + assistant_messages
+
+ return {
+ "totals": {
+ "lifetime_tokens": lifetime_tokens,
+ "peak_daily_tokens": max((entry["tokens"] for entry in heatmap), default=0),
+ "longest_chat_seconds": longest_chat_seconds,
+ "current_streak": current_streak,
+ "longest_streak": longest_streak,
+ "models_used": len(models_used),
+ "user_messages": user_messages,
+ "assistant_messages": assistant_messages,
+ "messages": total_messages,
+ "total_chats": total_chats,
+ },
+ "insights": {
+ "average_tokens_per_chat": round(lifetime_tokens / total_chats, 1)
+ if total_chats
+ else 0,
+ "average_messages_per_active_day": round(total_messages / len(active_days), 1)
+ if active_days
+ else 0,
+ "user_message_share": round((user_messages / total_messages) * 100, 1)
+ if total_messages
+ else 0,
+ "assistant_message_share": round((assistant_messages / total_messages) * 100, 1)
+ if total_messages
+ else 0,
+ },
+ "heatmap": heatmap,
+ "weekly_heatmap": weekly_heatmap,
+ "cumulative_heatmap": cumulative,
+ "top_models": [
+ {"model_id": model_id, **stats}
+ for model_id, stats in sorted(
+ model_stats.items(), key=lambda item: item[1]["messages"], reverse=True
+ )[:5]
+ ],
+ "top_tools": [
+ {"name": name, "count": count}
+ for name, count in sorted(tool_counts.items(), key=lambda item: item[1], reverse=True)
+ ][:5],
+ "period": {
+ "start_date": period_start,
+ "end_date": period_end,
+ "days": (today - start).days + 1,
+ },
+ }
+
+
async def _fetch_provider_models(conn: dict) -> list[str] | None:
"""Discover models from a provider's /models endpoint."""
import httpx
@@ -724,7 +980,7 @@ async def send_message(body: SendMessageRequest, request: Request):
"""
user_id = _get_user(request)
- from cptr.utils.model_targets import AgentModelTarget, resolve_model_target
+ from cptr.utils.model_targets import resolve_model_target
target = await resolve_model_target(body.model_id, request.app.state)
@@ -734,8 +990,6 @@ async def send_message(body: SendMessageRequest, request: Request):
if not chat or chat.user_id != user_id:
raise HTTPException(404, "chat not found")
workspace = (chat.meta or {}).get("workspace") or None
- if not workspace and isinstance(target, AgentModelTarget):
- raise HTTPException(400, "Home chats require an API model")
# Sync params into chat meta
if chat.meta is None:
chat.meta = {}
@@ -745,8 +999,6 @@ async def send_message(body: SendMessageRequest, request: Request):
await Chat.update_meta(chat.id, chat.meta)
else:
workspace = body.workspace or None
- if not workspace and isinstance(target, AgentModelTarget):
- raise HTTPException(400, "Home chats require an API model")
title = body.content[:50].strip() or "New Chat"
meta = {
"params": body.params,
diff --git a/cptr/routers/workspace.py b/cptr/routers/workspace.py
index 37da2753..d2c3bb88 100644
--- a/cptr/routers/workspace.py
+++ b/cptr/routers/workspace.py
@@ -121,6 +121,10 @@ def _scan() -> list[FileEntry]:
".java",
".c",
".h",
+ ".cc",
+ ".cxx",
+ ".hh",
+ ".hxx",
".cpp",
".hpp",
".rb",
@@ -193,6 +197,10 @@ def _detect_language(name: str) -> Optional[str]:
".java": "java",
".c": "c",
".h": "c",
+ ".cc": "cpp",
+ ".cxx": "cpp",
+ ".hh": "cpp",
+ ".hxx": "cpp",
".cpp": "cpp",
".hpp": "cpp",
".rb": "ruby",
diff --git a/cptr/utils/adapters/signal.py b/cptr/utils/adapters/signal.py
index 506bbff6..2a2f689a 100644
--- a/cptr/utils/adapters/signal.py
+++ b/cptr/utils/adapters/signal.py
@@ -140,8 +140,18 @@ async def send(self, chat_id: str, text: str) -> str | None:
return timestamp
async def edit(self, chat_id: str, message_id: str, text: str) -> None:
- """Signal has no edit API; send the updated text as a new message."""
- await self.send(chat_id, text)
+ if not self._http:
+ return
+ resp = await self._http.post(
+ f"{self._base_url}/v2/send",
+ json={
+ "message": text[:MAX_MESSAGE_LEN],
+ "number": self._phone,
+ "recipients": [chat_id],
+ "edit_timestamp": int(message_id),
+ },
+ )
+ resp.raise_for_status()
async def send_typing(self, chat_id: str) -> None:
"""Send typing indicator via signal-cli."""
diff --git a/cptr/utils/adapters/telegram.py b/cptr/utils/adapters/telegram.py
index 03ab97b5..a20d2668 100644
--- a/cptr/utils/adapters/telegram.py
+++ b/cptr/utils/adapters/telegram.py
@@ -240,7 +240,7 @@ async def _process_update(self, update: dict) -> None:
text = message.get("text") or message.get("caption") or ""
# Collect attachments from media types
- attachments: list = []
+ attachments: list[Attachment] = []
# Photos β Telegram sends multiple sizes, pick the largest
if message.get("photo"):
@@ -286,7 +286,6 @@ async def _process_update(self, update: dict) -> None:
audio = message["audio"]
file_data = await self._download_file(audio["file_id"])
if file_data:
- from cptr.utils.bridge import Attachment
fname = audio.get("file_name", "audio.mp3")
attachments.append(Attachment(
type="audio",
diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py
index cbdd0c9f..f67d5c86 100644
--- a/cptr/utils/agents/detection.py
+++ b/cptr/utils/agents/detection.py
@@ -21,6 +21,7 @@
model_id_for_profile,
normalize_agent_profiles,
)
+from cptr.utils.agents.opencode import opencode_server_url_candidates
DETECTION_TTL_SECONDS = 30
CLAUDE_MODEL_FALLBACKS = [
@@ -61,35 +62,58 @@ def _resolve_command(command: str) -> str | None:
def _find_claude_desktop_command() -> str | None:
if os.name == "nt":
+ roots = []
appdata = os.environ.get("APPDATA")
- root = os.path.join(appdata, "Claude", "claude-code") if appdata else None
+ if appdata:
+ roots.append(os.path.join(appdata, "Claude", "claude-code"))
+ localappdata = os.environ.get("LOCALAPPDATA")
+ packages_dir = os.path.join(localappdata, "Packages") if localappdata else None
+ if packages_dir and os.path.isdir(packages_dir):
+ with suppress(OSError):
+ for name in os.listdir(packages_dir):
+ if name.startswith("Claude_"):
+ roots.append(
+ os.path.join(
+ packages_dir,
+ name,
+ "LocalCache",
+ "Roaming",
+ "Claude",
+ "claude-code",
+ )
+ )
relative_paths = (("claude.exe",),)
else:
- root = os.path.join(
- os.path.expanduser("~"),
- "Library",
- "Application Support",
- "Claude",
- "claude-code",
- )
+ roots = [
+ os.path.join(
+ os.path.expanduser("~"),
+ "Library",
+ "Application Support",
+ "Claude",
+ "claude-code",
+ )
+ ]
relative_paths = (
("claude.app", "Contents", "MacOS", "claude"),
("claude",),
)
candidates: list[tuple[tuple[int, int, int], str]] = []
- if root is not None and os.path.isdir(root):
- for name in os.listdir(root):
- version_dir = os.path.join(root, name)
- version = _parse_version_tuple(name)
- if version is None or not os.path.isdir(version_dir):
- continue
- for relative_path in relative_paths:
- candidate = os.path.join(version_dir, *relative_path)
- if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
- candidates.append((version, candidate))
- break
- return max(candidates)[1] if candidates else None
+ for root in roots:
+ if not os.path.isdir(root):
+ continue
+ with suppress(OSError):
+ for name in os.listdir(root):
+ version_dir = os.path.join(root, name)
+ version = _parse_version_tuple(name)
+ if version is None or not os.path.isdir(version_dir):
+ continue
+ for relative_path in relative_paths:
+ candidate = os.path.join(version_dir, *relative_path)
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ candidates.append((version, candidate))
+ break
+ return max(candidates, key=lambda item: item[0])[1] if candidates else None
async def _run_probe(
@@ -433,7 +457,9 @@ async def _probe_cursor_models(command: str, profile: dict[str, Any]) -> list[st
)
try:
await asyncio.wait_for(client.start(), timeout=10)
- result = await asyncio.wait_for(client.request("cursor/list_available_models", {}), timeout=5)
+ result = await asyncio.wait_for(
+ client.request("cursor/list_available_models", {}), timeout=5
+ )
models = []
for item in result.get("models") or []:
if isinstance(item, dict) and isinstance(item.get("value"), str):
@@ -489,30 +515,42 @@ async def _probe_opencode_models(command: str, profile: dict[str, Any]) -> list[
env={**env, "OPENCODE_CONFIG_CONTENT": "{}"},
)
server_url = await _read_opencode_server_url(proc, port)
- async with httpx.AsyncClient(base_url=server_url, timeout=5) as client:
- headers = {}
- if password:
- import base64
-
- token = base64.b64encode(f"opencode:{password}".encode()).decode()
- headers["Authorization"] = f"Basic {token}"
- providers = await _opencode_json(client, ["provider.list", "provider/list", "provider"], headers)
- provider_list = providers.get("data") if isinstance(providers.get("data"), dict) else providers
- connected = set(provider_list.get("connected") or [])
- all_providers = provider_list.get("all") or []
- models: list[str] = []
- for provider in all_providers:
- if not isinstance(provider, dict):
- continue
- provider_id = provider.get("id")
- if not isinstance(provider_id, str) or provider_id not in connected:
- continue
- raw_models = provider.get("models")
- if isinstance(raw_models, dict):
- for model_id in raw_models:
- if isinstance(model_id, str) and model_id.strip():
- models.append(f"{provider_id}/{model_id.strip()}")
- return models or None
+ headers = {}
+ if password:
+ import base64
+
+ token = base64.b64encode(f"opencode:{password}".encode()).decode()
+ headers["Authorization"] = f"Basic {token}"
+ for candidate_url in opencode_server_url_candidates(server_url):
+ try:
+ async with httpx.AsyncClient(base_url=candidate_url, timeout=5) as client:
+ providers = await _opencode_json(
+ client, ["provider.list", "provider/list", "provider"], headers
+ )
+ provider_list = (
+ providers.get("data")
+ if isinstance(providers.get("data"), dict)
+ else providers
+ )
+ connected = set(provider_list.get("connected") or [])
+ all_providers = provider_list.get("all") or []
+ models: list[str] = []
+ for provider in all_providers:
+ if not isinstance(provider, dict):
+ continue
+ provider_id = provider.get("id")
+ if not isinstance(provider_id, str) or provider_id not in connected:
+ continue
+ raw_models = provider.get("models")
+ if isinstance(raw_models, dict):
+ for model_id in raw_models:
+ if isinstance(model_id, str) and model_id.strip():
+ models.append(f"{provider_id}/{model_id.strip()}")
+ if models:
+ return models
+ except Exception:
+ continue
+ return None
except Exception:
return None
finally:
diff --git a/cptr/utils/agents/opencode.py b/cptr/utils/agents/opencode.py
index 2875c230..4d80a804 100644
--- a/cptr/utils/agents/opencode.py
+++ b/cptr/utils/agents/opencode.py
@@ -11,6 +11,7 @@
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any, AsyncIterator
+from urllib.parse import urlsplit, urlunsplit
import httpx
@@ -25,12 +26,35 @@
from cptr.utils.agents.prompts import turn_prompt_text
+_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
+
+
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
+def opencode_server_url_candidates(server_url: str) -> list[str]:
+ server_url = server_url.strip()
+ if not server_url:
+ return []
+ parsed = urlsplit(server_url)
+ in_container = Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
+ if not in_container or parsed.hostname not in _LOOPBACK_HOSTS:
+ return [server_url]
+ docker_url = urlunsplit(
+ (
+ parsed.scheme,
+ f"host.docker.internal{':' + str(parsed.port) if parsed.port else ''}",
+ parsed.path,
+ parsed.query,
+ parsed.fragment,
+ )
+ )
+ return [server_url] if docker_url == server_url else [server_url, docker_url]
+
+
async def _server_url_from_stdout(proc: asyncio.subprocess.Process, port: int) -> str:
assert proc.stdout is not None
fallback = f"http://127.0.0.1:{port}"
@@ -109,6 +133,8 @@ async def _request(
try:
response = await client.request(method, f"/{path}", headers=headers, json=json_body)
response.raise_for_status()
+ if not response.content:
+ return {}
data = response.json()
return data if isinstance(data, dict) else {}
except Exception as exc: # noqa: BLE001 - try alternate generated route names.
@@ -181,7 +207,13 @@ async def _start_opencode_prompt(
await _request(
client,
"POST",
- ["session.promptAsync", "session/promptAsync", "session/prompt"],
+ [
+ f"session/{session_id}/prompt_async",
+ f"session/{session_id}/message",
+ "session.promptAsync",
+ "session/promptAsync",
+ "session/prompt",
+ ],
headers=headers,
json_body={
"sessionID": session_id,
@@ -191,16 +223,41 @@ async def _start_opencode_prompt(
)
-def _text_from_event(event: dict[str, Any], emitted: dict[str, str]) -> str | None:
+def _role_update_from_event(event: dict[str, Any]) -> tuple[str, str] | None:
+ if event.get("type") != "message.updated":
+ return None
+ props = event.get("properties") if isinstance(event.get("properties"), dict) else {}
+ info = props.get("info") if isinstance(props.get("info"), dict) else {}
+ message_id = info.get("id")
+ role = info.get("role")
+ if isinstance(message_id, str) and isinstance(role, str):
+ return message_id, role
+ return None
+
+
+def _text_from_event(
+ event: dict[str, Any],
+ emitted: dict[str, str],
+ message_roles: dict[str, str],
+) -> str | None:
event_type = event.get("type")
props = event.get("properties") if isinstance(event.get("properties"), dict) else {}
if event_type == "message.part.delta":
+ message_id = props.get("messageID")
+ if isinstance(message_id, str) and message_roles.get(message_id) == "user":
+ return None
delta = props.get("delta")
+ part_id = props.get("partID")
+ if isinstance(part_id, str) and isinstance(delta, str):
+ emitted[part_id] = f"{emitted.get(part_id, '')}{delta}"
return delta if isinstance(delta, str) and delta else None
if event_type == "message.part.updated":
part = props.get("part") if isinstance(props.get("part"), dict) else {}
if part.get("type") not in ("text", "reasoning"):
return None
+ message_id = part.get("messageID")
+ if isinstance(message_id, str) and message_roles.get(message_id) == "user":
+ return None
part_id = part.get("id")
text = part.get("text")
if not isinstance(part_id, str) or not isinstance(text, str):
@@ -273,66 +330,98 @@ async def run_opencode_agent(
try:
async with _opencode_server(profile, workspace) as (server_url, _proc):
headers = _headers(profile)
- async with httpx.AsyncClient(base_url=server_url, timeout=None) as client:
- session_id = _resume_session_id(resume_state)
- resumed = bool(session_id)
- if session_id is None:
- session_id = await _create_opencode_session(client, headers)
-
- parsed_model = _parse_model(model)
- while True:
- emitted: dict[str, str] = {}
- event_queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue()
- event_task = asyncio.create_task(
- _collect_opencode_events(client, headers, session_id, emitted, event_queue)
- )
-
- prompt = turn_prompt_text(messages, system_prompt, resumed=resumed)
- parts = _opencode_parts(prompt, attachments)
- try:
- try:
- await _start_opencode_prompt(
- client, headers, session_id, parsed_model, parts
- )
- except Exception:
- if not resumed:
- raise
- event_task.cancel()
- with suppress(asyncio.CancelledError):
- await event_task
+ urls = opencode_server_url_candidates(server_url)
+ last_connect_error: Exception | None = None
+ for index, candidate_url in enumerate(urls):
+ try:
+ async with httpx.AsyncClient(
+ base_url=candidate_url,
+ timeout=httpx.Timeout(None, connect=5),
+ ) as client:
+ session_id = _resume_session_id(resume_state)
+ resumed = bool(session_id)
+ if session_id is None:
session_id = await _create_opencode_session(client, headers)
- resumed = False
- continue
+ parsed_model = _parse_model(model)
while True:
- item = await event_queue.get()
- if item is None:
- break
- yield item
- except asyncio.CancelledError:
- with suppress(Exception):
- await _request(
- client,
- "POST",
- ["session.abort", "session/abort"],
- headers=headers,
- json_body={"sessionID": session_id},
+ emitted: dict[str, str] = {}
+ event_queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue()
+ event_task = asyncio.create_task(
+ _collect_opencode_events(
+ client, headers, session_id, emitted, event_queue
+ )
)
- raise
- finally:
- event_task.cancel()
- with suppress(asyncio.CancelledError):
- await event_task
- break
-
- yield AgentDone(
- resume_state={
- "profile_id": profile["id"],
- "session_id": session_id,
- "workspace": workspace,
- "model": model,
- }
- )
+
+ prompt = turn_prompt_text(messages, system_prompt, resumed=resumed)
+ parts = _opencode_parts(prompt, attachments)
+ try:
+ try:
+ await _start_opencode_prompt(
+ client, headers, session_id, parsed_model, parts
+ )
+ except Exception:
+ if not resumed:
+ raise
+ event_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await event_task
+ session_id = await _create_opencode_session(client, headers)
+ resumed = False
+ continue
+
+ while True:
+ item = await event_queue.get()
+ if item is None:
+ break
+ yield item
+ except asyncio.CancelledError:
+ with suppress(Exception):
+ await _request(
+ client,
+ "POST",
+ [
+ f"session/{session_id}/abort",
+ "session.abort",
+ "session/abort",
+ ],
+ headers=headers,
+ json_body={"sessionID": session_id},
+ )
+ raise
+ finally:
+ event_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await event_task
+ break
+
+ yield AgentDone(
+ resume_state={
+ "profile_id": profile["id"],
+ "session_id": session_id,
+ "workspace": workspace,
+ "model": model,
+ }
+ )
+ return
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
+ last_connect_error = exc
+ if index < len(urls) - 1:
+ continue
+ if len(urls) > 1:
+ port = urlsplit(server_url).port or 4096
+ raise RuntimeError(
+ "Unable to connect to OpenCode from Docker. If OpenCode is running "
+ "on the host, start it with "
+ f"`opencode serve --hostname 0.0.0.0 --port {port}` and set the "
+ f"OpenCode Server URL to `http://host.docker.internal:{port}`. "
+ "On Linux Docker, add "
+ "`--add-host=host.docker.internal:host-gateway` if that hostname "
+ "is unavailable."
+ ) from exc
+ raise
+ if last_connect_error:
+ raise last_connect_error
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - surfaced in chat.
@@ -346,6 +435,7 @@ async def _collect_opencode_events(
emitted: dict[str, str],
queue: asyncio.Queue[AgentEvent | None],
) -> None:
+ message_roles: dict[str, str] = {}
try:
for path in ("event.subscribe", "event/subscribe", "event"):
try:
@@ -367,7 +457,10 @@ async def _collect_opencode_events(
)
if props.get("sessionID") != session_id:
continue
- text = _text_from_event(event, emitted)
+ role_update = _role_update_from_event(event)
+ if role_update:
+ message_roles[role_update[0]] = role_update[1]
+ text = _text_from_event(event, emitted, message_roles)
if text:
await queue.put(AgentTextDelta(text))
tool = _tool_from_event(event)
diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py
index f40e69b4..3d103f50 100644
--- a/cptr/utils/ai.py
+++ b/cptr/utils/ai.py
@@ -61,7 +61,14 @@ def _openrouter_headers(url: str) -> dict[str, str]:
read=STREAM_READ_TIMEOUT_SECONDS,
write=STREAM_WRITE_TIMEOUT_SECONDS,
)
+
+
+class EmptyCompletionError(RuntimeError):
+ pass
+
+
_STREAM_RETRY_ERRORS = (
+ EmptyCompletionError,
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.HTTPStatusError,
@@ -779,6 +786,12 @@ def complete_reasoning_item() -> dict | None:
emitted = True
yield {"type": "output", "item": item}
raw = chunk["usage"]
+ usage_tokens = sum(
+ raw.get(key, 0)
+ for key in ("prompt_tokens", "completion_tokens", "total_tokens")
+ )
+ if not emitted and usage_tokens <= 0:
+ continue
emitted = True
yield {
"type": "usage",
@@ -792,6 +805,11 @@ def complete_reasoning_item() -> dict | None:
if item is not None:
emitted = True
yield {"type": "output", "item": item}
+ if not emitted:
+ raise EmptyCompletionError(
+ "Upstream provider returned an empty completion with no text, "
+ "output items, tool calls, or usage tokens."
+ )
emitted = True
yield {"type": "done"}
return
diff --git a/cptr/utils/bridge.py b/cptr/utils/bridge.py
index 3b6b11fd..7181cc4f 100644
--- a/cptr/utils/bridge.py
+++ b/cptr/utils/bridge.py
@@ -13,11 +13,10 @@
import asyncio
import logging
-import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
-from typing import Any, Awaitable, Callable, Optional
+from typing import Awaitable, Callable, Optional
logger = logging.getLogger(__name__)
_current_bot_manager: "BotManager | None" = None
@@ -612,9 +611,6 @@ async def _process_attachments(
file_entries match the web UI format so _load_message_history handles
them automatically (base64 for images, file:// refs for documents).
"""
- from cptr.models import Config
- from cptr.utils.config import _get_jwt_secret
- from cptr.utils.crypto import decrypt_key
from cptr.utils.storage import get_storage
file_entries: list[dict] = []
@@ -915,13 +911,14 @@ async def _stream_loop(
except Exception:
logger.exception("[bridge] Failed to send final chunk")
else:
- # Discord: edit the placeholder, then send overflow
+ # Edit the placeholder, then send overflow.
if len(final_display) <= max_len and platform_msg_id:
try:
await adapter.edit(platform_chat_id, platform_msg_id, final_display)
return
except Exception:
- pass
+ logger.debug("[bridge] Final edit failed", exc_info=True)
+ return
chunks = chunk_message(final_display, max_len)
if platform_msg_id and chunks:
@@ -929,7 +926,8 @@ async def _stream_loop(
await adapter.edit(platform_chat_id, platform_msg_id, chunks[0])
chunks = chunks[1:]
except Exception:
- pass
+ logger.debug("[bridge] Final chunk edit failed", exc_info=True)
+ return
for chunk in chunks:
try:
await adapter.send(platform_chat_id, chunk)
diff --git a/cptr/utils/browser/viewer.py b/cptr/utils/browser/viewer.py
index 46a9b0ff..f4b696f7 100644
--- a/cptr/utils/browser/viewer.py
+++ b/cptr/utils/browser/viewer.py
@@ -1676,6 +1676,15 @@ async def stop(self, session_id: str, owner: str) -> bool:
await viewer.controller_cdp.close()
await viewer.host.close_target(viewer.target_id)
await viewer.host.close_target(viewer.controller_id)
+ if viewer.host.source == "managed":
+ has_managed_viewers = any(
+ item.session.owner == owner and item.host.source == "managed"
+ for item in self.viewers.values()
+ )
+ if not has_managed_viewers:
+ host = self.hosts.pop((owner, ""), None)
+ if host:
+ await host.close()
return True
async def clear_managed_profile(self, owner: str) -> list[str]:
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 1c683703..91923cc0 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -10,6 +10,7 @@
import logging
import re
import uuid
+from pathlib import Path
from typing import Any
from cptr.events import EVENTS, publish_event
@@ -56,6 +57,7 @@
disabled_builtin_tool_names,
execute_tool,
get_tool_list,
+ resolve_builtin_tool_approval,
_fn_to_schema,
)
from cptr.utils.chat_export import export_chat_to_file
@@ -742,6 +744,7 @@ async def review_tool_approval(
model: str,
tool_name: str,
arguments: dict,
+ approval_policy: str = "review",
) -> bool:
"""Return True when Auto mode can run a pending tool without prompting."""
latest_user = ""
@@ -754,8 +757,9 @@ async def review_tool_approval(
args_text = args_text[:3500] + "\n...(truncated)"
configured_model = await Config.get("tool_approval.review.model")
logger.info(
- "[tool-approval] auto review start tool=%s active_model=%s review_model=%s args=%s",
+ "[tool-approval] auto review start tool=%s policy=%s active_model=%s review_model=%s args=%s",
tool_name,
+ approval_policy,
model,
configured_model or "",
args_text[:1000],
@@ -1307,23 +1311,23 @@ def _find_safe_split(messages: list[dict], target_keep: int) -> int:
"""Find a safe split index that doesn't break tool call pairs.
Returns the index where keep_zone starts. Ensures:
+ - Prefer starting compacted history on a user turn
- Never splits between an assistant tool_call and its tool result
- keep_zone doesn't start with a tool result message
- At least 2 messages are kept
"""
n = len(messages)
- split = max(2, n - target_keep)
-
- # Walk forward from the initial split to find a safe boundary
- while split < n - 1:
- msg = messages[split]
- # Don't start keep_zone with a tool result β it needs its preceding assistant
- if msg.get("role") == "tool":
- split += 1
- continue
- break
+ split = min(max(2, n - target_keep), max(0, n - 2))
+
+ for idx in range(split, n - 1):
+ if messages[idx].get("role") == "user":
+ return idx
+
+ # Don't start keep_zone with a tool result; keep its assistant call too.
+ while split > 0 and messages[split].get("role") == "tool":
+ split -= 1
- return min(split, n - 2) # always keep at least 2
+ return split
def _summary_checkpoint_message_id(keep_zone: list[dict], fallback: str) -> str:
@@ -1542,6 +1546,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
chat_obj = await Chat.get_by_id(chat_id)
chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {}
+ agent_workspace = workspace or str(Path.home())
messages, loaded_summary = await _load_message_history(chat_id, message_id)
skill_settings = await get_skill_settings()
skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary)
@@ -1554,7 +1559,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
)
memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt)
system = await _load_system_prompt(
- workspace,
+ agent_workspace,
agent_target.full_model_id,
user_id=user_id,
current_message=memory_message,
@@ -1570,7 +1575,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
if isinstance(meta_files, list):
current_user_files = meta_files
agent_attachments = await prepare_agent_attachments(
- workspace=workspace,
+ workspace=agent_workspace,
chat_id=chat_id,
message_id=(msg.parent_id if msg and msg.parent_id else message_id),
files=current_user_files,
@@ -1626,7 +1631,7 @@ async def _finish_reasoning_item():
async for event in runner(
profile=agent_target.config,
model=agent_target.model,
- workspace=workspace,
+ workspace=agent_workspace,
messages=messages,
system_prompt=system,
chat_params=chat_params,
@@ -1925,7 +1930,7 @@ async def _finish_reasoning_item():
# Plan mode: strip write tools, inject prompt as user message (not system, to preserve cache)
plan_mode = chat_params.get("plan_mode", False)
if plan_mode:
- tools = [t for t in tools if not ALL_TOOLS.get(t["name"], {}).get("ask", True)]
+ tools = [t for t in tools if await resolve_builtin_tool_approval(t["name"]) == "allow"]
tools = [t for t in tools if t["name"] not in {"delegate_task", "update_memory"}]
tools.append(_fn_to_schema("create_artifact", create_artifact))
tools.append(ASK_USER_SCHEMA)
@@ -1953,7 +1958,7 @@ async def _finish_reasoning_item():
# Tool approval mode: 'ask' | 'auto' | 'full'
# ask = require approval for ALL tools (including reads)
- # auto = run ask:false tools; review ask:true tools before prompting
+ # auto = run allow tools; review review tools before prompting
# full = auto-approve everything
approval_mode = chat_params.get("tool_approval_mode", "auto")
# Legacy compat: old boolean auto_approve_tools
@@ -1984,8 +1989,9 @@ async def run_queued_tool_calls(tool_ctx: dict) -> str:
name = item.get("name", "")
tool = ALL_TOOLS.get(name)
+ tool_approval = await resolve_builtin_tool_approval(name) if tool else "review"
should_auto = approval_mode == "full" or (
- approval_mode == "auto" and tool and not tool.get("ask", True)
+ approval_mode == "auto" and tool and tool_approval == "allow"
)
if (
not should_auto
@@ -1999,6 +2005,7 @@ async def run_queued_tool_calls(tool_ctx: dict) -> str:
model=model,
tool_name=name,
arguments=item.get("arguments") or {},
+ approval_policy=tool_approval,
)
):
item["approved"] = True
@@ -2492,8 +2499,9 @@ async def auto_answer():
if skill_name:
loaded_skill_names.add(skill_name)
tool = ALL_TOOLS.get(name)
+ tool_approval = await resolve_builtin_tool_approval(name) if tool else "review"
should_auto = approval_mode == "full" or (
- approval_mode == "auto" and tool and not tool.get("ask", True)
+ approval_mode == "auto" and tool and tool_approval == "allow"
)
if not should_auto:
needs_approval = tc
diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py
index 2e1b62c2..b07bec38 100644
--- a/cptr/utils/tools.py
+++ b/cptr/utils/tools.py
@@ -2277,45 +2277,53 @@ async def notify(message: str, target: str = "", title: str = "", *, __context__
# ββ Registry ββββββββββββββββββββββββββββββββββββββββββββββββ
+ToolApprovalPolicy = Literal["allow", "review"]
+TOOL_APPROVAL_POLICIES = {"allow", "review"}
+
+
+def normalize_tool_approval(value: Any) -> ToolApprovalPolicy | None:
+ return value if isinstance(value, str) and value in TOOL_APPROVAL_POLICIES else None
+
+
TOOLS: dict[str, dict] = {
# Auto mode runs these without asking.
- "read_file": {"fn": read_file, "ask": False},
- "list_directory": {"fn": list_directory, "ask": False},
- "search_files": {"fn": search_files, "ask": False},
- "check_task": {"fn": check_task, "ask": False},
- "web_search": {"fn": web_search, "ask": False},
- "read_url": {"fn": read_url, "ask": False},
- "search_chats": {"fn": search_chats, "ask": False},
- "list_automations": {"fn": list_automations, "ask": False},
- "view_skill": {"fn": view_skill, "ask": False},
- "update_tasks": {"fn": update_tasks, "ask": False},
- # Auto mode reviews these first, then asks if denied or unclear.
- "create_file": {"fn": create_file, "ask": True},
- "display_file": {"fn": display_file, "ask": True},
- "edit_file": {"fn": edit_file, "ask": True},
- "multi_edit_file": {"fn": multi_edit_file, "ask": True},
- "write_file": {"fn": write_file, "ask": True},
- "run_command": {"fn": run_command, "ask": True},
- "send_input": {"fn": send_input, "ask": True},
- "kill_task": {"fn": kill_task, "ask": True},
- "create_automation": {"fn": create_automation, "ask": True},
- "update_automation": {"fn": update_automation, "ask": True},
- "toggle_automation": {"fn": toggle_automation, "ask": True},
- "delete_automation": {"fn": delete_automation, "ask": True},
- "notify": {"fn": notify, "ask": True},
- "image_generate": {"fn": image_generate, "ask": True},
- "manage_skill": {"fn": manage_skill, "ask": True},
- "update_memory": {"fn": update_memory, "ask": False},
+ "read_file": {"fn": read_file, "approval": "allow"},
+ "list_directory": {"fn": list_directory, "approval": "allow"},
+ "search_files": {"fn": search_files, "approval": "allow"},
+ "check_task": {"fn": check_task, "approval": "allow"},
+ "web_search": {"fn": web_search, "approval": "allow"},
+ "read_url": {"fn": read_url, "approval": "allow"},
+ "search_chats": {"fn": search_chats, "approval": "allow"},
+ "list_automations": {"fn": list_automations, "approval": "allow"},
+ "view_skill": {"fn": view_skill, "approval": "allow"},
+ "update_tasks": {"fn": update_tasks, "approval": "allow"},
+ # Missing approval inherits tool_approval.default_builtin_approval.
+ "create_file": {"fn": create_file},
+ "display_file": {"fn": display_file},
+ "edit_file": {"fn": edit_file},
+ "multi_edit_file": {"fn": multi_edit_file},
+ "write_file": {"fn": write_file},
+ "run_command": {"fn": run_command},
+ "send_input": {"fn": send_input},
+ "kill_task": {"fn": kill_task},
+ "create_automation": {"fn": create_automation},
+ "update_automation": {"fn": update_automation},
+ "toggle_automation": {"fn": toggle_automation},
+ "delete_automation": {"fn": delete_automation},
+ "notify": {"fn": notify},
+ "image_generate": {"fn": image_generate},
+ "manage_skill": {"fn": manage_skill},
+ "update_memory": {"fn": update_memory, "approval": "allow"},
}
# Browser tools β conditionally included in schemas based on browser.enabled
BROWSER_TOOLS: dict[str, dict] = {
- "browser_navigate": {"fn": browser_navigate, "ask": True},
- "browser_snapshot": {"fn": browser_snapshot, "ask": False},
- "browser_click": {"fn": browser_click, "ask": True},
- "browser_type": {"fn": browser_type, "ask": True},
- "browser_screenshot": {"fn": browser_screenshot, "ask": False},
- "browser_evaluate": {"fn": browser_evaluate, "ask": True},
+ "browser_navigate": {"fn": browser_navigate},
+ "browser_snapshot": {"fn": browser_snapshot, "approval": "allow"},
+ "browser_click": {"fn": browser_click},
+ "browser_type": {"fn": browser_type},
+ "browser_screenshot": {"fn": browser_screenshot, "approval": "allow"},
+ "browser_evaluate": {"fn": browser_evaluate},
}
@@ -2668,14 +2676,36 @@ async def _run_subagent_chat(
SUBAGENT_TOOLS: dict[str, dict] = {
- "delegate_task": {"fn": delegate_task, "ask": False},
- "timer": {"fn": timer, "ask": True},
+ "delegate_task": {"fn": delegate_task, "approval": "allow"},
+ "timer": {"fn": timer},
}
# Combined lookup for execution and approval (always available regardless of config)
ALL_TOOLS: dict[str, dict] = {**TOOLS, **BROWSER_TOOLS, **SUBAGENT_TOOLS}
+async def resolve_builtin_tool_approval(name: str) -> ToolApprovalPolicy:
+ """Resolve built-in tool approval. Unknown tools stay conservative."""
+ tool = ALL_TOOLS.get(name)
+ if tool is None:
+ return "review"
+
+ from cptr.models import Config
+
+ overrides = await Config.get("tool_approval.builtin_tools") or {}
+ if isinstance(overrides, dict):
+ override = normalize_tool_approval(overrides.get(name))
+ if override:
+ return override
+
+ registry = normalize_tool_approval(tool.get("approval"))
+ if registry:
+ return registry
+
+ default = normalize_tool_approval(await Config.get("tool_approval.default_builtin_approval"))
+ return default or "review"
+
+
BUILTIN_TOOL_GROUPS: dict[str, tuple[str, ...]] = {
"files": (
"read_file",
diff --git a/dev.sh b/dev.sh
index 31da6812..949ef3be 100755
--- a/dev.sh
+++ b/dev.sh
@@ -1,3 +1,3 @@
#!/bin/bash
export CPTR_DATA_DIR="${CPTR_DATA_DIR:-$(cd "$(dirname "$0")" && pwd)/.cptr}"
-uv run --extra all cptr run --reload --host 0.0.0.0 --port 9741
+uv run --extra all cptr run --reload --host 0.0.0.0 --port 9741 --headless
diff --git a/pyproject.toml b/pyproject.toml
index e9bb75f4..28f0ea5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.9.20"
+version = "0.9.21"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
diff --git a/uv.lock b/uv.lock
index 75c879a6..1dda52f9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -284,7 +284,7 @@ wheels = [
[[package]]
name = "cptr"
-version = "0.9.20"
+version = "0.9.21"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },