Expanded ACP support

Can now send chain-of-thought messages
Manage terminals
Control agent working directory
Adding more tool support and notifications
Some session management
Added image support
Added slash commands
This commit is contained in:
2026-05-09 16:20:25 -04:00
parent 3b1738fdfb
commit 722169adfd
7 changed files with 485 additions and 48 deletions
+6
View File
@@ -10,3 +10,9 @@ trusted_contacts:
number: "+15559876543" number: "+15559876543"
- name: "Bob" - name: "Bob"
number: "+15551112222" number: "+15551112222"
# Working directory for Goose sessions (absolute path recommended)
working_directory: "/home/user/projects"
# Forward Goose's chain-of-thought reasoning to Signal (can be noisy)
forward_thoughts: false
+233
View File
@@ -0,0 +1,233 @@
# Honker ACP Bridge — Implementation Plan
## Current State
What's implemented today:
- ✅ Initialize ACP connection to Goose (`goose acp` over stdio)
- ✅ Create new sessions (one per Signal contact)
- ✅ Send text prompts and collect text responses
- ✅ Interactive permission requests over Signal (numbered options)
- ✅ Signal-cli lifecycle management and socket communication
---
## Phase 1: Essential — Make the Bridge Robust
### 1.1 Terminal Execution (Client ← Agent)
**Priority: HIGH — Goose cannot function without this**
Goose's primary tool is running shell commands. The ACP protocol has the agent
ask the *client* to execute commands via `create_terminal`, then polls output
with `terminal_output` and `wait_for_terminal_exit`.
Currently we return `None` for all terminal methods, which means Goose can't
run any commands. We need to:
- `create_terminal(command, args, cwd, env)` → spawn a real subprocess, return
a `terminal_id`
- `terminal_output(terminal_id)` → return accumulated stdout/stderr
- `wait_for_terminal_exit(terminal_id)` → await process completion, return exit code
- `release_terminal(terminal_id)` → clean up
- `kill_terminal(terminal_id)` → kill the subprocess
Implementation: a `TerminalManager` class that tracks running subprocesses
keyed by terminal_id.
### 1.2 File System Operations (Client ← Agent)
**Priority: HIGH — Goose cannot edit code without this**
The agent asks the client to read/write files:
- `read_text_file(path, line, limit)` → read from the local filesystem, return content
- `write_text_file(path, content)` → write to the local filesystem
Currently we return `None`, so Goose can't read or write files.
These should be straightforward to implement against the local filesystem.
We should also advertise these capabilities in `ClientCapabilities` during
`initialize`:
```python
ClientCapabilities(
fs=FileSystemCapabilities(read_text_file=True, write_text_file=True),
terminal=True,
)
```
### 1.3 Cancellation
**Priority: MEDIUM**
Allow Signal users to cancel a running prompt. The user could send a special
command (e.g. `/cancel`) while a prompt is in progress. We'd call
`conn.cancel(session_id)` to tell Goose to stop.
The `PromptResponse.stop_reason` already distinguishes `cancelled` from
`end_turn`, `max_tokens`, `refusal`, and `max_turn_requests` — we should
handle each appropriately in the reply.
### 1.4 Stop Reason Handling
**Priority: MEDIUM**
Currently we only look at the response text. We should handle all stop reasons:
- `end_turn` — normal completion (current behavior)
- `max_tokens` — context limit hit; notify user
- `max_turn_requests` — agent hit its turn limit; notify user
- `refusal` — agent refused the request; notify user
- `cancelled` — user cancelled; acknowledge
---
## Phase 2: Enriched Feedback
### 2.1 Tool Call Notifications
**Priority: MEDIUM**
Forward tool activity as status messages so the Signal user knows what Goose
is doing while working. On `ToolCallStart`, send a brief message like:
"🔧 Running: `ls -la /src`"
On `ToolCallProgress` with status updates, optionally send progress. We need
to be careful not to spam — batch or throttle these.
### 2.2 Agent Thoughts
**Priority: LOW-MEDIUM**
`AgentThoughtChunk` contains the agent's chain-of-thought reasoning. Could
forward these optionally (config flag) as italicized or prefixed messages.
Useful for debugging but noisy for everyday use.
### 2.3 Plan Updates
**Priority: LOW-MEDIUM**
`AgentPlanUpdate` sends a structured plan with entries (content, priority,
status). Could render as a checklist:
```
📋 Plan:
✅ Read the project structure
🔄 Implement the new feature
⬚ Write tests
⬚ Update documentation
```
### 2.4 Usage/Cost Updates
**Priority: LOW**
`UsageUpdate` reports token usage (size, used) and optional `Cost` (amount,
currency). Could send periodic summaries or on-demand via a `/usage` command.
---
## Phase 3: Session Management
### 3.1 Session Persistence (Load/Resume)
**Priority: MEDIUM**
Goose supports `load_session` and `resume_session`. We could persist the
session_id→contact mapping in SQLite (as the README mentions) and restore
sessions across honker restarts.
- `load_session(cwd, session_id)` — restore conversation history
- `resume_session(cwd, session_id)` — resume an interrupted session
- `list_sessions()` — list available sessions
- `close_session(session_id)` — explicitly end a session
User commands: `/sessions`, `/load <id>`, `/new` (force new session)
### 3.2 Session Commands
**Priority: MEDIUM**
Signal user sends special commands (prefix with `/`):
- `/new` — start a fresh session
- `/sessions` — list previous sessions
- `/load <id>` — resume a previous session
- `/cancel` — cancel current prompt
- `/mode <mode>` — change session mode (via `set_session_mode`)
- `/model <model>` — change model (via `set_session_model`)
- `/usage` — show token usage / cost
### 3.3 Session Modes
**Priority: LOW**
Goose supports modes (e.g., "auto" vs "manual" approval). `set_session_mode`
and `CurrentModeUpdate` notifications. Could expose via `/mode` command.
### 3.4 Session Configuration
**Priority: LOW**
`set_config_option` lets the client change boolean/select config options.
`ConfigOptionUpdate` notifications report changes. Could expose via a
`/config` command.
---
## Phase 4: Rich Content
### 4.1 Image Support (Input)
**Priority: MEDIUM**
Signal users can send images. signal-cli provides attachment data. ACP
supports `ImageContentBlock` in prompts (with base64 `data` and `mime_type`).
We could forward Signal image attachments to Goose as image content blocks.
### 4.2 Image Support (Output)
**Priority: LOW**
If Goose responds with `ImageContentBlock` in agent messages, we could send
them as Signal attachments. Less common but possible.
### 4.3 File/Resource Content
**Priority: LOW**
`ResourceContentBlock` and `EmbeddedResourceContentBlock` in agent messages.
Could send file contents as Signal attachments or inline code blocks.
### 4.4 Audio Support
**Priority: LOW**
ACP has `AudioContentBlock`. Signal supports voice messages. Theoretically
bridgeable but Goose doesn't currently use audio.
---
## Phase 5: Advanced / Optional
### 5.1 Multi-User Concurrency
**Priority: MEDIUM**
Currently each contact gets their own session, and prompts run as async tasks.
But there's no protection against a user sending a second message while the
first is still processing. Options:
- Queue messages per-user (process sequentially)
- Allow concurrent prompts (may confuse Goose session state)
- Tell the user "please wait" if a prompt is in progress
### 5.2 Group Messaging
**Priority: LOW**
Signal groups could map to shared Goose sessions. Would need the account
number (already in config) for group message handling. signal-cli has
different envelope structure for group messages.
### 5.3 Extension Methods
**Priority: LOW**
`ext_method` and `ext_notification` are catch-alls for custom protocol
extensions. Log them for now; implement specific ones if Goose uses them.
### 5.4 MCP Server Configuration
**Priority: LOW**
`new_session` accepts `mcp_servers` to connect external tool servers. Could
be configured per-contact or globally in honker config.
---
## Decisions Made
1. **Terminal execution**: Permission gate is enough. No additional sandboxing.
2. **Tool call notifications**: Light — send "🔧 Running: ..." on tool start.
Goose already keeps extensive logs at `~/.local/state/goose/logs/`.
3. **Agent thoughts**: Opt-in via config flag (`forward_thoughts: true`).
4. **Cancellation UX**: `/cancel` plus general `/command` prefix convention.
5. **Multi-user concurrency**: "Please wait" — reject concurrent prompts per user.
6. **Image attachments**: Yes, wire up in Phase 1.
7. **Working directory**: Global config option (`working_directory`).
+119 -26
View File
@@ -1,7 +1,9 @@
"""Bridge — ties Signal and Goose together.""" """Bridge — ties Signal and Goose together."""
import asyncio import asyncio
import base64
import logging import logging
from pathlib import Path
from honker.config import Config from honker.config import Config
from honker.goose import GooseConnection from honker.goose import GooseConnection
@@ -29,7 +31,6 @@ def _split_message(text: str, max_len: int = MAX_SIGNAL_MESSAGE_LENGTH) -> list[
# Try to break at a newline # Try to break at a newline
split_at = text.rfind("\n", 0, max_len) split_at = text.rfind("\n", 0, max_len)
if split_at == -1: if split_at == -1:
# No newline found, break at max_len
split_at = max_len split_at = max_len
chunks.append(text[:split_at]) chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n") text = text[split_at:].lstrip("\n")
@@ -47,16 +48,28 @@ class Bridge:
# Pending interactive questions: source phone -> Future[str] # Pending interactive questions: source phone -> Future[str]
self._pending_replies: dict[str, asyncio.Future[str]] = {} self._pending_replies: dict[str, asyncio.Future[str]] = {}
# Wire up the ask_user callback so GooseClient can ask permission questions # Track which users have a prompt in progress
self._active_prompts: set[str] = set()
# Wire up callbacks so GooseClient can communicate with Signal users
self.goose.client.set_ask_user(self._ask_user) self.goose.client.set_ask_user(self._ask_user)
self.goose.client.set_notify_user(self._notify_user)
async def _send(self, source: str, text: str) -> None:
"""Send a message to a Signal user, splitting if needed."""
for chunk in _split_message(text):
await self.signal.send_message(source, chunk)
async def _notify_user(self, session_id: str, message: str) -> None:
"""Send a status notification to the Signal user for a session."""
source = self.goose.session_key_for_id(session_id)
if source:
await self._send(source, message)
async def _ask_user( async def _ask_user(
self, session_id: str, question: str, option_labels: list[str] self, session_id: str, question: str, option_labels: list[str]
) -> str: ) -> str:
"""Send a question to the Signal user and wait for their reply. """Send a question to the Signal user and wait for their reply."""
Called by GooseClient.request_permission during a prompt() call.
"""
source = self.goose.session_key_for_id(session_id) source = self.goose.session_key_for_id(session_id)
if source is None: if source is None:
raise RuntimeError(f"No contact found for session {session_id}") raise RuntimeError(f"No contact found for session {session_id}")
@@ -65,10 +78,7 @@ class Bridge:
contact_name = contact.name if contact else source contact_name = contact.name if contact else source
log.info("Asking %s for permission [%s]", contact_name, session_id[:8]) log.info("Asking %s for permission [%s]", contact_name, session_id[:8])
await self._send(source, question)
# Send the question
for chunk in _split_message(question):
await self.signal.send_message(source, chunk)
# Create a future and wait for the user's reply # Create a future and wait for the user's reply
future: asyncio.Future[str] = asyncio.get_event_loop().create_future() future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
@@ -80,9 +90,7 @@ class Bridge:
return reply return reply
except asyncio.TimeoutError: except asyncio.TimeoutError:
log.warning("Permission request timed out for %s [%s]", contact_name, session_id[:8]) log.warning("Permission request timed out for %s [%s]", contact_name, session_id[:8])
await self.signal.send_message( await self._send(source, "⏰ Permission request timed out — denying by default.")
source, "⏰ Permission request timed out — denying by default."
)
raise raise
finally: finally:
self._pending_replies.pop(source, None) self._pending_replies.pop(source, None)
@@ -93,7 +101,8 @@ class Bridge:
async for incoming in self.signal.receive_messages(): async for incoming in self.signal.receive_messages():
source = incoming["source"] source = incoming["source"]
message = incoming["message"] message = incoming.get("message", "")
attachments = incoming.get("attachments", [])
# Check trust # Check trust
if not self.config.is_trusted(source): if not self.config.is_trusted(source):
@@ -114,37 +123,121 @@ class Bridge:
self._pending_replies[source].set_result(message) self._pending_replies[source].set_result(message)
continue continue
# Check for slash commands
if message.startswith("/"):
await self._handle_command(source, contact_name, message)
continue
# Reject if a prompt is already in progress
if source in self._active_prompts:
log.info("Rejecting message from %s — prompt in progress", contact_name)
await self._send(source, "⏳ Please wait — I'm still working on your previous message.")
continue
log.info("Message from %s (%s): %s", contact_name, source, message[:80]) log.info("Message from %s (%s): %s", contact_name, source, message[:80])
# Process the message as a new prompt in the background so we can # Process the prompt in the background
# continue reading Signal messages (needed for permission replies)
asyncio.create_task( asyncio.create_task(
self._handle_prompt(source, contact_name, message), self._handle_prompt(source, contact_name, message, attachments),
name=f"prompt-{source}", name=f"prompt-{source}",
) )
async def _handle_prompt(self, source: str, contact_name: str, message: str) -> None: async def _handle_command(self, source: str, contact_name: str, message: str) -> None:
"""Handle a slash command from a Signal user."""
parts = message.strip().split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
log.info("Command from %s: %s", contact_name, command)
if command == "/cancel":
if source in self._active_prompts:
cancelled = await self.goose.cancel(source)
if cancelled:
await self._send(source, "🛑 Cancelling current request…")
else:
await self._send(source, "No active request to cancel.")
else:
await self._send(source, "No active request to cancel.")
elif command == "/new":
# Close existing session and start fresh
await self.goose.close_session(source)
await self._send(source, "🆕 Starting a new session.")
elif command == "/help":
help_text = (
"Available commands:\n"
"/cancel — Cancel the current request\n"
"/new — Start a new session\n"
"/help — Show this help message"
)
await self._send(source, help_text)
else:
await self._send(source, f"Unknown command: {command}\nType /help for available commands.")
async def _handle_prompt(
self,
source: str,
contact_name: str,
message: str,
attachments: list[dict],
) -> None:
"""Handle a single prompt from a Signal user.""" """Handle a single prompt from a Signal user."""
self._active_prompts.add(source)
try: try:
# Get or create a Goose session for this contact
session_id = await self.goose.get_or_create_session(source) session_id = await self.goose.get_or_create_session(source)
# Send the message to Goose # Check for image attachments
response_text = await self.goose.prompt(session_id, message) image_data, mime_type = await self._extract_image(attachments)
if image_data:
response_text = await self.goose.prompt_with_image(
session_id, message or None, image_data, mime_type
)
else:
if not message:
return # No text and no image, nothing to do
response_text = await self.goose.prompt(session_id, message)
if not response_text.strip(): if not response_text.strip():
response_text = "(Goose returned an empty response)" response_text = "(Goose returned an empty response)"
# Send the reply back via Signal await self._send(source, response_text)
for chunk in _split_message(response_text): log.info("Sent reply to %s (%d chars)", contact_name, len(response_text))
await self.signal.send_message(source, chunk)
log.info("Sent reply to %s (%d chars)", contact_name, len(chunk))
except Exception: except Exception:
log.exception("Error processing message from %s", contact_name) log.exception("Error processing message from %s", contact_name)
try: try:
await self.signal.send_message( await self._send(
source, "⚠️ Sorry, something went wrong processing your message." source, "⚠️ Sorry, something went wrong processing your message."
) )
except Exception: except Exception:
log.exception("Failed to send error message to %s", contact_name) log.exception("Failed to send error message to %s", contact_name)
finally:
self._active_prompts.discard(source)
async def _extract_image(self, attachments: list[dict]) -> tuple[str | None, str | None]:
"""Extract the first image attachment as base64 data.
Returns (base64_data, mime_type) or (None, None).
"""
for att in attachments:
content_type = att.get("contentType", "")
if not content_type.startswith("image/"):
continue
# signal-cli stores attachments locally; the path is in the "file" field
file_path = att.get("file")
if file_path and Path(file_path).is_file():
log.info("Reading image attachment: %s (%s)", file_path, content_type)
data = Path(file_path).read_bytes()
return base64.b64encode(data).decode("ascii"), content_type
# Some versions use "id" - try fetching via signal-cli's attachment path
att_id = att.get("id")
if att_id:
log.info("Attachment has id %s but no file path; skipping", att_id)
return None, None
+11 -1
View File
@@ -17,6 +17,8 @@ class Contact:
class Config: class Config:
account: str account: str
trusted_contacts: list[Contact] = field(default_factory=list) trusted_contacts: list[Contact] = field(default_factory=list)
working_directory: str = "."
forward_thoughts: bool = False
def is_trusted(self, number: str) -> bool: def is_trusted(self, number: str) -> bool:
"""Check whether a phone number belongs to a trusted contact.""" """Check whether a phone number belongs to a trusted contact."""
@@ -52,4 +54,12 @@ def load_config(path: Path | None = None) -> Config:
for c in raw.get("trusted_contacts", []) for c in raw.get("trusted_contacts", [])
] ]
return Config(account=raw["account"], trusted_contacts=contacts) working_directory = raw.get("working_directory", ".")
forward_thoughts = raw.get("forward_thoughts", False)
return Config(
account=raw["account"],
trusted_contacts=contacts,
working_directory=working_directory,
forward_thoughts=forward_thoughts,
)
+100 -13
View File
@@ -15,6 +15,7 @@ from acp import (
) )
from acp.schema import ( from acp.schema import (
AgentMessageChunk, AgentMessageChunk,
AgentThoughtChunk,
AllowedOutcome, AllowedOutcome,
ClientCapabilities, ClientCapabilities,
DeniedOutcome, DeniedOutcome,
@@ -32,6 +33,9 @@ log = logging.getLogger(__name__)
# Type for the callback the bridge provides: (session_id, question, options) -> answer # Type for the callback the bridge provides: (session_id, question, options) -> answer
AskUserFunc = Callable[[str, str, list[str]], Awaitable[str]] AskUserFunc = Callable[[str, str, list[str]], Awaitable[str]]
# Type for sending a notification to the user: (session_id, message) -> None
NotifyUserFunc = Callable[[str, str], Awaitable[None]]
def _format_tool_call(tool_call: ToolCallUpdate) -> str: def _format_tool_call(tool_call: ToolCallUpdate) -> str:
"""Format a tool call into a human-readable description.""" """Format a tool call into a human-readable description."""
@@ -67,32 +71,59 @@ def _format_permission_message(
class GooseClient(Client): class GooseClient(Client):
"""ACP Client implementation that collects agent responses.""" """ACP Client implementation that handles agent requests and notifications.
def __init__(self) -> None: Goose runs locally and uses its own built-in tools for file I/O and shell
execution, so we don't advertise client-side fs or terminal capabilities.
The client-side methods exist only as no-op stubs required by the protocol.
"""
def __init__(self, forward_thoughts: bool = False) -> None:
self._response_chunks: dict[str, list[str]] = {} # session_id -> chunks self._response_chunks: dict[str, list[str]] = {} # session_id -> chunks
self._forward_thoughts = forward_thoughts
self._ask_user: AskUserFunc | None = None self._ask_user: AskUserFunc | None = None
self._notify_user: NotifyUserFunc | None = None
def set_ask_user(self, func: AskUserFunc) -> None: def set_ask_user(self, func: AskUserFunc) -> None:
"""Set the callback used to ask the Signal user a question.""" """Set the callback used to ask the Signal user a question."""
self._ask_user = func self._ask_user = func
def set_notify_user(self, func: NotifyUserFunc) -> None:
"""Set the callback used to send status notifications to the Signal user."""
self._notify_user = func
def take_response(self, session_id: str) -> str: def take_response(self, session_id: str) -> str:
"""Take the accumulated response text for a session, clearing the buffer.""" """Take the accumulated response text for a session, clearing the buffer."""
chunks = self._response_chunks.pop(session_id, []) chunks = self._response_chunks.pop(session_id, [])
return "".join(chunks) return "".join(chunks)
# -- Session update notifications --
async def session_update(self, session_id: str, update, **kwargs: Any) -> None: async def session_update(self, session_id: str, update, **kwargs: Any) -> None:
if isinstance(update, AgentMessageChunk): if isinstance(update, AgentMessageChunk):
content = update.content content = update.content
if isinstance(content, TextContentBlock): if isinstance(content, TextContentBlock):
self._response_chunks.setdefault(session_id, []).append(content.text) self._response_chunks.setdefault(session_id, []).append(content.text)
log.debug("Agent chunk [%s]: %s", session_id[:8], content.text[:80]) log.debug("Agent chunk [%s]: %s", session_id[:8], content.text[:80])
elif isinstance(update, AgentThoughtChunk):
content = update.content
if isinstance(content, TextContentBlock):
log.debug("Agent thought [%s]: %s", session_id[:8], content.text[:80])
if self._forward_thoughts and self._notify_user:
await self._notify_user(session_id, f"💭 {content.text}")
elif isinstance(update, ToolCallStart): elif isinstance(update, ToolCallStart):
log.info("Tool call started [%s]: %s", session_id[:8], update.title) title = update.title or "(unknown)"
log.info("Tool call started [%s]: %s", session_id[:8], title)
if self._notify_user:
await self._notify_user(session_id, f"🔧 {title}")
elif isinstance(update, ToolCallProgress): elif isinstance(update, ToolCallProgress):
log.debug("Tool call progress [%s]", session_id[:8]) log.debug("Tool call progress [%s]", session_id[:8])
# -- Permission requests --
async def request_permission( async def request_permission(
self, self,
options: list[PermissionOption], options: list[PermissionOption],
@@ -132,16 +163,18 @@ class GooseClient(Client):
outcome=DeniedOutcome(outcome="cancelled") outcome=DeniedOutcome(outcome="cancelled")
) )
async def write_text_file(self, content, path, session_id, **kwargs): # -- Client-side stubs (Goose uses its own built-in tools instead) --
log.info("Agent wants to write file [%s]: %s", session_id[:8], path)
return None
async def read_text_file(self, path, session_id, **kwargs): async def read_text_file(self, path, session_id, **kwargs):
log.info("Agent wants to read file [%s]: %s", session_id[:8], path) log.debug("read_text_file called (unused) [%s]: %s", session_id[:8], path)
return None
async def write_text_file(self, content, path, session_id, **kwargs):
log.debug("write_text_file called (unused) [%s]: %s", session_id[:8], path)
return None return None
async def create_terminal(self, command, session_id, **kwargs): async def create_terminal(self, command, session_id, **kwargs):
log.info("Agent wants to create terminal [%s]: %s", session_id[:8], command) log.debug("create_terminal called (unused) [%s]: %s", session_id[:8], command)
return None return None
async def terminal_output(self, session_id, terminal_id, **kwargs): async def terminal_output(self, session_id, terminal_id, **kwargs):
@@ -157,10 +190,11 @@ class GooseClient(Client):
return None return None
async def ext_method(self, method, params): async def ext_method(self, method, params):
log.debug("ext_method: %s", method)
return {} return {}
async def ext_notification(self, method, params): async def ext_notification(self, method, params):
pass log.debug("ext_notification: %s", method)
def on_connect(self, conn: Agent) -> None: def on_connect(self, conn: Agent) -> None:
pass pass
@@ -197,9 +231,9 @@ def _parse_permission_reply(
class GooseConnection: class GooseConnection:
"""Manages the ACP connection to a Goose agent process.""" """Manages the ACP connection to a Goose agent process."""
def __init__(self, cwd: str = "."): def __init__(self, cwd: str = ".", forward_thoughts: bool = False):
self.cwd = cwd self.cwd = cwd
self.client = GooseClient() self.client = GooseClient(forward_thoughts=forward_thoughts)
self._conn = None self._conn = None
self._process = None self._process = None
self._ctx = None self._ctx = None
@@ -217,7 +251,8 @@ class GooseConnection:
self._ctx = spawn_agent_process(self.client, "goose", "acp", env=os.environ.copy()) self._ctx = spawn_agent_process(self.client, "goose", "acp", env=os.environ.copy())
self._conn, self._process = await self._ctx.__aenter__() self._conn, self._process = await self._ctx.__aenter__()
# Initialize the ACP protocol # Initialize — don't advertise fs or terminal capabilities since
# Goose runs locally and uses its own built-in tools
resp = await self._conn.initialize( resp = await self._conn.initialize(
protocol_version=PROTOCOL_VERSION, protocol_version=PROTOCOL_VERSION,
client_info=Implementation(name="honker", version="0.1.0"), client_info=Implementation(name="honker", version="0.1.0"),
@@ -247,9 +282,32 @@ class GooseConnection:
log.info("Created new Goose session for %s: %s", key, session_id[:8]) log.info("Created new Goose session for %s: %s", key, session_id[:8])
return session_id return session_id
async def close_session(self, key: str) -> bool:
"""Close and remove the session for the given key. Returns True if found."""
session_id = self._sessions.pop(key, None)
if session_id is None:
return False
try:
await self._conn.close_session(session_id=session_id)
except Exception:
log.exception("Error closing session %s", session_id[:8])
log.info("Closed session for %s: %s", key, session_id[:8])
return True
async def cancel(self, key: str) -> bool:
"""Cancel the current prompt for the given key. Returns True if found."""
session_id = self._sessions.get(key)
if session_id is None:
return False
try:
await self._conn.cancel(session_id=session_id)
log.info("Cancelled prompt for %s [%s]", key, session_id[:8])
except Exception:
log.exception("Error cancelling session %s", session_id[:8])
return True
async def prompt(self, session_id: str, text: str) -> str: async def prompt(self, session_id: str, text: str) -> str:
"""Send a prompt and wait for the complete response.""" """Send a prompt and wait for the complete response."""
# Clear any prior state
self.client._response_chunks.pop(session_id, None) self.client._response_chunks.pop(session_id, None)
prompt_content = [TextContentBlock(type="text", text=text)] prompt_content = [TextContentBlock(type="text", text=text)]
@@ -257,4 +315,33 @@ class GooseConnection:
log.info("Prompt complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason) log.info("Prompt complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
text_response = self.client.take_response(session_id)
# Append stop reason context if not a normal end
stop_reason = response.stop_reason
if stop_reason == "max_tokens":
text_response += "\n\n⚠️ Response was cut short — context limit reached."
elif stop_reason == "max_turn_requests":
text_response += "\n\n⚠️ Response was cut short — turn limit reached."
elif stop_reason == "refusal":
text_response += "\n\n🚫 The agent refused this request."
elif stop_reason == "cancelled":
text_response += "\n\n🛑 Request was cancelled."
return text_response
async def prompt_with_image(self, session_id: str, text: str | None, image_data: str, mime_type: str) -> str:
"""Send a prompt with an image and wait for the complete response."""
from acp.schema import ImageContentBlock
self.client._response_chunks.pop(session_id, None)
prompt_content = []
if text:
prompt_content.append(TextContentBlock(type="text", text=text))
prompt_content.append(ImageContentBlock(type="image", data=image_data, mime_type=mime_type))
response = await self._conn.prompt(prompt=prompt_content, session_id=session_id)
log.info("Prompt (with image) complete [%s]: stop_reason=%s", session_id[:8], response.stop_reason)
return self.client.take_response(session_id) return self.client.take_response(session_id)
+5 -2
View File
@@ -51,9 +51,12 @@ async def run(args: argparse.Namespace) -> None:
await signal_client.connect() await signal_client.connect()
# Start Goose via ACP # Start Goose via ACP
goose = GooseConnection() goose = GooseConnection(
cwd=config.working_directory,
forward_thoughts=config.forward_thoughts,
)
await goose.start() await goose.start()
log.info("Goose ACP connection established") log.info("Goose ACP connection established (cwd=%s)", config.working_directory)
# Run the bridge; cancel on SIGINT/SIGTERM # Run the bridge; cancel on SIGINT/SIGTERM
bridge = Bridge(config, signal_client, goose) bridge = Bridge(config, signal_client, goose)
+11 -6
View File
@@ -82,12 +82,17 @@ class SignalClient:
source = envelope.get("source") source = envelope.get("source")
data_message = envelope.get("dataMessage") data_message = envelope.get("dataMessage")
if source and data_message and data_message.get("message"): if source and data_message:
await self._incoming_queue.put({ message_text = data_message.get("message", "")
"source": source, attachments = data_message.get("attachments", [])
"message": data_message["message"], # Only enqueue if there's text or attachments
"timestamp": data_message.get("timestamp"), if message_text or attachments:
}) await self._incoming_queue.put({
"source": source,
"message": message_text,
"timestamp": data_message.get("timestamp"),
"attachments": attachments,
})
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception: except Exception: