Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.11] - 2026-07-19

### Added

- 🤖 **Gemini is now its own coding agent.** Choose Gemini as an agent and Computer will start the Gemini CLI with its Google sign-in flow, keep the chat resumable, and handle image attachments like the other coding agents.

### Fixed

- 📱 **Signal group chats reply to the right group.** When a Signal group message reaches Computer, replies, typing indicators, and attachments now use the group address Signal expects instead of falling back to the sender or losing the group target.

## [0.9.10] - 2026-07-18

### Added
Expand Down
11 changes: 9 additions & 2 deletions cptr/utils/adapters/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import asyncio
import base64
import logging
from typing import Any, Optional
from urllib.parse import quote
Expand Down Expand Up @@ -222,7 +223,14 @@ async def _process_message(self, msg: dict) -> None:

# Use source number as chat_id (DM) or groupId for groups
group_info = data_message.get("groupInfo")
chat_id = group_info.get("groupId", "") if group_info else source
chat_id = source
if group_info:
group_id = group_info.get("groupId", "")
chat_id = group_info.get("id") or (
"group." + base64.b64encode(group_id.encode("utf-8")).decode("ascii")
if group_id
else source
)

event = MessageEvent(
platform="signal",
Expand Down Expand Up @@ -265,7 +273,6 @@ async def _send_with_attachment(
"""Send a message with a base64-encoded attachment via signal-cli."""
if not self._http:
return None
import base64
try:
resp = await self._http.post(
f"{self._base_url}/v2/send",
Expand Down
10 changes: 7 additions & 3 deletions cptr/utils/agents/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ async def detect_profile(profile: dict[str, Any]) -> AgentDetection:
return AgentDetection("ready", command, version, None, models)

if profile.get("agent") == "gemini":
models = await _probe_acp_models(command, profile)
models = await _probe_acp_models(command, profile, auth_method_id="oauth-personal")
if not models:
return AgentDetection(
"auth_unknown",
Expand Down Expand Up @@ -456,7 +456,11 @@ async def _probe_opencode_models(command: str, profile: dict[str, Any]) -> list[
await proc.wait()


async def _probe_acp_models(command: str, profile: dict[str, Any]) -> list[str] | None:
async def _probe_acp_models(
command: str,
profile: dict[str, Any],
auth_method_id: str | None = None,
) -> list[str] | None:
from cptr.utils.agents.acp import AcpClient, acp_models_from_setup

env = os.environ.copy()
Expand All @@ -467,7 +471,7 @@ async def _probe_acp_models(command: str, profile: dict[str, Any]) -> list[str]
args=["--acp"],
cwd=os.getcwd(),
env=env,
auth_method_id=None,
auth_method_id=auth_method_id,
)
try:
await asyncio.wait_for(client.start(), timeout=10)
Expand Down
118 changes: 118 additions & 0 deletions cptr/utils/agents/gemini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Gemini ACP adapter."""

from __future__ import annotations

import asyncio
import os
from contextlib import suppress
from typing import Any, AsyncIterator

from cptr.utils.agents.acp import (
AcpClient,
acp_event_stream,
acp_text_from_update,
acp_tool_from_update,
)
from cptr.utils.agents.attachments import PreparedAgentAttachments
from cptr.utils.agents.events import (
AgentDone,
AgentError,
AgentEvent,
AgentTextDelta,
AgentToolUpdate,
)
from cptr.utils.agents.prompts import turn_prompt_text


def _auto_approve(chat_params: dict[str, Any]) -> bool:
if chat_params.get("tool_approval_mode") == "full":
return True
return bool(chat_params.get("auto_approve_tools"))


async def run_gemini_agent(
*,
profile: dict[str, Any],
model: str,
workspace: str,
messages: list[dict[str, Any]],
system_prompt: str,
chat_params: dict[str, Any],
resume_state: dict[str, Any] | None,
attachments: PreparedAgentAttachments,
) -> AsyncIterator[AgentEvent]:
env = os.environ.copy()
if profile.get("home"):
env["HOME"] = os.path.expanduser(str(profile["home"]))

session_id = None
if resume_state and isinstance(resume_state.get("session_id"), str):
session_id = resume_state["session_id"]

client = AcpClient(
command=str(profile["command"]),
args=["--acp"],
cwd=workspace,
env=env,
auth_method_id="oauth-personal",
resume_session_id=session_id,
auto_approve_permissions=_auto_approve(chat_params),
)
try:
await client.start()
if model != "default":
await client.set_model(model)

prompt = turn_prompt_text(messages, system_prompt, resumed=bool(session_id))

images = [
{"data": image.base64, "mimeType": image.mime_type} for image in attachments.images
]
prompt_task = asyncio.create_task(client.prompt(prompt, images=images))
try:
async for event in acp_event_stream(client):
params = event.get("params") if isinstance(event.get("params"), dict) else {}
text = acp_text_from_update(params)
if text:
yield AgentTextDelta(text)
tool = acp_tool_from_update(params)
if tool:
yield AgentToolUpdate(**tool)
if prompt_task.done():
try:
next_event = await asyncio.wait_for(client.events.get(), timeout=0.25)
except asyncio.TimeoutError:
break
next_params = (
next_event.get("params")
if isinstance(next_event.get("params"), dict)
else {}
)
next_text = acp_text_from_update(next_params)
if next_text:
yield AgentTextDelta(next_text)
next_tool = acp_tool_from_update(next_params)
if next_tool:
yield AgentToolUpdate(**next_tool)
await prompt_task
finally:
if not prompt_task.done():
prompt_task.cancel()
with suppress(asyncio.CancelledError):
await prompt_task

yield AgentDone(
resume_state={
"profile_id": profile["id"],
"session_id": client.session_id,
"workspace": workspace,
"model": model,
}
)
except asyncio.CancelledError:
await client.cancel()
raise
except Exception as exc: # noqa: BLE001 - surfaced in chat.
yield AgentError(str(exc))
finally:
await client.close()
3 changes: 2 additions & 1 deletion cptr/utils/chat_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
from cptr.utils.agents.cline import run_cline_agent
from cptr.utils.agents.codex import run_codex_agent
from cptr.utils.agents.cursor import run_cursor_agent
from cptr.utils.agents.gemini import run_gemini_agent
from cptr.utils.agents.grok import run_grok_agent
from cptr.utils.agents.opencode import run_opencode_agent
from cptr.utils.agents.pi import run_pi_agent
Expand Down Expand Up @@ -1706,7 +1707,7 @@ async def _run_agent_target(agent_target: AgentModelTarget):
"grok": run_grok_agent,
"opencode": run_opencode_agent,
"cline": run_cline_agent,
"gemini": run_cline_agent,
"gemini": run_gemini_agent,
"pi": run_pi_agent,
}
runner = runners.get(agent_target.agent)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cptr"
version = "0.9.10"
version = "0.9.11"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading