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"
- name: "Bob"
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."""
import asyncio
import base64
import logging
from pathlib import Path
from honker.config import Config
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
split_at = text.rfind("\n", 0, max_len)
if split_at == -1:
# No newline found, break at max_len
split_at = max_len
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
@@ -47,16 +48,28 @@ class Bridge:
# Pending interactive questions: source phone -> 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_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(
self, session_id: str, question: str, option_labels: list[str]
) -> str:
"""Send a question to the Signal user and wait for their reply.
Called by GooseClient.request_permission during a prompt() call.
"""
"""Send a question to the Signal user and wait for their reply."""
source = self.goose.session_key_for_id(session_id)
if source is None:
raise RuntimeError(f"No contact found for session {session_id}")
@@ -65,10 +78,7 @@ class Bridge:
contact_name = contact.name if contact else source
log.info("Asking %s for permission [%s]", contact_name, session_id[:8])
# Send the question
for chunk in _split_message(question):
await self.signal.send_message(source, chunk)
await self._send(source, question)
# Create a future and wait for the user's reply
future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
@@ -80,9 +90,7 @@ class Bridge:
return reply
except asyncio.TimeoutError:
log.warning("Permission request timed out for %s [%s]", contact_name, session_id[:8])
await self.signal.send_message(
source, "⏰ Permission request timed out — denying by default."
)
await self._send(source, "⏰ Permission request timed out — denying by default.")
raise
finally:
self._pending_replies.pop(source, None)
@@ -93,7 +101,8 @@ class Bridge:
async for incoming in self.signal.receive_messages():
source = incoming["source"]
message = incoming["message"]
message = incoming.get("message", "")
attachments = incoming.get("attachments", [])
# Check trust
if not self.config.is_trusted(source):
@@ -114,37 +123,121 @@ class Bridge:
self._pending_replies[source].set_result(message)
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])
# Process the message as a new prompt in the background so we can
# continue reading Signal messages (needed for permission replies)
# Process the prompt in the background
asyncio.create_task(
self._handle_prompt(source, contact_name, message),
self._handle_prompt(source, contact_name, message, attachments),
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."""
self._active_prompts.add(source)
try:
# Get or create a Goose session for this contact
session_id = await self.goose.get_or_create_session(source)
# Send the message to Goose
response_text = await self.goose.prompt(session_id, message)
# Check for image attachments
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():
response_text = "(Goose returned an empty response)"
# Send the reply back via Signal
for chunk in _split_message(response_text):
await self.signal.send_message(source, chunk)
log.info("Sent reply to %s (%d chars)", contact_name, len(chunk))
await self._send(source, response_text)
log.info("Sent reply to %s (%d chars)", contact_name, len(response_text))
except Exception:
log.exception("Error processing message from %s", contact_name)
try:
await self.signal.send_message(
await self._send(
source, "⚠️ Sorry, something went wrong processing your message."
)
except Exception:
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:
account: str
trusted_contacts: list[Contact] = field(default_factory=list)
working_directory: str = "."
forward_thoughts: bool = False
def is_trusted(self, number: str) -> bool:
"""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", [])
]
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 (
AgentMessageChunk,
AgentThoughtChunk,
AllowedOutcome,
ClientCapabilities,
DeniedOutcome,
@@ -32,6 +33,9 @@ log = logging.getLogger(__name__)
# Type for the callback the bridge provides: (session_id, question, options) -> answer
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:
"""Format a tool call into a human-readable description."""
@@ -67,32 +71,59 @@ def _format_permission_message(
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._forward_thoughts = forward_thoughts
self._ask_user: AskUserFunc | None = None
self._notify_user: NotifyUserFunc | None = None
def set_ask_user(self, func: AskUserFunc) -> None:
"""Set the callback used to ask the Signal user a question."""
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:
"""Take the accumulated response text for a session, clearing the buffer."""
chunks = self._response_chunks.pop(session_id, [])
return "".join(chunks)
# -- Session update notifications --
async def session_update(self, session_id: str, update, **kwargs: Any) -> None:
if isinstance(update, AgentMessageChunk):
content = update.content
if isinstance(content, TextContentBlock):
self._response_chunks.setdefault(session_id, []).append(content.text)
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):
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):
log.debug("Tool call progress [%s]", session_id[:8])
# -- Permission requests --
async def request_permission(
self,
options: list[PermissionOption],
@@ -132,16 +163,18 @@ class GooseClient(Client):
outcome=DeniedOutcome(outcome="cancelled")
)
async def write_text_file(self, content, path, session_id, **kwargs):
log.info("Agent wants to write file [%s]: %s", session_id[:8], path)
return None
# -- Client-side stubs (Goose uses its own built-in tools instead) --
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
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
async def terminal_output(self, session_id, terminal_id, **kwargs):
@@ -157,10 +190,11 @@ class GooseClient(Client):
return None
async def ext_method(self, method, params):
log.debug("ext_method: %s", method)
return {}
async def ext_notification(self, method, params):
pass
log.debug("ext_notification: %s", method)
def on_connect(self, conn: Agent) -> None:
pass
@@ -197,9 +231,9 @@ def _parse_permission_reply(
class GooseConnection:
"""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.client = GooseClient()
self.client = GooseClient(forward_thoughts=forward_thoughts)
self._conn = None
self._process = None
self._ctx = None
@@ -217,7 +251,8 @@ class GooseConnection:
self._ctx = spawn_agent_process(self.client, "goose", "acp", env=os.environ.copy())
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(
protocol_version=PROTOCOL_VERSION,
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])
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:
"""Send a prompt and wait for the complete response."""
# Clear any prior state
self.client._response_chunks.pop(session_id, None)
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)
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)
+5 -2
View File
@@ -51,9 +51,12 @@ async def run(args: argparse.Namespace) -> None:
await signal_client.connect()
# Start Goose via ACP
goose = GooseConnection()
goose = GooseConnection(
cwd=config.working_directory,
forward_thoughts=config.forward_thoughts,
)
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
bridge = Bridge(config, signal_client, goose)
+11 -6
View File
@@ -82,12 +82,17 @@ class SignalClient:
source = envelope.get("source")
data_message = envelope.get("dataMessage")
if source and data_message and data_message.get("message"):
await self._incoming_queue.put({
"source": source,
"message": data_message["message"],
"timestamp": data_message.get("timestamp"),
})
if source and data_message:
message_text = data_message.get("message", "")
attachments = data_message.get("attachments", [])
# Only enqueue if there's text or attachments
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:
pass
except Exception: