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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user