diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b93594..d24b84e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.6] - 2026-07-12 + +### Added + +- ⏰ **Follow-up timers for chats.** Computer can now return to a chat at the right time to continue work, check again, or follow up. + +### Changed + +- ✨ **Clearer change reviews.** Changes in files are easier to spot, including larger edits. + ## [0.9.5] - 2026-07-11 ### Added diff --git a/cptr/app.py b/cptr/app.py index a0421af6..16e3883d 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -71,6 +71,11 @@ async def lifespan(app: FastAPI): app.state.scheduler_task = asyncio.create_task(scheduler_worker_loop(app)) + from cptr.utils.timers import recover_timers, timer_worker_loop + + await recover_timers() + app.state.timer_task = asyncio.create_task(timer_worker_loop(app)) + # Start messaging bots from cptr.utils.bridge import BotManager @@ -80,6 +85,12 @@ async def lifespan(app: FastAPI): try: yield finally: + timer_task = getattr(app.state, "timer_task", None) + if timer_task: + timer_task.cancel() + with suppress(asyncio.CancelledError): + await timer_task + scheduler_task = getattr(app.state, "scheduler_task", None) if scheduler_task: scheduler_task.cancel() diff --git a/cptr/env.py b/cptr/env.py index fd9f47aa..5d856a58 100644 --- a/cptr/env.py +++ b/cptr/env.py @@ -20,6 +20,7 @@ def _env_int(name: str, default: int) -> int: except ValueError: return default + # ── Data directory ────────────────────────────────────────── # Where cptr stores its database, config, and user data. # Default: ~/.cptr @@ -32,9 +33,7 @@ def _env_int(name: str, default: int) -> int: LOG_FORMAT = os.environ.get("CPTR_LOG_FORMAT", "text").lower() AUDIT_LOG_LEVEL = os.environ.get("CPTR_AUDIT_LOG_LEVEL", "NONE").upper() -AUDIT_LOG_PATH = Path( - os.environ.get("CPTR_AUDIT_LOG_PATH", str(DATA_DIR / "logs" / "audit.jsonl")) -) +AUDIT_LOG_PATH = Path(os.environ.get("CPTR_AUDIT_LOG_PATH", str(DATA_DIR / "logs" / "audit.jsonl"))) AUDIT_LOG_ROTATION = os.environ.get("CPTR_AUDIT_LOG_ROTATION", "10 MB") AUDIT_MAX_BODY_SIZE = _env_int("CPTR_AUDIT_MAX_BODY_SIZE", 2048) AUDIT_EXCLUDED_PATHS = [ @@ -82,6 +81,7 @@ def _env_int(name: str, default: int) -> int: # ── Automation scheduler ──────────────────────────────────── AUTOMATION_POLL_INTERVAL = int(os.environ.get("AUTOMATION_POLL_INTERVAL", "10")) +TIMER_POLL_INTERVAL = int(os.environ.get("TIMER_POLL_INTERVAL", "1")) # ── CORS ──────────────────────────────────────────────────── # Socket.IO CORS allowed origins. diff --git a/cptr/events.py b/cptr/events.py index d871b956..a2113710 100644 --- a/cptr/events.py +++ b/cptr/events.py @@ -26,6 +26,16 @@ def label(self) -> str: class EventDefinitions: + CHAT_READ = EventDefinition( + "chat.read", + "The user explicitly marked a chat as read.", + "Chat read", + ) + CHAT_USER_MESSAGE = EventDefinition( + "chat.user_message", + "A user message was added to a chat.", + "User message", + ) CHAT_FINISHED = EventDefinition( "chat.finished", "A chat run finished successfully.", @@ -191,7 +201,16 @@ async def handle_event(self, event: Event) -> None: await dispatch_notification_event(event) -EVENT_SINKS = [NotificationEventSink()] +class TimerEventSink: + async def handle_event(self, event: Event) -> None: + if event.event not in {EVENTS.CHAT_READ.name, EVENTS.CHAT_USER_MESSAGE.name}: + return + from cptr.utils.timers import cancel_timers_for_event + + await cancel_timers_for_event(event) + + +EVENT_SINKS = [TimerEventSink(), NotificationEventSink()] async def publish_event( diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 0a8e9912..f4e8f86c 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -17,6 +17,7 @@ interface Props { content: string; + meta?: Record | null; done: boolean; output: any[] | null; usage: Record | null; @@ -35,6 +36,7 @@ } let { content, + meta = null, done, output, usage, diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 8206d2bc..884cb828 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -95,6 +95,7 @@ onqueueedit?: (id: string) => void; onqueuedelete?: (id: string) => void; onsettingschange?: () => void; + ontoolapprovalchange?: (mode: ToolApprovalMode) => void; } let { inputText = $bindable(), @@ -123,7 +124,8 @@ onqueuesendnow, onqueueedit, onqueuedelete, - onsettingschange + onsettingschange, + ontoolapprovalchange }: Props = $props(); let editorEl: HTMLDivElement | undefined = $state(); @@ -1507,6 +1509,7 @@ bind:planMode bind:requestParams onchange={onsettingschange} + {ontoolapprovalchange} onfiles={(files) => { if (files) processFiles(Array.from(files)); }} diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 33d7dd6a..63f8c211 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -35,7 +35,13 @@ import { socketStore } from '$lib/stores/socket.svelte'; import { onMount, onDestroy, tick } from 'svelte'; import { get } from 'svelte/store'; - import { currentWorkspace, openChatTab, streamingBehavior, widescreenMode } from '$lib/stores'; + import { + currentWorkspace, + openChatTab, + streamingBehavior, + toolApprovalMode as defaultToolApprovalMode, + widescreenMode + } from '$lib/stores'; import { getPathDisplayName } from '$lib/utils/paths'; import { ttsEnabled, @@ -663,7 +669,7 @@ const dm = get(defaultModel); if (dm) selectedModel = dm; else if (models.length) selectedModel = models[0].id; - toolApprovalMode = 'auto'; + toolApprovalMode = get(defaultToolApprovalMode); planMode = false; requestParams = {}; voiceModeEnabled = false; @@ -1062,6 +1068,12 @@ persistChatSettings(); } + function handleToolApprovalModeChange(mode: ToolApprovalMode) { + toolApprovalMode = mode; + defaultToolApprovalMode.set(mode); + persistChatSettings(); + } + function firstUserMessageTitle(): string { const message = allMessages.find((m) => m.role === 'user' && !isPendingHiddenMessage(m)); return message ? message.content.replace(/\s+/g, ' ').trim().slice(0, 80) : ''; @@ -1652,9 +1664,13 @@ > {#if !isLanding}
+
@@ -1705,6 +1721,7 @@ onsend={send} onplan={handlePlanCommand} onsettingschange={persistChatSettings} + ontoolapprovalchange={handleToolApprovalModeChange} {queuedMessages} onqueuesendnow={handleQueueSendNow} onqueueedit={handleQueueEdit} @@ -1742,7 +1759,7 @@
{#if hasHiddenMessages} @@ -1761,6 +1778,7 @@ {:else} ; onchange?: () => void; + ontoolapprovalchange?: (mode: ToolApprovalMode) => void; } let { onfiles, @@ -20,7 +21,8 @@ toolApprovalMode = $bindable('auto'), planMode = $bindable(false), requestParams = $bindable({}), - onchange + onchange, + ontoolapprovalchange }: Props = $props(); let open = $state(false); @@ -108,7 +110,8 @@ function selectMode(mode: ToolApprovalMode) { toolApprovalMode = mode; - onchange?.(); + if (ontoolapprovalchange) ontoolapprovalchange(mode); + else onchange?.(); } function triggerUpload() { diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index 0701f920..ca010342 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -54,7 +54,9 @@ let copied = $state(false); let textareaEl: HTMLTextAreaElement; let asyncExpanded = $state(false); + let timerExpanded = $state(false); const isAsyncSubagentResult = $derived(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 : []); const delegationLabel = $derived( @@ -121,7 +123,33 @@
- {#if isAsyncSubagentResult} + {#if isTimer} +
+ + {#if timerExpanded} +
+ {content} +
+ {/if} +
+ {:else if isAsyncSubagentResult}