diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb69cd..3a58d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.8.5] - 2026-07-07 + +### Added + +- ✨ **Better shared skill discovery.** Computer now finds skills saved in the common agents skill folder, so more reusable workflows show up without extra setup. + +### Fixed + +- 🤖 **Smoother agent replies.** Coding agents no longer repeat final answers or thinking notes that already appeared while the chat was running. +- ✅ **Cleaner chat endings.** Thinking notes now close before answers, tool use, or the final saved message, keeping the chat display and completion state tidier. +- 🔐 **More reliable agent approvals.** Agent approval prompts now handle request IDs more flexibly, so approvals are less likely to get stuck. + ## [0.8.4] - 2026-07-07 ### Fixed diff --git a/cptr/utils/agents/acp.py b/cptr/utils/agents/acp.py index 76a0754..747b6d3 100644 --- a/cptr/utils/agents/acp.py +++ b/cptr/utils/agents/acp.py @@ -33,7 +33,7 @@ def __init__( self.proc: asyncio.subprocess.Process | None = None self.reader_task: asyncio.Task | None = None self.stderr_task: asyncio.Task | None = None - self.pending: dict[int, asyncio.Future[dict[str, Any]]] = {} + self.pending: dict[Any, asyncio.Future[dict[str, Any]]] = {} self.events: asyncio.Queue[dict[str, Any]] = asyncio.Queue() self.next_id = 1 self.session_id: str | None = None @@ -197,7 +197,7 @@ async def _handle_message(self, message: dict[str, Any]) -> None: and ("result" in message or "error" in message) and "method" not in message ): - future = self.pending.pop(int(message["id"]), None) + future = self.pending.pop(message["id"], None) if future and not future.done(): future.set_result(message) return @@ -205,11 +205,11 @@ async def _handle_message(self, message: dict[str, Any]) -> None: method = message.get("method") params = message.get("params") if isinstance(message.get("params"), dict) else {} if "id" in message and method == "session/request_permission": - await self._reply_permission(int(message["id"]), params) + await self._reply_permission(message["id"], params) return await self.events.put(message) - async def _reply_permission(self, request_id: int, params: dict[str, Any]) -> None: + async def _reply_permission(self, request_id: Any, params: dict[str, Any]) -> None: option_id = None if self.auto_approve_permissions: option_id = _select_permission_option( diff --git a/cptr/utils/agents/claude_code.py b/cptr/utils/agents/claude_code.py index e0e4a18..fc16951 100644 --- a/cptr/utils/agents/claude_code.py +++ b/cptr/utils/agents/claude_code.py @@ -160,6 +160,8 @@ async def run_claude_code_agent( usage: dict[str, Any] | None = None observed_session_id = session_id tool_calls: dict[int, AgentToolUpdate] = {} + received_text_delta = False + received_thinking_delta = False async for message in stream: event = getattr(message, "event", None) @@ -171,11 +173,17 @@ async def run_claude_code_agent( if delta.get("type") == "text_delta" and isinstance( delta.get("text"), str ): - yield AgentTextDelta(delta["text"]) + text = delta["text"] + if text: + received_text_delta = True + yield AgentTextDelta(text) elif delta.get("type") == "thinking_delta" and isinstance( delta.get("thinking"), str ): - yield AgentReasoningDelta(delta["thinking"]) + thinking = delta["thinking"] + yield AgentReasoningDelta(thinking) + if thinking: + received_thinking_delta = True elif event_type == "content_block_start": index, tool = _tool_update_from_claude_start(event) if tool: @@ -199,10 +207,14 @@ async def run_claude_code_agent( if class_name == "AssistantMessage": for block in getattr(message, "content", []) or []: if block.__class__.__name__ == "TextBlock": + if received_text_delta: + continue text = getattr(block, "text", "") if text: yield AgentTextDelta(text) elif block.__class__.__name__ == "ThinkingBlock": + if received_thinking_delta: + continue text = getattr(block, "thinking", "") if text: yield AgentReasoningDelta(text) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 9d596ce..671b393 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1522,6 +1522,24 @@ async def _run_agent_target(agent_target: AgentModelTarget): if runner is None: raise RuntimeError(f"Unsupported agent type: {agent_target.agent}") reasoning_buffer = "" + reasoning_item_id = f"reasoning-{message_id}" + + async def _finish_reasoning_item(): + existing = next( + (item for item in output_items if item.get("id") == reasoning_item_id), None + ) + if not existing or existing.get("status") == "completed": + return + item = { + "type": "reasoning", + "id": reasoning_item_id, + "status": "completed", + "content": [{"type": "reasoning_text", "text": reasoning_buffer}], + } + _upsert_output_item(output_items, item) + await emit(output=item) + _sync_state() + async for event in runner( profile=agent_target.config, model=agent_target.model, @@ -1533,6 +1551,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): attachments=agent_attachments, ): if isinstance(event, AgentTextDelta): + await _finish_reasoning_item() content += event.text text_buffer += event.text await emit(delta=event.text) @@ -1541,7 +1560,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): reasoning_buffer += event.text item = { "type": "reasoning", - "id": f"reasoning-{message_id}", + "id": reasoning_item_id, "status": "in_progress", "content": [{"type": "reasoning_text", "text": reasoning_buffer}], } @@ -1549,6 +1568,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): await emit(output=item) _sync_state() elif isinstance(event, AgentToolUpdate): + await _finish_reasoning_item() flushed_item = _flush_text() if flushed_item: await emit(output=flushed_item) @@ -1609,6 +1629,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): await emit(output=output_item) _sync_state() elif isinstance(event, AgentToolOutputDelta): + await _finish_reasoning_item() flushed_item = _flush_text() if flushed_item: await emit(output=flushed_item) @@ -1665,16 +1686,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): elif isinstance(event, AgentError): raise RuntimeError(event.message) elif isinstance(event, AgentDone): - if reasoning_buffer: - _upsert_output_item( - output_items, - { - "type": "reasoning", - "id": f"reasoning-{message_id}", - "status": "completed", - "content": [{"type": "reasoning_text", "text": reasoning_buffer}], - }, - ) + await _finish_reasoning_item() flushed_item = _flush_text() if flushed_item: await emit(output=flushed_item) @@ -1704,6 +1716,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): flushed_item = _flush_text() if flushed_item: await emit(output=flushed_item) + await _finish_reasoning_item() await _save_message("agent stream ended", content=content, output=output_items, done=True) _task_state.pop(message_id, None) await _emit_done() diff --git a/cptr/utils/skills.py b/cptr/utils/skills.py index 4150bb6..790b060 100644 --- a/cptr/utils/skills.py +++ b/cptr/utils/skills.py @@ -41,9 +41,10 @@ ".codex/skills", ] -# Global (user-level): only our own directory +# Global (user-level): scan our own directory plus the cross-agent convention. GLOBAL_SKILL_DIRS = [ str(Path.home() / ".cptr" / "skills"), + str(Path.home() / ".agents" / "skills"), ] MANAGED_WORKSPACE_SKILL_DIR = ".cptr/skills" diff --git a/pyproject.toml b/pyproject.toml index 68acd71..b61d28c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.8.4" +version = "0.8.5" 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 865902d..ba606fc 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.8.4" +version = "0.8.5" source = { editable = "." } dependencies = [ { name = "aiosqlite" },