From 70a2b63a0987da57889aa564b70f68aa98314aed Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 16:31:11 -0400 Subject: [PATCH 1/7] refac --- cptr/utils/tools.py | 64 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 066a248a..f9a26502 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -16,6 +16,7 @@ import json import mimetypes import os +import stat import time import uuid from pathlib import Path, PureWindowsPath @@ -226,11 +227,32 @@ async def stream_command_session_output(command_session_id: str): def _is_dotenv(path: Path) -> bool: """Return True if the path refers to a .env file (e.g. .env, .env.local).""" - return path.name == ".env" or path.name.startswith(".env.") + return path.name == ".env" or path.name == ".envrc" or path.name.startswith(".env.") _DOTENV_ERROR = "Error: access to .env files is not allowed for security reasons." +_SENSITIVE_READ_ERROR = "Error: access to credential files is not allowed for security reasons." + +_SENSITIVE_HOME_FILES = { + ".git-credentials", + ".netrc", + ".npmrc", + ".pgpass", + ".pypirc", +} + +_SENSITIVE_HOME_DIRS = { + ".aws", + ".config/gh", + ".config/gcloud", + ".config/op", + ".docker", + ".gnupg", + ".kube", + ".ssh", +} + def _human_size(size: int) -> str: """Format byte size for display.""" @@ -484,14 +506,39 @@ async def read_file( workspace: str, ) -> str: """Read file contents with optional line range. Lines are 1-indexed. - Supports absolute paths for user-attached files. - :param path: Path relative to workspace root, or absolute path for attached files. + Supports relative workspace paths, absolute paths, and ~/ paths. + Blocks .env, common credential files, and special device/socket files. + :param path: Path relative to workspace root, absolute path, or ~/ path. :param start_line: First line to read (1-indexed, 0 = from beginning). :param end_line: Last line to read (inclusive, 0 = to end of file). """ - full = _resolve_path(path, workspace) + p = Path(path).expanduser() + if p.is_absolute(): + full = p.resolve() + else: + ws = Path(workspace).resolve() + full = (ws / path).resolve() + if not full.is_relative_to(ws): + raise ValueError(f"Path traversal rejected: {path}") + if _is_dotenv(full): return _DOTENV_ERROR + + resolved = full.resolve() + home = Path.home().resolve() + home_rel = resolved.relative_to(home).as_posix() if resolved.is_relative_to(home) else "" + if home_rel in _SENSITIVE_HOME_FILES or any( + home_rel == d or home_rel.startswith(f"{d}/") for d in _SENSITIVE_HOME_DIRS + ): + return _SENSITIVE_READ_ERROR + + try: + mode = full.stat().st_mode + except OSError: + mode = 0 + if any(check(mode) for check in (stat.S_ISBLK, stat.S_ISCHR, stat.S_ISFIFO, stat.S_ISSOCK)): + return f"Error: cannot read special file: {full}" + if not full.is_file(): return f"Error: file not found: {path}" @@ -1369,20 +1416,19 @@ def _resolve_path(path: str, workspace: str) -> Path: # Allow absolute paths to the uploads directory (for user-attached files) if p.is_absolute(): full = p.resolve() - uploads = str(UPLOADS_DIR.resolve()) - ws = str(Path(workspace).resolve()) - if str(full).startswith(uploads) or str(full).startswith(ws): + uploads = UPLOADS_DIR.resolve() + ws = Path(workspace).resolve() + if full.is_relative_to(uploads) or full.is_relative_to(ws): return full raise ValueError(f"Path outside allowed directories: {path}") # Relative paths resolve against workspace ws = Path(workspace).resolve() full = (ws / path).resolve() - if not str(full).startswith(str(ws)): + if not full.is_relative_to(ws): raise ValueError(f"Path traversal rejected: {path}") return full - async def create_automation( name: str, prompt: str, From c3833aae4eaf0570e2e24eb1d7c8391b5abddbf2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 17:14:30 -0400 Subject: [PATCH 2/7] refac --- cptr/frontend/src/lib/apis/chat.ts | 1 - cptr/routers/chat.py | 2 +- cptr/utils/ai.py | 1 + cptr/utils/chat_task.py | 37 +++++++++++++++++++++++++++++- cptr/utils/context.py | 5 ++-- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index eb1e1718..5809ee97 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -34,7 +34,6 @@ export interface ContextUsage { estimated_tokens: number; threshold: number; percent: number; - source: 'estimated'; } export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 326b851a..63e4c969 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -478,7 +478,7 @@ async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | N [{"role": m.role, "content": m.content or ""} for m in trailing_messages] ) return build_context_usage( - tokens, threshold=compact_token_threshold, source="estimated" + tokens, threshold=compact_token_threshold ) return estimate_context_usage(messages, system, threshold=compact_token_threshold) diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index f922bb57..e461f20b 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -637,6 +637,7 @@ def complete_reasoning_item() -> dict | None: "type": "usage", "input_tokens": raw.get("prompt_tokens", 0), "output_tokens": raw.get("completion_tokens", 0), + "total_tokens": raw.get("total_tokens", 0), } # Emit any remaining reasoning if no usage event was received diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 598d8c0e..78d777ab 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -16,6 +16,8 @@ from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS from cptr.utils.context import ( build_context_usage, + estimate_messages_tokens, + estimate_tokens, normalize_usage, resolve_compact_token_threshold, should_compact, @@ -2204,12 +2206,35 @@ async def _finish_reasoning_item(): pending_call_ids: set[str] = set() response_reasoning_items: list[dict] = [] # Pair with tool outputs on the next request streamed_reasoning_chars = 0 + estimated_prompt_tokens = 0 + last_emitted_context_tokens = 0 + if provider_type == "llama.cpp": + estimated_prompt_tokens = estimate_tokens(system) + estimate_messages_tokens( + api_messages + ) + last_emitted_context_tokens = estimated_prompt_tokens + await emit( + context_usage=build_context_usage( + estimated_prompt_tokens, + threshold=compact_token_threshold, + ) + ) async for event in stream: if event["type"] == "text_delta": content += event["content"] text_buffer += event["content"] await emit(delta=event["content"]) + if provider_type == "llama.cpp" and usage_context_tokens(last_usage) <= 0: + estimated_context_tokens = estimated_prompt_tokens + estimate_tokens(content) + if estimated_context_tokens - last_emitted_context_tokens >= 256: + last_emitted_context_tokens = estimated_context_tokens + await emit( + context_usage=build_context_usage( + estimated_context_tokens, + threshold=compact_token_threshold, + ) + ) _sync_state() elif event["type"] == "tool_call": @@ -2259,7 +2284,7 @@ async def _finish_reasoning_item(): if tokens > 0: await emit( context_usage=build_context_usage( - tokens, threshold=compact_token_threshold, source="estimated" + tokens, threshold=compact_token_threshold ) ) @@ -2273,6 +2298,16 @@ async def _finish_reasoning_item(): message_id[:8], streamed_reasoning_chars, ) + if provider_type == "llama.cpp" and usage_context_tokens(last_usage) <= 0: + estimated_context_tokens = estimated_prompt_tokens + estimate_tokens( + content + ) + await emit( + context_usage=build_context_usage( + estimated_context_tokens, + threshold=compact_token_threshold, + ) + ) await _save_message( "done", content=content, diff --git a/cptr/utils/context.py b/cptr/utils/context.py index ad046678..ea6c3637 100644 --- a/cptr/utils/context.py +++ b/cptr/utils/context.py @@ -60,7 +60,7 @@ def should_compact( return total > resolved_threshold -def build_context_usage(tokens: int, *, threshold: int | None = None, source: str) -> dict: +def build_context_usage(tokens: int, *, threshold: int | None = None) -> dict: """Return context fullness stats for estimated token counts.""" resolved_threshold = threshold or _get_threshold() percent = round((tokens / resolved_threshold) * 100) if resolved_threshold > 0 else 0 @@ -69,7 +69,6 @@ def build_context_usage(tokens: int, *, threshold: int | None = None, source: st "estimated_tokens": tokens, "threshold": resolved_threshold, "percent": max(0, percent), - "source": source, } @@ -109,7 +108,7 @@ def estimate_context_usage( """Return context fullness stats using the same estimate as compaction.""" resolved_threshold = threshold or _get_threshold() estimated_tokens = estimate_tokens(system_prompt) + estimate_messages_tokens(messages) - return build_context_usage(estimated_tokens, threshold=resolved_threshold, source="estimated") + return build_context_usage(estimated_tokens, threshold=resolved_threshold) def _parse_positive_int(value: object) -> int | None: From 5ca7bff590bd7acb056b71936d27455523da906e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 16 Jul 2026 01:05:47 -0400 Subject: [PATCH 3/7] refac --- .../components/SidebarWorkspaceList.svelte | 50 ++++++++++++++-- .../src/lib/components/chat/ChatPanel.svelte | 60 +++++++++++++++---- cptr/routers/chat.py | 18 +++++- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index fcaf7907..2908143b 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -63,7 +63,20 @@ const data = await getChats(path, 5, append ? existing.length : 0, 'updated_at', 'desc'); wsChatsCache = new Map([ ...wsChatsCache, - [path, append ? [...existing, ...(data.chats || [])] : data.chats || []] + [ + path, + append + ? [...existing, ...(data.chats || [])].sort( + (a, b) => + Number( + !b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at) + ) - + Number( + !a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at) + ) || b.updated_at - a.updated_at + ) + : data.chats || [] + ] ]); updateChatStatuses(data.chats || [], path); wsChatsHasMore = new Map([...wsChatsHasMore, [path, data.has_more]]); @@ -196,10 +209,14 @@ } let known = false; + const shouldReorder = + typeof data.updated_at === 'number' || + typeof data.last_read_at === 'number' || + typeof data.active === 'boolean'; + wsChatsCache = new Map( - [...wsChatsCache].map(([path, chats]) => [ - path, - chats.map((chat) => { + [...wsChatsCache].map(([path, chats]) => { + const nextChats = chats.map((chat) => { if (chat.id !== data.chat_id) return chat; known = true; return { @@ -209,14 +226,35 @@ ...(typeof data.last_read_at === 'number' ? { last_read_at: data.last_read_at } : {}), ...(typeof data.active === 'boolean' ? { is_active: data.active } : {}) }; - }) - ]) + }); + return [ + path, + shouldReorder + ? nextChats.sort( + (a, b) => + Number( + !b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at) + ) - + Number( + !a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at) + ) || b.updated_at - a.updated_at + ) + : nextChats + ] as [string, ChatInfo[]]; + }) ); // A chat created in another session is not yet in this sidebar's page. // Refresh only that expanded workspace; all known rows update in place. if (!known && data.workspace && expandedWorkspaces.has(data.workspace)) { void fetchWorkspaceChats(data.workspace); + } else if ( + known && + typeof data.last_read_at === 'number' && + data.workspace && + expandedWorkspaces.has(data.workspace) + ) { + void fetchWorkspaceChats(data.workspace); } } diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 1f645866..780d1292 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -462,7 +462,17 @@ try { const offset = (page - 1) * CHATS_PAGE_SIZE; const data = await getChats(workspace, CHATS_PAGE_SIZE, offset, chatSortBy, chatSortDir); - previousChats = data.chats || []; + previousChats = + chatSortBy === 'updated_at' + ? [...(data.chats || [])].sort( + (a, b) => + Number(!b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at)) - + Number( + !a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at) + ) || + (chatSortDir === 'desc' ? b.updated_at - a.updated_at : a.updated_at - b.updated_at) + ) + : data.chats || []; totalChats = data.total; chatPage = page; } catch { @@ -538,6 +548,10 @@ pending_inputs_processed?: boolean; async_subagent_pending?: boolean; title?: string; + workspace?: string; + active?: boolean; + updated_at?: number; + last_read_at?: number; }) { // On the landing page, update the chat list in place from socket events if (isLanding) { @@ -550,15 +564,41 @@ loadPreviousChats(chatPage); }, 300); } else { - if (data.done) { - previousChats = previousChats.map((c) => - c.id === data.chat_id ? { ...c, is_active: false } : c - ); - } - if (data.title) { - previousChats = previousChats.map((c) => - c.id === data.chat_id ? { ...c, title: data.title! } : c - ); + const nextChats = previousChats.map((c) => + c.id === data.chat_id + ? { + ...c, + ...(data.title ? { title: data.title } : {}), + ...(data.done ? { is_active: false } : {}), + ...(typeof data.active === 'boolean' ? { is_active: data.active } : {}), + ...(typeof data.updated_at === 'number' ? { updated_at: data.updated_at } : {}), + ...(typeof data.last_read_at === 'number' + ? { last_read_at: data.last_read_at } + : {}) + } + : c + ); + previousChats = + chatSortBy === 'updated_at' + ? nextChats.sort( + (a, b) => + Number( + !b.is_active && (b.last_read_at === null || b.updated_at > b.last_read_at) + ) - + Number( + !a.is_active && (a.last_read_at === null || a.updated_at > a.last_read_at) + ) || + (chatSortDir === 'desc' + ? b.updated_at - a.updated_at + : a.updated_at - b.updated_at) + ) + : nextChats; + if (typeof data.last_read_at === 'number') { + if (landingRefreshTimer) clearTimeout(landingRefreshTimer); + landingRefreshTimer = setTimeout(() => { + landingRefreshTimer = null; + loadPreviousChats(chatPage); + }, 100); } } } diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 63e4c969..fb9d7492 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -192,8 +192,22 @@ def _scan_chat_files() -> list[dict]: reverse = sort_dir != "asc" if sort_field == "updated_at": visible_chats.sort( - key=lambda c: c["updated_at"] if isinstance(c["updated_at"], int) else 0, - reverse=reverse, + key=lambda c: ( + not ( + not c.get("is_active") + and (c["updated_at"] if isinstance(c["updated_at"], int) else 0) > 0 + and ( + c["last_read_at"] is None + or (c["updated_at"] if isinstance(c["updated_at"], int) else 0) + > c["last_read_at"] + ) + ), + -(c["updated_at"] if isinstance(c["updated_at"], int) else 0) + if reverse + else c["updated_at"] + if isinstance(c["updated_at"], int) + else 0, + ), ) else: visible_chats.sort(key=lambda c: str(c["title"] or ""), reverse=reverse) From a1856ea73756bb641f82eb50fd1892ea9b4e4833 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 16 Jul 2026 01:09:35 -0400 Subject: [PATCH 4/7] refac --- .../src/lib/components/chat/ChatPanel.svelte | 6 ++- .../lib/components/chat/UserMessage.svelte | 4 +- cptr/models/__init__.py | 12 +++++- cptr/models/chats.py | 37 +++++++++++++++++-- cptr/routers/search.py | 4 +- cptr/utils/async_subagents.py | 7 ++-- cptr/utils/chat_task.py | 36 +++++++++++++----- cptr/utils/timers.py | 9 +++-- cptr/utils/tools.py | 2 +- 9 files changed, 90 insertions(+), 27 deletions(-) diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 780d1292..457d1fd7 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -206,7 +206,11 @@ } function isPendingHiddenMessage(m: ChatMessageRow): boolean { - return !!(m.meta?.queued || m.meta?.async_subagent_pending); + return !!( + m.meta?.queued || + m.meta?.async_subagent_pending || + (m.meta?.internal === true && m.meta?.type === 'subagent' && m.meta?.status === 'pending') + ); } function nearestVisibleAncestorId( diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index ce58ac71..82757f21 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -56,7 +56,9 @@ let textareaEl: HTMLTextAreaElement; let asyncExpanded = $state(false); let timerExpanded = $state(false); - const isAsyncSubagentResult = $derived(meta?.async_subagent_result === true); + const isAsyncSubagentResult = $derived( + (meta?.internal === true && meta?.type === 'subagent') || meta?.async_subagent_result === true + ); const isTimer = $derived(meta?.internal === true && meta?.type === 'timer'); const delegationId = $derived(meta?.delegation_id || ''); const delegationIds = $derived(Array.isArray(meta?.delegation_ids) ? meta.delegation_ids : []); diff --git a/cptr/models/__init__.py b/cptr/models/__init__.py index b6a0a850..747438b6 100644 --- a/cptr/models/__init__.py +++ b/cptr/models/__init__.py @@ -5,7 +5,14 @@ from cptr.models.workspaces import Workspace from cptr.models.config import Config from cptr.models.files import File -from cptr.models.chats import Chat, ChatMessage, is_internal_chat +from cptr.models.chats import ( + Chat, + ChatMessage, + internal_status, + is_internal_chat, + is_pending_subagent_result_message, + is_subagent_result_message, +) from cptr.models.automations import Automation, AutomationRun __all__ = [ @@ -18,7 +25,10 @@ "File", "Chat", "ChatMessage", + "internal_status", "is_internal_chat", + "is_pending_subagent_result_message", + "is_subagent_result_message", "Automation", "AutomationRun", ] diff --git a/cptr/models/chats.py b/cptr/models/chats.py index 2b9b3f20..59c2d04a 100644 --- a/cptr/models/chats.py +++ b/cptr/models/chats.py @@ -30,7 +30,36 @@ def _uuid() -> str: def is_internal_chat(meta: dict | None) -> bool: """Whether a chat is harness-owned and should stay out of normal chat surfaces.""" meta = meta or {} - return bool(meta.get("internal") or meta.get("subagent")) + return bool(meta.get("internal") is True or meta.get("subagent") is True) + + +def internal_status(meta: dict | None) -> str | None: + """Canonical internal lifecycle status, with legacy timer fallback.""" + meta = meta or {} + return meta.get("status") or meta.get("timer_status") + + +def is_subagent_result_message(meta: dict | None) -> bool: + """Whether a parent-chat message carries a subagent result.""" + meta = meta or {} + return bool( + (meta.get("internal") is True and meta.get("type") == "subagent") + or meta.get("async_subagent_result") + or meta.get("async_subagent_pending") + ) + + +def is_pending_subagent_result_message(meta: dict | None) -> bool: + """Whether a subagent result is queued behind active parent work.""" + meta = meta or {} + return bool( + ( + meta.get("internal") is True + and meta.get("type") == "subagent" + and meta.get("status") == "pending" + ) + or meta.get("async_subagent_pending") + ) class Chat(Base): @@ -75,7 +104,7 @@ async def get_due_timers(now_ns: int, limit: int = 10) -> list[Chat]: for chat in result.scalars().all() if (chat.meta or {}).get("internal") is True and (chat.meta or {}).get("type") == "timer" - and (chat.meta or {}).get("timer_status") == "pending" + and internal_status(chat.meta) == "pending" and int((chat.meta or {}).get("timer_at") or 0) <= now_ns ] timers.sort(key=lambda chat: int((chat.meta or {}).get("timer_at") or 0)) @@ -92,7 +121,7 @@ async def get_pending_timers(parent_chat_id: str) -> list[Chat]: if (chat.meta or {}).get("internal") is True and (chat.meta or {}).get("type") == "timer" and (chat.meta or {}).get("parent_chat_id") == parent_chat_id - and (chat.meta or {}).get("timer_status") == "pending" + and internal_status(chat.meta) == "pending" ] @staticmethod @@ -105,7 +134,7 @@ async def get_timers(status: str | None = None) -> list[Chat]: for chat in result.scalars().all() if (chat.meta or {}).get("internal") is True and (chat.meta or {}).get("type") == "timer" - and (status is None or (chat.meta or {}).get("timer_status") == status) + and (status is None or internal_status(chat.meta) == status) ] @staticmethod diff --git a/cptr/routers/search.py b/cptr/routers/search.py index 8b32aa6a..a590c507 100644 --- a/cptr/routers/search.py +++ b/cptr/routers/search.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request -from cptr.models import Chat +from cptr.models import Chat, is_internal_chat from cptr.routers.workspace import walk_and_rank_files router = APIRouter(prefix="/api/search", tags=["search"]) @@ -123,7 +123,7 @@ async def recent_chats( rows = [ c for c in rows - if not (c.meta or {}).get("subagent") + if not is_internal_chat(c.meta) and (workspace is None or (c.meta or {}).get("workspace") == workspace) ][:limit] diff --git a/cptr/utils/async_subagents.py b/cptr/utils/async_subagents.py index 8af8ed3d..3511fe10 100644 --- a/cptr/utils/async_subagents.py +++ b/cptr/utils/async_subagents.py @@ -202,12 +202,13 @@ async def _inject_completion(record: dict[str, Any]) -> None: parent_id = done_assistants[-1].id if done_assistants else record.get("parent_message_id") meta = { - "async_subagent_result": True, + "internal": True, + "type": "timer" if record.get("timer_chat_id") else "subagent", "delegation_id": record.get("delegation_id"), "subagent_chat_id": record.get("subagent_chat_id"), } if active: - meta["async_subagent_pending"] = True + meta["status"] = "pending" if record.get("timer_chat_id") and not active: direct_timer_completion = True @@ -264,7 +265,7 @@ async def _inject_completion(record: dict[str, Any]) -> None: { "chat_id": parent_chat_id, "message_id": user_msg.id, - "async_subagent_pending": True, + "pending_inputs_processed": True, }, ) return diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 78d777ab..dea9aa56 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -31,7 +31,14 @@ load_skill, ) from cptr.utils.summarize import summarize_messages -from cptr.models import Chat, ChatMessage, Config, is_internal_chat +from cptr.models import ( + Chat, + ChatMessage, + Config, + is_internal_chat, + is_pending_subagent_result_message, + is_subagent_result_message, +) from cptr.socket.main import emit_to_user from cptr.utils.ai import ( ChatCompletionForm, @@ -463,11 +470,11 @@ def _memory_recall_inputs( # ── Pending input processing ──────────────────────────────── -def _merge_async_subagent_result_meta(messages: list[ChatMessage]) -> dict | None: +def _merge_subagent_result_meta(messages: list[ChatMessage]) -> dict | None: if not messages: return None - if all((m.meta or {}).get("async_subagent_result") for m in messages): + if all(is_subagent_result_message(m.meta) for m in messages): delegation_ids = [ m.meta.get("delegation_id") for m in messages if m.meta and m.meta.get("delegation_id") ] @@ -476,7 +483,7 @@ def _merge_async_subagent_result_meta(messages: list[ChatMessage]) -> dict | Non for m in messages if m.meta and m.meta.get("subagent_chat_id") ] - meta = {"async_subagent_result": True} + meta = {"internal": True, "type": "subagent"} if len(delegation_ids) == 1: meta["delegation_id"] = delegation_ids[0] elif delegation_ids: @@ -491,18 +498,18 @@ def _merge_async_subagent_result_meta(messages: list[ChatMessage]) -> dict | Non def _is_pending_internal_subagent_result(message: ChatMessage) -> bool: - return bool((message.meta or {}).get("async_subagent_pending")) + return is_pending_subagent_result_message(message.meta) def _is_pending_chat_input(message: ChatMessage) -> bool: meta = message.meta or {} - return bool(meta.get("queued") or meta.get("async_subagent_pending")) + return bool(meta.get("queued") or is_pending_subagent_result_message(meta)) def _merge_pending_input_meta(messages: list[ChatMessage]) -> dict | None: - async_meta = _merge_async_subagent_result_meta(messages) - if async_meta: - return async_meta + subagent_meta = _merge_subagent_result_meta(messages) + if subagent_meta: + return subagent_meta files: list[dict] = [] for message in messages: @@ -2034,7 +2041,16 @@ async def _finish_reasoning_item(): review_model_name = model review_chat_params = { **chat_params, - "subagent": bool(chat_obj and (chat_obj.meta or {}).get("subagent")), + "subagent": bool( + chat_obj + and ( + (chat_obj.meta or {}).get("subagent") + or ( + (chat_obj.meta or {}).get("internal") is True + and (chat_obj.meta or {}).get("type") == "subagent" + ) + ) + ), } review_builtin_tools = builtin_tools diff --git a/cptr/utils/timers.py b/cptr/utils/timers.py index 165b702e..30d7a8bf 100644 --- a/cptr/utils/timers.py +++ b/cptr/utils/timers.py @@ -74,7 +74,7 @@ async def cancel_timers_for_event(event) -> None: continue meta.update( { - "timer_status": "cancelled", + "status": "cancelled", "timer_cancelled_at": time.time_ns(), "timer_cancelled_by": event.event, } @@ -90,7 +90,7 @@ async def _set_timer_status(chat_id: str, status: str, **fields) -> None: if not chat: return meta = dict(chat.meta or {}) - meta["timer_status"] = status + meta["status"] = status meta.update(fields) await Chat.update_meta(chat_id, meta, now_ms()) @@ -108,7 +108,8 @@ async def _launch_timer(timer, app) -> None: if not timer: return meta = dict(timer.meta or {}) - if meta.get("timer_status") != "pending" or int(meta.get("timer_at") or 0) > time.time_ns(): + status = meta.get("status") or meta.get("timer_status") + if status != "pending" or int(meta.get("timer_at") or 0) > time.time_ns(): return parent = await Chat.get_by_id(meta.get("parent_chat_id", "")) @@ -165,7 +166,7 @@ async def _launch_timer(timer, app) -> None: meta.update( { - "timer_status": "completed", + "status": "completed", "timer_completed_at": time.time_ns(), } ) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index f9a26502..0fc7debf 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -2523,7 +2523,7 @@ async def timer( extra_meta={ "timer_at": due_at, "cancel_on": selected_events, - "timer_status": "pending", + "status": "pending", "timer_parent_message_id": __context__.get("message_id"), "timer_model_id": full_model_id, }, From 60207e1f44e472e7f83304f8b78be3a97bacf0c3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 16 Jul 2026 02:51:19 -0400 Subject: [PATCH 5/7] refac --- cptr/frontend/src/app.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index e03784fc..19eb13f0 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -29,8 +29,8 @@ --app-fg: #525252; --app-fg-muted: color-mix(in oklab, var(--app-fg) 62%, var(--app-bg)); --app-fg-subtle: color-mix(in oklab, var(--app-fg) 48%, var(--app-bg)); - --app-border: color-mix(in oklab, var(--app-fg) 5%, transparent); - --app-divider: color-mix(in oklab, var(--app-fg) 3.5%, transparent); + --app-border: color-mix(in oklab, var(--app-fg) 1.5%, transparent); + --app-divider: color-mix(in oklab, var(--app-fg) 0.875%, transparent); --app-hover: color-mix(in oklab, var(--app-fg) 6%, transparent); --app-active: color-mix(in oklab, var(--app-fg) 7%, transparent); --app-ui-font: var(--font-sans); From 71979dac6e73cb61fe3f3224e30d5bda3fc95d46 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 16 Jul 2026 16:30:07 -0400 Subject: [PATCH 6/7] refac --- cptr/utils/adapters/signal.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/cptr/utils/adapters/signal.py b/cptr/utils/adapters/signal.py index 33023722..3d466b05 100644 --- a/cptr/utils/adapters/signal.py +++ b/cptr/utils/adapters/signal.py @@ -14,7 +14,7 @@ import asyncio import logging -from typing import Optional +from typing import Any, Optional from urllib.parse import quote import httpx @@ -47,6 +47,17 @@ def _parse_token(token: str) -> tuple[str, str]: raise ValueError("Signal token must be 'base_url|phone_number' (pipe-separated)") +def _signal_cli_version(data: dict[str, Any]) -> str: + versions = data.get("versions") + if isinstance(versions, dict): + return str(versions.get("signal-cli", "unknown")) + if isinstance(versions, list) and versions: + return str(versions[-1]) + if versions: + return str(versions) + return "unknown" + + class SignalAdapter(BaseAdapter): """Signal bot via signal-cli REST API bridge.""" @@ -76,7 +87,7 @@ async def connect(self) -> bool: raise ValueError(f"Phone number {self._phone} not registered with signal-cli") logger.info( "Signal adapter connected: %s (signal-cli %s)", - self._phone, data.get("versions", {}).get("signal-cli", "?"), + self._phone, _signal_cli_version(data), ) except httpx.ConnectError: logger.error("Cannot connect to signal-cli REST API at %s", self._base_url) @@ -128,8 +139,8 @@ 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 doesn't support message editing. No-op.""" - pass + """Signal has no edit API; send the updated text as a new message.""" + await self.send(chat_id, text) async def send_typing(self, chat_id: str) -> None: """Send typing indicator via signal-cli.""" @@ -295,5 +306,5 @@ async def verify_token(token: str) -> dict: return { "username": phone, "id": phone, - "version": data.get("versions", {}).get("signal-cli", "unknown"), + "version": _signal_cli_version(data), } From abd41fded62e50603c46d3391127e8d1ddcbe397 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 16 Jul 2026 16:33:19 -0400 Subject: [PATCH 7/7] refac --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c751d2..f5faee49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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.9] - 2026-07-16 + +### Changed + +- 💬 **Unread chats stay where you can see them.** Chat lists now keep unread finished chats above already-read chats, and they refresh when a chat is marked read. +- 🧭 **The app feels a little lighter.** Dividers and borders across the interface are softer, so the workspace chrome gets out of the way more. + +### Fixed + +- ⏰ **Follow-up timers behave more cleanly.** Timer prompts and subagent handoffs stay internal, pending follow-ups stay hidden until they are ready, and old timer state is still understood. +- 🔎 **Internal work stays out of normal chat search.** Recent-chat search no longer shows hidden timer or subagent chats as if they were regular conversations. +- 🧠 **The chat size meter keeps moving for local models.** llama.cpp chats now show live size updates even when the server does not send token totals until the end. +- 🔐 **File reading avoids credential files.** Computer can read normal absolute paths and `~/` paths, but it blocks common credential files, `.envrc`, and special device files. +- 📱 **Signal bot updates no longer disappear.** When a Signal chat needs an edited answer, Computer now sends the updated text as a new message instead of silently doing nothing. + ## [0.9.8] - 2026-07-15 ### Added diff --git a/pyproject.toml b/pyproject.toml index 4926e967..2acff79b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.8" +version = "0.9.9" 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 18d56881..dd23e60d 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.8" +version = "0.9.9" source = { editable = "." } dependencies = [ { name = "aiosqlite" },