claude-code-acp 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/PKG-INFO +38 -1
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/README.md +37 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/pyproject.toml +1 -1
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/src/claude_code_acp/__init__.py +1 -1
- claude_code_acp-0.3.4/src/claude_code_acp/acp_client.py +688 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/src/claude_code_acp/agent.py +71 -2
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/uv.lock +1 -1
- claude_code_acp-0.3.2/src/claude_code_acp/acp_client.py +0 -374
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/.github/workflows/publish.yml +0 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/.gitignore +0 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/LICENSE +0 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/src/claude_code_acp/__main__.py +0 -0
- {claude_code_acp-0.3.2 → claude_code_acp-0.3.4}/src/claude_code_acp/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-code-acp
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: ACP-compatible agent for Claude Code (Python version)
|
|
5
5
|
Project-URL: Homepage, https://github.com/yazelin/claude-code-acp-py
|
|
6
6
|
Project-URL: Repository, https://github.com/yazelin/claude-code-acp-py
|
|
@@ -234,6 +234,43 @@ gemini = AcpClient(command="gemini", args=["--experimental-acp"])
|
|
|
234
234
|
ts_claude = AcpClient(command="npx", args=["@zed-industries/claude-code-acp"])
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
### File Operation Handlers
|
|
238
|
+
|
|
239
|
+
AcpClient supports intercepting file read/write operations for security or custom handling:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
@client.on_file_read
|
|
243
|
+
async def handle_read(path: str) -> str | None:
|
|
244
|
+
"""Intercept file reads. Return content to override, or None to proceed."""
|
|
245
|
+
print(f"📖 Reading: {path}")
|
|
246
|
+
return None # Proceed with normal read
|
|
247
|
+
|
|
248
|
+
@client.on_file_write
|
|
249
|
+
async def handle_write(path: str, content: str) -> bool:
|
|
250
|
+
"""Intercept file writes. Return True to allow, False to block."""
|
|
251
|
+
print(f"📝 Writing: {path}")
|
|
252
|
+
response = input("Allow write? [y/N]: ")
|
|
253
|
+
return response.lower() == "y"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Terminal Operation Handlers
|
|
257
|
+
|
|
258
|
+
AcpClient supports intercepting terminal/shell execution for security:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
@client.on_terminal_create
|
|
262
|
+
async def handle_terminal(command: str, cwd: str) -> bool:
|
|
263
|
+
"""Intercept shell commands. Return True to allow, False to block."""
|
|
264
|
+
print(f"🖥️ Command: {command} in {cwd}")
|
|
265
|
+
response = input("Allow execution? [y/N]: ")
|
|
266
|
+
return response.lower() == "y"
|
|
267
|
+
|
|
268
|
+
@client.on_terminal_output
|
|
269
|
+
async def handle_output(terminal_id: str, output: str) -> None:
|
|
270
|
+
"""Receive terminal output in real-time."""
|
|
271
|
+
print(output, end="")
|
|
272
|
+
```
|
|
273
|
+
|
|
237
274
|
### AcpClient vs ClaudeClient
|
|
238
275
|
|
|
239
276
|
| Feature | `ClaudeClient` | `AcpClient` |
|
|
@@ -211,6 +211,43 @@ gemini = AcpClient(command="gemini", args=["--experimental-acp"])
|
|
|
211
211
|
ts_claude = AcpClient(command="npx", args=["@zed-industries/claude-code-acp"])
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
### File Operation Handlers
|
|
215
|
+
|
|
216
|
+
AcpClient supports intercepting file read/write operations for security or custom handling:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
@client.on_file_read
|
|
220
|
+
async def handle_read(path: str) -> str | None:
|
|
221
|
+
"""Intercept file reads. Return content to override, or None to proceed."""
|
|
222
|
+
print(f"📖 Reading: {path}")
|
|
223
|
+
return None # Proceed with normal read
|
|
224
|
+
|
|
225
|
+
@client.on_file_write
|
|
226
|
+
async def handle_write(path: str, content: str) -> bool:
|
|
227
|
+
"""Intercept file writes. Return True to allow, False to block."""
|
|
228
|
+
print(f"📝 Writing: {path}")
|
|
229
|
+
response = input("Allow write? [y/N]: ")
|
|
230
|
+
return response.lower() == "y"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Terminal Operation Handlers
|
|
234
|
+
|
|
235
|
+
AcpClient supports intercepting terminal/shell execution for security:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
@client.on_terminal_create
|
|
239
|
+
async def handle_terminal(command: str, cwd: str) -> bool:
|
|
240
|
+
"""Intercept shell commands. Return True to allow, False to block."""
|
|
241
|
+
print(f"🖥️ Command: {command} in {cwd}")
|
|
242
|
+
response = input("Allow execution? [y/N]: ")
|
|
243
|
+
return response.lower() == "y"
|
|
244
|
+
|
|
245
|
+
@client.on_terminal_output
|
|
246
|
+
async def handle_output(terminal_id: str, output: str) -> None:
|
|
247
|
+
"""Receive terminal output in real-time."""
|
|
248
|
+
print(output, end="")
|
|
249
|
+
```
|
|
250
|
+
|
|
214
251
|
### AcpClient vs ClaudeClient
|
|
215
252
|
|
|
216
253
|
| Feature | `ClaudeClient` | `AcpClient` |
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ACP Client - Connect to any ACP-compatible agent.
|
|
3
|
+
|
|
4
|
+
This module provides a client that can connect to any ACP agent
|
|
5
|
+
(like claude-code-acp, or Zed's TypeScript version) via subprocess.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Coroutine
|
|
15
|
+
|
|
16
|
+
from acp.client.connection import ClientSideConnection
|
|
17
|
+
from acp.schema import (
|
|
18
|
+
AgentMessageChunk,
|
|
19
|
+
AgentThoughtChunk,
|
|
20
|
+
Implementation,
|
|
21
|
+
PermissionOption,
|
|
22
|
+
RequestPermissionResponse,
|
|
23
|
+
TextContentBlock,
|
|
24
|
+
ToolCallProgress,
|
|
25
|
+
ToolCallStart,
|
|
26
|
+
ToolCallUpdate,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["AcpClient", "AcpClientEvents"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TerminalProcess:
|
|
36
|
+
"""Represents an active terminal process."""
|
|
37
|
+
|
|
38
|
+
process: asyncio.subprocess.Process
|
|
39
|
+
command: str
|
|
40
|
+
cwd: str
|
|
41
|
+
output_buffer: list[str]
|
|
42
|
+
exit_code: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AcpClientEvents:
|
|
47
|
+
"""Event handlers for ACP client."""
|
|
48
|
+
|
|
49
|
+
on_text: Callable[[str], Coroutine[Any, Any, None]] | None = None
|
|
50
|
+
on_thinking: Callable[[str], Coroutine[Any, Any, None]] | None = None
|
|
51
|
+
on_tool_start: Callable[[str, str, dict], Coroutine[Any, Any, None]] | None = None
|
|
52
|
+
on_tool_end: Callable[[str, str, Any], Coroutine[Any, Any, None]] | None = None
|
|
53
|
+
on_permission: Callable[[str, dict, list], Coroutine[Any, Any, str]] | None = None
|
|
54
|
+
on_error: Callable[[Exception], Coroutine[Any, Any, None]] | None = None
|
|
55
|
+
on_complete: Callable[[], Coroutine[Any, Any, None]] | None = None
|
|
56
|
+
# File operation handlers (optional - if not set, operations proceed automatically)
|
|
57
|
+
on_file_read: Callable[[str], Coroutine[Any, Any, str | None]] | None = None
|
|
58
|
+
on_file_write: Callable[[str, str], Coroutine[Any, Any, bool]] | None = None
|
|
59
|
+
# Terminal operation handlers (optional)
|
|
60
|
+
on_terminal_create: Callable[[str, str], Coroutine[Any, Any, bool]] | None = None
|
|
61
|
+
on_terminal_output: Callable[[str, str], Coroutine[Any, Any, None]] | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AcpClient:
|
|
65
|
+
"""
|
|
66
|
+
ACP Client that connects to any ACP-compatible agent.
|
|
67
|
+
|
|
68
|
+
This client spawns an ACP agent as a subprocess and communicates
|
|
69
|
+
via the Agent Client Protocol (JSON-RPC over stdio).
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
```python
|
|
73
|
+
# Connect to claude-code-acp
|
|
74
|
+
client = AcpClient(command="claude-code-acp")
|
|
75
|
+
|
|
76
|
+
# Or connect to the TypeScript version
|
|
77
|
+
client = AcpClient(command="npx", args=["@anthropic/claude-code-acp"])
|
|
78
|
+
|
|
79
|
+
@client.on_text
|
|
80
|
+
async def handle_text(text):
|
|
81
|
+
print(text, end="")
|
|
82
|
+
|
|
83
|
+
async with client:
|
|
84
|
+
await client.prompt("Hello!")
|
|
85
|
+
```
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
command: str = "claude-code-acp",
|
|
91
|
+
args: list[str] | None = None,
|
|
92
|
+
cwd: str = ".",
|
|
93
|
+
env: dict[str, str] | None = None,
|
|
94
|
+
):
|
|
95
|
+
"""
|
|
96
|
+
Initialize the ACP client.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
command: The ACP agent command to run.
|
|
100
|
+
args: Additional arguments for the command.
|
|
101
|
+
cwd: Working directory for the agent.
|
|
102
|
+
env: Additional environment variables.
|
|
103
|
+
"""
|
|
104
|
+
self.command = command
|
|
105
|
+
self.args = args or []
|
|
106
|
+
self.cwd = cwd
|
|
107
|
+
self.env = env
|
|
108
|
+
self.events = AcpClientEvents()
|
|
109
|
+
|
|
110
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
111
|
+
self._connection: ClientSideConnection | None = None
|
|
112
|
+
self._session_id: str | None = None
|
|
113
|
+
self._text_buffer = ""
|
|
114
|
+
self._initialized = False
|
|
115
|
+
# Terminal management
|
|
116
|
+
self._terminals: dict[str, TerminalProcess] = {}
|
|
117
|
+
self._terminal_counter = 0
|
|
118
|
+
|
|
119
|
+
# --- Event decorators ---
|
|
120
|
+
|
|
121
|
+
def on_text(self, func: Callable[[str], Coroutine[Any, Any, None]]):
|
|
122
|
+
"""Register handler for text responses."""
|
|
123
|
+
self.events.on_text = func
|
|
124
|
+
return func
|
|
125
|
+
|
|
126
|
+
def on_thinking(self, func: Callable[[str], Coroutine[Any, Any, None]]):
|
|
127
|
+
"""Register handler for thinking blocks."""
|
|
128
|
+
self.events.on_thinking = func
|
|
129
|
+
return func
|
|
130
|
+
|
|
131
|
+
def on_tool_start(self, func: Callable[[str, str, dict], Coroutine[Any, Any, None]]):
|
|
132
|
+
"""Register handler for tool start events."""
|
|
133
|
+
self.events.on_tool_start = func
|
|
134
|
+
return func
|
|
135
|
+
|
|
136
|
+
def on_tool_end(self, func: Callable[[str, str, Any], Coroutine[Any, Any, None]]):
|
|
137
|
+
"""Register handler for tool end events."""
|
|
138
|
+
self.events.on_tool_end = func
|
|
139
|
+
return func
|
|
140
|
+
|
|
141
|
+
def on_permission(self, func: Callable[[str, dict, list], Coroutine[Any, Any, str]]):
|
|
142
|
+
"""
|
|
143
|
+
Register handler for permission requests.
|
|
144
|
+
|
|
145
|
+
The handler receives (name, input, options) and should return
|
|
146
|
+
the option_id to select (e.g., "allow", "reject", "allow_always").
|
|
147
|
+
"""
|
|
148
|
+
self.events.on_permission = func
|
|
149
|
+
return func
|
|
150
|
+
|
|
151
|
+
def on_error(self, func: Callable[[Exception], Coroutine[Any, Any, None]]):
|
|
152
|
+
"""Register handler for errors."""
|
|
153
|
+
self.events.on_error = func
|
|
154
|
+
return func
|
|
155
|
+
|
|
156
|
+
def on_complete(self, func: Callable[[], Coroutine[Any, Any, None]]):
|
|
157
|
+
"""Register handler for completion."""
|
|
158
|
+
self.events.on_complete = func
|
|
159
|
+
return func
|
|
160
|
+
|
|
161
|
+
def on_file_read(self, func: Callable[[str], Coroutine[Any, Any, str | None]]):
|
|
162
|
+
"""
|
|
163
|
+
Register handler for file read operations.
|
|
164
|
+
|
|
165
|
+
The handler receives (path) and can return:
|
|
166
|
+
- str: Override the file content with this value
|
|
167
|
+
- None: Proceed with normal file reading
|
|
168
|
+
|
|
169
|
+
This allows intercepting file reads for security or custom handling.
|
|
170
|
+
"""
|
|
171
|
+
self.events.on_file_read = func
|
|
172
|
+
return func
|
|
173
|
+
|
|
174
|
+
def on_file_write(self, func: Callable[[str, str], Coroutine[Any, Any, bool]]):
|
|
175
|
+
"""
|
|
176
|
+
Register handler for file write operations.
|
|
177
|
+
|
|
178
|
+
The handler receives (path, content) and should return:
|
|
179
|
+
- True: Allow the write to proceed
|
|
180
|
+
- False: Block the write
|
|
181
|
+
|
|
182
|
+
This allows intercepting file writes for security or confirmation prompts.
|
|
183
|
+
"""
|
|
184
|
+
self.events.on_file_write = func
|
|
185
|
+
return func
|
|
186
|
+
|
|
187
|
+
def on_terminal_create(self, func: Callable[[str, str], Coroutine[Any, Any, bool]]):
|
|
188
|
+
"""
|
|
189
|
+
Register handler for terminal creation requests.
|
|
190
|
+
|
|
191
|
+
The handler receives (command, cwd) and should return:
|
|
192
|
+
- True: Allow the terminal to be created
|
|
193
|
+
- False: Block the terminal creation
|
|
194
|
+
|
|
195
|
+
This allows intercepting shell command execution for security.
|
|
196
|
+
"""
|
|
197
|
+
self.events.on_terminal_create = func
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
def on_terminal_output(self, func: Callable[[str, str], Coroutine[Any, Any, None]]):
|
|
201
|
+
"""
|
|
202
|
+
Register handler for terminal output.
|
|
203
|
+
|
|
204
|
+
The handler receives (terminal_id, output) when new output is available.
|
|
205
|
+
This allows displaying or logging terminal output in real-time.
|
|
206
|
+
"""
|
|
207
|
+
self.events.on_terminal_output = func
|
|
208
|
+
return func
|
|
209
|
+
|
|
210
|
+
# --- Connection management ---
|
|
211
|
+
|
|
212
|
+
async def connect(self) -> None:
|
|
213
|
+
"""Connect to the ACP agent."""
|
|
214
|
+
if self._process is not None:
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Spawn the agent process
|
|
218
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
219
|
+
self.command,
|
|
220
|
+
*self.args,
|
|
221
|
+
stdin=asyncio.subprocess.PIPE,
|
|
222
|
+
stdout=asyncio.subprocess.PIPE,
|
|
223
|
+
stderr=asyncio.subprocess.PIPE,
|
|
224
|
+
cwd=self.cwd,
|
|
225
|
+
env=self.env,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if self._process.stdin is None or self._process.stdout is None:
|
|
229
|
+
raise RuntimeError("Failed to create subprocess pipes")
|
|
230
|
+
|
|
231
|
+
# Create ACP connection
|
|
232
|
+
# Note: ClientSideConnection expects (writer, reader) - stdin is writer, stdout is reader
|
|
233
|
+
self._connection = ClientSideConnection(
|
|
234
|
+
to_client=self._create_client_handler(),
|
|
235
|
+
input_stream=self._process.stdin,
|
|
236
|
+
output_stream=self._process.stdout,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Initialize the connection
|
|
240
|
+
init_response = await self._connection.initialize(
|
|
241
|
+
protocol_version=1,
|
|
242
|
+
client_info=Implementation(
|
|
243
|
+
name="claude-code-acp-client",
|
|
244
|
+
version="0.2.0",
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
logger.info(f"Connected to agent: {init_response.agent_info}")
|
|
248
|
+
self._initialized = True
|
|
249
|
+
|
|
250
|
+
async def disconnect(self) -> None:
|
|
251
|
+
"""Disconnect from the ACP agent."""
|
|
252
|
+
# Clean up all terminals
|
|
253
|
+
for terminal_id, terminal in list(self._terminals.items()):
|
|
254
|
+
try:
|
|
255
|
+
terminal.process.kill()
|
|
256
|
+
await terminal.process.wait()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
self._terminals.clear()
|
|
260
|
+
|
|
261
|
+
if self._connection:
|
|
262
|
+
await self._connection.close()
|
|
263
|
+
self._connection = None
|
|
264
|
+
|
|
265
|
+
if self._process:
|
|
266
|
+
self._process.terminate()
|
|
267
|
+
await self._process.wait()
|
|
268
|
+
self._process = None
|
|
269
|
+
|
|
270
|
+
self._initialized = False
|
|
271
|
+
self._session_id = None
|
|
272
|
+
|
|
273
|
+
async def __aenter__(self) -> "AcpClient":
|
|
274
|
+
await self.connect()
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
278
|
+
await self.disconnect()
|
|
279
|
+
|
|
280
|
+
# --- Session management ---
|
|
281
|
+
|
|
282
|
+
async def new_session(self) -> str:
|
|
283
|
+
"""Create a new session."""
|
|
284
|
+
if not self._connection:
|
|
285
|
+
raise RuntimeError("Not connected")
|
|
286
|
+
|
|
287
|
+
response = await self._connection.new_session(
|
|
288
|
+
cwd=self.cwd,
|
|
289
|
+
mcp_servers=[],
|
|
290
|
+
)
|
|
291
|
+
self._session_id = response.session_id
|
|
292
|
+
return self._session_id
|
|
293
|
+
|
|
294
|
+
async def prompt(self, text: str) -> str:
|
|
295
|
+
"""
|
|
296
|
+
Send a prompt and receive the response.
|
|
297
|
+
|
|
298
|
+
Events will be dispatched to registered handlers as they arrive.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
text: The prompt text to send.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The full text response.
|
|
305
|
+
"""
|
|
306
|
+
if not self._connection:
|
|
307
|
+
raise RuntimeError("Not connected")
|
|
308
|
+
|
|
309
|
+
if not self._session_id:
|
|
310
|
+
await self.new_session()
|
|
311
|
+
|
|
312
|
+
self._text_buffer = ""
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
response = await self._connection.prompt(
|
|
316
|
+
prompt=[TextContentBlock(type="text", text=text)],
|
|
317
|
+
session_id=self._session_id,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if self.events.on_complete:
|
|
321
|
+
await self.events.on_complete()
|
|
322
|
+
|
|
323
|
+
return self._text_buffer
|
|
324
|
+
|
|
325
|
+
except Exception as e:
|
|
326
|
+
if self.events.on_error:
|
|
327
|
+
await self.events.on_error(e)
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
async def cancel(self) -> None:
|
|
331
|
+
"""Cancel the current operation."""
|
|
332
|
+
if self._connection and self._session_id:
|
|
333
|
+
await self._connection.cancel(session_id=self._session_id)
|
|
334
|
+
|
|
335
|
+
async def set_mode(self, mode: str) -> None:
|
|
336
|
+
"""Set the permission mode."""
|
|
337
|
+
if not self._connection or not self._session_id:
|
|
338
|
+
raise RuntimeError("No active session")
|
|
339
|
+
|
|
340
|
+
await self._connection.set_session_mode(
|
|
341
|
+
mode_id=mode,
|
|
342
|
+
session_id=self._session_id,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# --- Internal handlers ---
|
|
346
|
+
|
|
347
|
+
def _create_client_handler(self):
|
|
348
|
+
"""Create the client handler for receiving agent messages."""
|
|
349
|
+
client = self
|
|
350
|
+
|
|
351
|
+
class ClientHandler:
|
|
352
|
+
"""Handles incoming messages from the agent."""
|
|
353
|
+
|
|
354
|
+
async def session_update(self, session_id: str, update: Any) -> None:
|
|
355
|
+
"""Handle session updates from the agent."""
|
|
356
|
+
update_type = type(update).__name__
|
|
357
|
+
|
|
358
|
+
if isinstance(update, AgentMessageChunk):
|
|
359
|
+
content = getattr(update, "content", None)
|
|
360
|
+
if content and hasattr(content, "text"):
|
|
361
|
+
text = content.text
|
|
362
|
+
if text and text not in client._text_buffer:
|
|
363
|
+
client._text_buffer += text
|
|
364
|
+
if client.events.on_text:
|
|
365
|
+
await client.events.on_text(text)
|
|
366
|
+
|
|
367
|
+
elif isinstance(update, AgentThoughtChunk):
|
|
368
|
+
content = getattr(update, "content", None)
|
|
369
|
+
if content and hasattr(content, "text"):
|
|
370
|
+
if client.events.on_thinking:
|
|
371
|
+
await client.events.on_thinking(content.text)
|
|
372
|
+
|
|
373
|
+
elif isinstance(update, ToolCallStart):
|
|
374
|
+
if client.events.on_tool_start:
|
|
375
|
+
await client.events.on_tool_start(
|
|
376
|
+
update.tool_call_id,
|
|
377
|
+
update.title or "",
|
|
378
|
+
update.raw_input or {},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
elif isinstance(update, ToolCallProgress):
|
|
382
|
+
if client.events.on_tool_end:
|
|
383
|
+
await client.events.on_tool_end(
|
|
384
|
+
update.tool_call_id,
|
|
385
|
+
update.status or "",
|
|
386
|
+
update.raw_output,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
async def request_permission(
|
|
390
|
+
self,
|
|
391
|
+
options: list[PermissionOption],
|
|
392
|
+
session_id: str,
|
|
393
|
+
tool_call: ToolCallUpdate,
|
|
394
|
+
**kwargs: Any,
|
|
395
|
+
) -> RequestPermissionResponse:
|
|
396
|
+
"""Handle permission requests from the agent."""
|
|
397
|
+
name = tool_call.title or "Unknown"
|
|
398
|
+
raw_input = tool_call.raw_input or {}
|
|
399
|
+
option_list = [{"id": o.option_id, "name": o.name} for o in options]
|
|
400
|
+
|
|
401
|
+
# Default to allow
|
|
402
|
+
selected_id = "allow"
|
|
403
|
+
|
|
404
|
+
if client.events.on_permission:
|
|
405
|
+
selected_id = await client.events.on_permission(
|
|
406
|
+
name, raw_input, option_list
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return RequestPermissionResponse(
|
|
410
|
+
outcome={"outcome": "selected", "option_id": selected_id}
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
async def write_text_file(
|
|
414
|
+
self,
|
|
415
|
+
path: str,
|
|
416
|
+
content: str,
|
|
417
|
+
**kwargs,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Handle write file requests from the agent.
|
|
421
|
+
|
|
422
|
+
The agent requests the client to write a file to disk.
|
|
423
|
+
This enables the agent to create/modify files in the user's filesystem.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
path: The file path to write to.
|
|
427
|
+
content: The content to write.
|
|
428
|
+
"""
|
|
429
|
+
# Check if handler wants to intercept/block the write
|
|
430
|
+
if client.events.on_file_write:
|
|
431
|
+
allowed = await client.events.on_file_write(path, content)
|
|
432
|
+
if not allowed:
|
|
433
|
+
logger.info(f"File write blocked by handler: {path}")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
file_path = Path(path)
|
|
438
|
+
# Create parent directories if they don't exist
|
|
439
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
# Write the file
|
|
441
|
+
file_path.write_text(content, encoding="utf-8")
|
|
442
|
+
logger.debug(f"Wrote file: {path}")
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.error(f"Failed to write file {path}: {e}")
|
|
445
|
+
raise
|
|
446
|
+
|
|
447
|
+
async def read_text_file(
|
|
448
|
+
self,
|
|
449
|
+
path: str,
|
|
450
|
+
**kwargs,
|
|
451
|
+
) -> dict:
|
|
452
|
+
"""
|
|
453
|
+
Handle read file requests from the agent.
|
|
454
|
+
|
|
455
|
+
The agent requests the client to read a file from disk.
|
|
456
|
+
This enables the agent to access files in the user's filesystem.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
path: The file path to read.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
A dict with 'content' key containing the file content.
|
|
463
|
+
"""
|
|
464
|
+
# Check if handler wants to override the content
|
|
465
|
+
if client.events.on_file_read:
|
|
466
|
+
override = await client.events.on_file_read(path)
|
|
467
|
+
if override is not None:
|
|
468
|
+
logger.debug(f"File read overridden by handler: {path}")
|
|
469
|
+
return {"content": override}
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
file_path = Path(path)
|
|
473
|
+
if not file_path.exists():
|
|
474
|
+
logger.warning(f"File not found: {path}")
|
|
475
|
+
return {"content": "", "error": f"File not found: {path}"}
|
|
476
|
+
content = file_path.read_text(encoding="utf-8")
|
|
477
|
+
logger.debug(f"Read file: {path} ({len(content)} chars)")
|
|
478
|
+
return {"content": content}
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logger.error(f"Failed to read file {path}: {e}")
|
|
481
|
+
return {"content": "", "error": str(e)}
|
|
482
|
+
|
|
483
|
+
async def create_terminal(
|
|
484
|
+
self,
|
|
485
|
+
command: str = "",
|
|
486
|
+
args: list[str] | None = None,
|
|
487
|
+
cwd: str | None = None,
|
|
488
|
+
env: dict[str, str] | None = None,
|
|
489
|
+
**kwargs,
|
|
490
|
+
) -> dict:
|
|
491
|
+
"""
|
|
492
|
+
Create a terminal and execute a command.
|
|
493
|
+
|
|
494
|
+
The agent requests the client to run a shell command.
|
|
495
|
+
This enables command execution in the user's environment.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
command: The command to execute.
|
|
499
|
+
args: Command arguments.
|
|
500
|
+
cwd: Working directory (defaults to client cwd).
|
|
501
|
+
env: Additional environment variables.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
A dict with 'terminal_id' for tracking the process.
|
|
505
|
+
"""
|
|
506
|
+
work_dir = cwd or client.cwd
|
|
507
|
+
full_command = command
|
|
508
|
+
if args:
|
|
509
|
+
full_command = f"{command} {' '.join(args)}"
|
|
510
|
+
|
|
511
|
+
# Check if handler wants to block the terminal creation
|
|
512
|
+
if client.events.on_terminal_create:
|
|
513
|
+
allowed = await client.events.on_terminal_create(full_command, work_dir)
|
|
514
|
+
if not allowed:
|
|
515
|
+
logger.info(f"Terminal creation blocked by handler: {full_command}")
|
|
516
|
+
return {"terminal_id": "", "error": "Terminal creation blocked"}
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Create the subprocess
|
|
520
|
+
process = await asyncio.create_subprocess_shell(
|
|
521
|
+
full_command,
|
|
522
|
+
stdout=asyncio.subprocess.PIPE,
|
|
523
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
524
|
+
cwd=work_dir,
|
|
525
|
+
env={**dict(env or {})} if env else None,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
# Generate terminal ID
|
|
529
|
+
client._terminal_counter += 1
|
|
530
|
+
terminal_id = f"terminal-{client._terminal_counter}"
|
|
531
|
+
|
|
532
|
+
# Store the terminal
|
|
533
|
+
client._terminals[terminal_id] = TerminalProcess(
|
|
534
|
+
process=process,
|
|
535
|
+
command=full_command,
|
|
536
|
+
cwd=work_dir,
|
|
537
|
+
output_buffer=[],
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
logger.debug(f"Created terminal {terminal_id}: {full_command}")
|
|
541
|
+
return {"terminal_id": terminal_id}
|
|
542
|
+
|
|
543
|
+
except Exception as e:
|
|
544
|
+
logger.error(f"Failed to create terminal: {e}")
|
|
545
|
+
return {"terminal_id": "", "error": str(e)}
|
|
546
|
+
|
|
547
|
+
async def terminal_output(
|
|
548
|
+
self,
|
|
549
|
+
terminal_id: str = "",
|
|
550
|
+
**kwargs,
|
|
551
|
+
) -> dict:
|
|
552
|
+
"""
|
|
553
|
+
Get output from a terminal.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
terminal_id: The terminal to get output from.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
A dict with 'output' containing available output.
|
|
560
|
+
"""
|
|
561
|
+
terminal = client._terminals.get(terminal_id)
|
|
562
|
+
if not terminal:
|
|
563
|
+
return {"output": "", "error": f"Terminal not found: {terminal_id}"}
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
# Try to read available output (non-blocking)
|
|
567
|
+
if terminal.process.stdout:
|
|
568
|
+
try:
|
|
569
|
+
# Read with a short timeout
|
|
570
|
+
output = await asyncio.wait_for(
|
|
571
|
+
terminal.process.stdout.read(4096),
|
|
572
|
+
timeout=0.1,
|
|
573
|
+
)
|
|
574
|
+
if output:
|
|
575
|
+
decoded = output.decode("utf-8", errors="replace")
|
|
576
|
+
terminal.output_buffer.append(decoded)
|
|
577
|
+
|
|
578
|
+
# Notify handler if registered
|
|
579
|
+
if client.events.on_terminal_output:
|
|
580
|
+
await client.events.on_terminal_output(terminal_id, decoded)
|
|
581
|
+
|
|
582
|
+
return {"output": decoded}
|
|
583
|
+
except asyncio.TimeoutError:
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
# Return buffered output if no new output
|
|
587
|
+
if terminal.output_buffer:
|
|
588
|
+
return {"output": "".join(terminal.output_buffer)}
|
|
589
|
+
return {"output": ""}
|
|
590
|
+
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.error(f"Failed to get terminal output: {e}")
|
|
593
|
+
return {"output": "", "error": str(e)}
|
|
594
|
+
|
|
595
|
+
async def release_terminal(
|
|
596
|
+
self,
|
|
597
|
+
terminal_id: str = "",
|
|
598
|
+
**kwargs,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Release a terminal without killing it.
|
|
602
|
+
|
|
603
|
+
The terminal continues running but we stop tracking it.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
terminal_id: The terminal to release.
|
|
607
|
+
"""
|
|
608
|
+
if terminal_id in client._terminals:
|
|
609
|
+
logger.debug(f"Released terminal: {terminal_id}")
|
|
610
|
+
del client._terminals[terminal_id]
|
|
611
|
+
|
|
612
|
+
async def wait_for_terminal_exit(
|
|
613
|
+
self,
|
|
614
|
+
terminal_id: str = "",
|
|
615
|
+
**kwargs,
|
|
616
|
+
) -> dict:
|
|
617
|
+
"""
|
|
618
|
+
Wait for a terminal to exit and return its exit code.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
terminal_id: The terminal to wait for.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
A dict with 'exit_code'.
|
|
625
|
+
"""
|
|
626
|
+
terminal = client._terminals.get(terminal_id)
|
|
627
|
+
if not terminal:
|
|
628
|
+
return {"exit_code": -1, "error": f"Terminal not found: {terminal_id}"}
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
# Read remaining output while waiting
|
|
632
|
+
if terminal.process.stdout:
|
|
633
|
+
remaining = await terminal.process.stdout.read()
|
|
634
|
+
if remaining:
|
|
635
|
+
decoded = remaining.decode("utf-8", errors="replace")
|
|
636
|
+
terminal.output_buffer.append(decoded)
|
|
637
|
+
if client.events.on_terminal_output:
|
|
638
|
+
await client.events.on_terminal_output(terminal_id, decoded)
|
|
639
|
+
|
|
640
|
+
# Wait for process to exit
|
|
641
|
+
exit_code = await terminal.process.wait()
|
|
642
|
+
terminal.exit_code = exit_code
|
|
643
|
+
logger.debug(f"Terminal {terminal_id} exited with code {exit_code}")
|
|
644
|
+
return {"exit_code": exit_code}
|
|
645
|
+
|
|
646
|
+
except Exception as e:
|
|
647
|
+
logger.error(f"Failed to wait for terminal exit: {e}")
|
|
648
|
+
return {"exit_code": -1, "error": str(e)}
|
|
649
|
+
|
|
650
|
+
async def kill_terminal(
|
|
651
|
+
self,
|
|
652
|
+
terminal_id: str = "",
|
|
653
|
+
**kwargs,
|
|
654
|
+
) -> None:
|
|
655
|
+
"""
|
|
656
|
+
Kill a terminal process.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
terminal_id: The terminal to kill.
|
|
660
|
+
"""
|
|
661
|
+
terminal = client._terminals.get(terminal_id)
|
|
662
|
+
if not terminal:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
terminal.process.kill()
|
|
667
|
+
await terminal.process.wait()
|
|
668
|
+
logger.debug(f"Killed terminal: {terminal_id}")
|
|
669
|
+
except Exception as e:
|
|
670
|
+
logger.error(f"Failed to kill terminal: {e}")
|
|
671
|
+
|
|
672
|
+
# Remove from tracking
|
|
673
|
+
if terminal_id in client._terminals:
|
|
674
|
+
del client._terminals[terminal_id]
|
|
675
|
+
|
|
676
|
+
async def ext_method(self, method: str, params: dict) -> dict:
|
|
677
|
+
"""Handle extension methods."""
|
|
678
|
+
return {}
|
|
679
|
+
|
|
680
|
+
async def ext_notification(self, method: str, params: dict) -> None:
|
|
681
|
+
"""Handle extension notifications."""
|
|
682
|
+
pass
|
|
683
|
+
|
|
684
|
+
def on_connect(self, conn: Any) -> None:
|
|
685
|
+
"""Called when connected."""
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
return ClientHandler()
|
|
@@ -73,6 +73,7 @@ class Session:
|
|
|
73
73
|
permission_mode: PermissionMode = "default"
|
|
74
74
|
cancelled: bool = False
|
|
75
75
|
tool_use_cache: dict[str, ToolUseBlock] = field(default_factory=dict)
|
|
76
|
+
mcp_servers: dict[str, Any] = field(default_factory=dict)
|
|
76
77
|
|
|
77
78
|
|
|
78
79
|
class ClaudeAcpAgent(Agent):
|
|
@@ -141,12 +142,16 @@ class ClaudeAcpAgent(Agent):
|
|
|
141
142
|
"""Create a new Claude session."""
|
|
142
143
|
session_id = str(uuid4())
|
|
143
144
|
|
|
145
|
+
# Convert ACP MCP servers to Claude SDK format
|
|
146
|
+
sdk_mcp_servers = self._convert_mcp_servers(mcp_servers)
|
|
147
|
+
|
|
144
148
|
self._sessions[session_id] = Session(
|
|
145
149
|
session_id=session_id,
|
|
146
150
|
cwd=cwd,
|
|
151
|
+
mcp_servers=sdk_mcp_servers,
|
|
147
152
|
)
|
|
148
153
|
|
|
149
|
-
logger.info(f"New session created: {session_id} in {cwd}")
|
|
154
|
+
logger.info(f"New session created: {session_id} in {cwd} with {len(sdk_mcp_servers)} MCP servers")
|
|
150
155
|
|
|
151
156
|
return NewSessionResponse(
|
|
152
157
|
session_id=session_id,
|
|
@@ -224,11 +229,12 @@ class ClaudeAcpAgent(Agent):
|
|
|
224
229
|
|
|
225
230
|
logger.info(f"Prompt for session {session_id}: {prompt_text[:100]}...")
|
|
226
231
|
|
|
227
|
-
# Build Claude options with
|
|
232
|
+
# Build Claude options with MCP servers and permission callback
|
|
228
233
|
options = ClaudeAgentOptions(
|
|
229
234
|
cwd=session.cwd,
|
|
230
235
|
permission_mode=session.permission_mode,
|
|
231
236
|
include_partial_messages=True,
|
|
237
|
+
mcp_servers=session.mcp_servers if session.mcp_servers else {},
|
|
232
238
|
)
|
|
233
239
|
|
|
234
240
|
# Add permission callback if not bypassing permissions
|
|
@@ -237,6 +243,7 @@ class ClaudeAcpAgent(Agent):
|
|
|
237
243
|
cwd=session.cwd,
|
|
238
244
|
permission_mode=session.permission_mode,
|
|
239
245
|
include_partial_messages=True,
|
|
246
|
+
mcp_servers=session.mcp_servers if session.mcp_servers else {},
|
|
240
247
|
can_use_tool=self._create_permission_handler(session_id),
|
|
241
248
|
)
|
|
242
249
|
|
|
@@ -300,6 +307,68 @@ class ClaudeAcpAgent(Agent):
|
|
|
300
307
|
|
|
301
308
|
return "\n".join(parts)
|
|
302
309
|
|
|
310
|
+
def _convert_mcp_servers(
|
|
311
|
+
self,
|
|
312
|
+
acp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
"""Convert ACP MCP server configs to Claude SDK format."""
|
|
315
|
+
sdk_servers: dict[str, Any] = {}
|
|
316
|
+
|
|
317
|
+
for i, server in enumerate(acp_servers):
|
|
318
|
+
if isinstance(server, dict):
|
|
319
|
+
server_type = server.get("type")
|
|
320
|
+
name = server.get("name", f"mcp-server-{i}")
|
|
321
|
+
|
|
322
|
+
if server_type == "stdio":
|
|
323
|
+
# Stdio MCP server
|
|
324
|
+
sdk_servers[name] = {
|
|
325
|
+
"type": "stdio",
|
|
326
|
+
"command": server.get("command", ""),
|
|
327
|
+
"args": server.get("args", []),
|
|
328
|
+
"env": server.get("env", {}),
|
|
329
|
+
}
|
|
330
|
+
elif server_type == "sse":
|
|
331
|
+
# SSE MCP server
|
|
332
|
+
sdk_servers[name] = {
|
|
333
|
+
"type": "sse",
|
|
334
|
+
"url": server.get("url", ""),
|
|
335
|
+
}
|
|
336
|
+
elif server_type == "http":
|
|
337
|
+
# HTTP MCP server
|
|
338
|
+
sdk_servers[name] = {
|
|
339
|
+
"type": "http",
|
|
340
|
+
"url": server.get("url", ""),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
logger.info(f"Added MCP server: {name} ({server_type})")
|
|
344
|
+
|
|
345
|
+
elif hasattr(server, "type"):
|
|
346
|
+
# Pydantic model
|
|
347
|
+
name = getattr(server, "name", f"mcp-server-{i}")
|
|
348
|
+
server_type = server.type
|
|
349
|
+
|
|
350
|
+
if server_type == "stdio":
|
|
351
|
+
sdk_servers[name] = {
|
|
352
|
+
"type": "stdio",
|
|
353
|
+
"command": getattr(server, "command", ""),
|
|
354
|
+
"args": getattr(server, "args", []),
|
|
355
|
+
"env": getattr(server, "env", {}),
|
|
356
|
+
}
|
|
357
|
+
elif server_type == "sse":
|
|
358
|
+
sdk_servers[name] = {
|
|
359
|
+
"type": "sse",
|
|
360
|
+
"url": getattr(server, "url", ""),
|
|
361
|
+
}
|
|
362
|
+
elif server_type == "http":
|
|
363
|
+
sdk_servers[name] = {
|
|
364
|
+
"type": "http",
|
|
365
|
+
"url": getattr(server, "url", ""),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
logger.info(f"Added MCP server: {name} ({server_type})")
|
|
369
|
+
|
|
370
|
+
return sdk_servers
|
|
371
|
+
|
|
303
372
|
async def _handle_message(self, session_id: str, message: Message) -> None:
|
|
304
373
|
"""Convert and emit a Claude message as ACP updates."""
|
|
305
374
|
if self._conn is None:
|
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ACP Client - Connect to any ACP-compatible agent.
|
|
3
|
-
|
|
4
|
-
This module provides a client that can connect to any ACP agent
|
|
5
|
-
(like claude-code-acp, or Zed's TypeScript version) via subprocess.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import asyncio
|
|
11
|
-
import logging
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
from typing import Any, Callable, Coroutine
|
|
14
|
-
|
|
15
|
-
from acp.client.connection import ClientSideConnection
|
|
16
|
-
from acp.schema import (
|
|
17
|
-
AgentMessageChunk,
|
|
18
|
-
AgentThoughtChunk,
|
|
19
|
-
Implementation,
|
|
20
|
-
PermissionOption,
|
|
21
|
-
RequestPermissionResponse,
|
|
22
|
-
TextContentBlock,
|
|
23
|
-
ToolCallProgress,
|
|
24
|
-
ToolCallStart,
|
|
25
|
-
ToolCallUpdate,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
logger = logging.getLogger(__name__)
|
|
29
|
-
|
|
30
|
-
__all__ = ["AcpClient", "AcpClientEvents"]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass
|
|
34
|
-
class AcpClientEvents:
|
|
35
|
-
"""Event handlers for ACP client."""
|
|
36
|
-
|
|
37
|
-
on_text: Callable[[str], Coroutine[Any, Any, None]] | None = None
|
|
38
|
-
on_thinking: Callable[[str], Coroutine[Any, Any, None]] | None = None
|
|
39
|
-
on_tool_start: Callable[[str, str, dict], Coroutine[Any, Any, None]] | None = None
|
|
40
|
-
on_tool_end: Callable[[str, str, Any], Coroutine[Any, Any, None]] | None = None
|
|
41
|
-
on_permission: Callable[[str, dict, list], Coroutine[Any, Any, str]] | None = None
|
|
42
|
-
on_error: Callable[[Exception], Coroutine[Any, Any, None]] | None = None
|
|
43
|
-
on_complete: Callable[[], Coroutine[Any, Any, None]] | None = None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class AcpClient:
|
|
47
|
-
"""
|
|
48
|
-
ACP Client that connects to any ACP-compatible agent.
|
|
49
|
-
|
|
50
|
-
This client spawns an ACP agent as a subprocess and communicates
|
|
51
|
-
via the Agent Client Protocol (JSON-RPC over stdio).
|
|
52
|
-
|
|
53
|
-
Example:
|
|
54
|
-
```python
|
|
55
|
-
# Connect to claude-code-acp
|
|
56
|
-
client = AcpClient(command="claude-code-acp")
|
|
57
|
-
|
|
58
|
-
# Or connect to the TypeScript version
|
|
59
|
-
client = AcpClient(command="npx", args=["@anthropic/claude-code-acp"])
|
|
60
|
-
|
|
61
|
-
@client.on_text
|
|
62
|
-
async def handle_text(text):
|
|
63
|
-
print(text, end="")
|
|
64
|
-
|
|
65
|
-
async with client:
|
|
66
|
-
await client.prompt("Hello!")
|
|
67
|
-
```
|
|
68
|
-
"""
|
|
69
|
-
|
|
70
|
-
def __init__(
|
|
71
|
-
self,
|
|
72
|
-
command: str = "claude-code-acp",
|
|
73
|
-
args: list[str] | None = None,
|
|
74
|
-
cwd: str = ".",
|
|
75
|
-
env: dict[str, str] | None = None,
|
|
76
|
-
):
|
|
77
|
-
"""
|
|
78
|
-
Initialize the ACP client.
|
|
79
|
-
|
|
80
|
-
Args:
|
|
81
|
-
command: The ACP agent command to run.
|
|
82
|
-
args: Additional arguments for the command.
|
|
83
|
-
cwd: Working directory for the agent.
|
|
84
|
-
env: Additional environment variables.
|
|
85
|
-
"""
|
|
86
|
-
self.command = command
|
|
87
|
-
self.args = args or []
|
|
88
|
-
self.cwd = cwd
|
|
89
|
-
self.env = env
|
|
90
|
-
self.events = AcpClientEvents()
|
|
91
|
-
|
|
92
|
-
self._process: asyncio.subprocess.Process | None = None
|
|
93
|
-
self._connection: ClientSideConnection | None = None
|
|
94
|
-
self._session_id: str | None = None
|
|
95
|
-
self._text_buffer = ""
|
|
96
|
-
self._initialized = False
|
|
97
|
-
|
|
98
|
-
# --- Event decorators ---
|
|
99
|
-
|
|
100
|
-
def on_text(self, func: Callable[[str], Coroutine[Any, Any, None]]):
|
|
101
|
-
"""Register handler for text responses."""
|
|
102
|
-
self.events.on_text = func
|
|
103
|
-
return func
|
|
104
|
-
|
|
105
|
-
def on_thinking(self, func: Callable[[str], Coroutine[Any, Any, None]]):
|
|
106
|
-
"""Register handler for thinking blocks."""
|
|
107
|
-
self.events.on_thinking = func
|
|
108
|
-
return func
|
|
109
|
-
|
|
110
|
-
def on_tool_start(self, func: Callable[[str, str, dict], Coroutine[Any, Any, None]]):
|
|
111
|
-
"""Register handler for tool start events."""
|
|
112
|
-
self.events.on_tool_start = func
|
|
113
|
-
return func
|
|
114
|
-
|
|
115
|
-
def on_tool_end(self, func: Callable[[str, str, Any], Coroutine[Any, Any, None]]):
|
|
116
|
-
"""Register handler for tool end events."""
|
|
117
|
-
self.events.on_tool_end = func
|
|
118
|
-
return func
|
|
119
|
-
|
|
120
|
-
def on_permission(self, func: Callable[[str, dict, list], Coroutine[Any, Any, str]]):
|
|
121
|
-
"""
|
|
122
|
-
Register handler for permission requests.
|
|
123
|
-
|
|
124
|
-
The handler receives (name, input, options) and should return
|
|
125
|
-
the option_id to select (e.g., "allow", "reject", "allow_always").
|
|
126
|
-
"""
|
|
127
|
-
self.events.on_permission = func
|
|
128
|
-
return func
|
|
129
|
-
|
|
130
|
-
def on_error(self, func: Callable[[Exception], Coroutine[Any, Any, None]]):
|
|
131
|
-
"""Register handler for errors."""
|
|
132
|
-
self.events.on_error = func
|
|
133
|
-
return func
|
|
134
|
-
|
|
135
|
-
def on_complete(self, func: Callable[[], Coroutine[Any, Any, None]]):
|
|
136
|
-
"""Register handler for completion."""
|
|
137
|
-
self.events.on_complete = func
|
|
138
|
-
return func
|
|
139
|
-
|
|
140
|
-
# --- Connection management ---
|
|
141
|
-
|
|
142
|
-
async def connect(self) -> None:
|
|
143
|
-
"""Connect to the ACP agent."""
|
|
144
|
-
if self._process is not None:
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
# Spawn the agent process
|
|
148
|
-
self._process = await asyncio.create_subprocess_exec(
|
|
149
|
-
self.command,
|
|
150
|
-
*self.args,
|
|
151
|
-
stdin=asyncio.subprocess.PIPE,
|
|
152
|
-
stdout=asyncio.subprocess.PIPE,
|
|
153
|
-
stderr=asyncio.subprocess.PIPE,
|
|
154
|
-
cwd=self.cwd,
|
|
155
|
-
env=self.env,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
if self._process.stdin is None or self._process.stdout is None:
|
|
159
|
-
raise RuntimeError("Failed to create subprocess pipes")
|
|
160
|
-
|
|
161
|
-
# Create ACP connection
|
|
162
|
-
# Note: ClientSideConnection expects (writer, reader) - stdin is writer, stdout is reader
|
|
163
|
-
self._connection = ClientSideConnection(
|
|
164
|
-
to_client=self._create_client_handler(),
|
|
165
|
-
input_stream=self._process.stdin,
|
|
166
|
-
output_stream=self._process.stdout,
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
# Initialize the connection
|
|
170
|
-
init_response = await self._connection.initialize(
|
|
171
|
-
protocol_version=1,
|
|
172
|
-
client_info=Implementation(
|
|
173
|
-
name="claude-code-acp-client",
|
|
174
|
-
version="0.2.0",
|
|
175
|
-
),
|
|
176
|
-
)
|
|
177
|
-
logger.info(f"Connected to agent: {init_response.agent_info}")
|
|
178
|
-
self._initialized = True
|
|
179
|
-
|
|
180
|
-
async def disconnect(self) -> None:
|
|
181
|
-
"""Disconnect from the ACP agent."""
|
|
182
|
-
if self._connection:
|
|
183
|
-
await self._connection.close()
|
|
184
|
-
self._connection = None
|
|
185
|
-
|
|
186
|
-
if self._process:
|
|
187
|
-
self._process.terminate()
|
|
188
|
-
await self._process.wait()
|
|
189
|
-
self._process = None
|
|
190
|
-
|
|
191
|
-
self._initialized = False
|
|
192
|
-
self._session_id = None
|
|
193
|
-
|
|
194
|
-
async def __aenter__(self) -> "AcpClient":
|
|
195
|
-
await self.connect()
|
|
196
|
-
return self
|
|
197
|
-
|
|
198
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
199
|
-
await self.disconnect()
|
|
200
|
-
|
|
201
|
-
# --- Session management ---
|
|
202
|
-
|
|
203
|
-
async def new_session(self) -> str:
|
|
204
|
-
"""Create a new session."""
|
|
205
|
-
if not self._connection:
|
|
206
|
-
raise RuntimeError("Not connected")
|
|
207
|
-
|
|
208
|
-
response = await self._connection.new_session(
|
|
209
|
-
cwd=self.cwd,
|
|
210
|
-
mcp_servers=[],
|
|
211
|
-
)
|
|
212
|
-
self._session_id = response.session_id
|
|
213
|
-
return self._session_id
|
|
214
|
-
|
|
215
|
-
async def prompt(self, text: str) -> str:
|
|
216
|
-
"""
|
|
217
|
-
Send a prompt and receive the response.
|
|
218
|
-
|
|
219
|
-
Events will be dispatched to registered handlers as they arrive.
|
|
220
|
-
|
|
221
|
-
Args:
|
|
222
|
-
text: The prompt text to send.
|
|
223
|
-
|
|
224
|
-
Returns:
|
|
225
|
-
The full text response.
|
|
226
|
-
"""
|
|
227
|
-
if not self._connection:
|
|
228
|
-
raise RuntimeError("Not connected")
|
|
229
|
-
|
|
230
|
-
if not self._session_id:
|
|
231
|
-
await self.new_session()
|
|
232
|
-
|
|
233
|
-
self._text_buffer = ""
|
|
234
|
-
|
|
235
|
-
try:
|
|
236
|
-
response = await self._connection.prompt(
|
|
237
|
-
prompt=[TextContentBlock(type="text", text=text)],
|
|
238
|
-
session_id=self._session_id,
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
if self.events.on_complete:
|
|
242
|
-
await self.events.on_complete()
|
|
243
|
-
|
|
244
|
-
return self._text_buffer
|
|
245
|
-
|
|
246
|
-
except Exception as e:
|
|
247
|
-
if self.events.on_error:
|
|
248
|
-
await self.events.on_error(e)
|
|
249
|
-
raise
|
|
250
|
-
|
|
251
|
-
async def cancel(self) -> None:
|
|
252
|
-
"""Cancel the current operation."""
|
|
253
|
-
if self._connection and self._session_id:
|
|
254
|
-
await self._connection.cancel(session_id=self._session_id)
|
|
255
|
-
|
|
256
|
-
async def set_mode(self, mode: str) -> None:
|
|
257
|
-
"""Set the permission mode."""
|
|
258
|
-
if not self._connection or not self._session_id:
|
|
259
|
-
raise RuntimeError("No active session")
|
|
260
|
-
|
|
261
|
-
await self._connection.set_session_mode(
|
|
262
|
-
mode_id=mode,
|
|
263
|
-
session_id=self._session_id,
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
# --- Internal handlers ---
|
|
267
|
-
|
|
268
|
-
def _create_client_handler(self):
|
|
269
|
-
"""Create the client handler for receiving agent messages."""
|
|
270
|
-
client = self
|
|
271
|
-
|
|
272
|
-
class ClientHandler:
|
|
273
|
-
"""Handles incoming messages from the agent."""
|
|
274
|
-
|
|
275
|
-
async def session_update(self, session_id: str, update: Any) -> None:
|
|
276
|
-
"""Handle session updates from the agent."""
|
|
277
|
-
update_type = type(update).__name__
|
|
278
|
-
|
|
279
|
-
if isinstance(update, AgentMessageChunk):
|
|
280
|
-
content = getattr(update, "content", None)
|
|
281
|
-
if content and hasattr(content, "text"):
|
|
282
|
-
text = content.text
|
|
283
|
-
if text and text not in client._text_buffer:
|
|
284
|
-
client._text_buffer += text
|
|
285
|
-
if client.events.on_text:
|
|
286
|
-
await client.events.on_text(text)
|
|
287
|
-
|
|
288
|
-
elif isinstance(update, AgentThoughtChunk):
|
|
289
|
-
content = getattr(update, "content", None)
|
|
290
|
-
if content and hasattr(content, "text"):
|
|
291
|
-
if client.events.on_thinking:
|
|
292
|
-
await client.events.on_thinking(content.text)
|
|
293
|
-
|
|
294
|
-
elif isinstance(update, ToolCallStart):
|
|
295
|
-
if client.events.on_tool_start:
|
|
296
|
-
await client.events.on_tool_start(
|
|
297
|
-
update.tool_call_id,
|
|
298
|
-
update.title or "",
|
|
299
|
-
update.raw_input or {},
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
elif isinstance(update, ToolCallProgress):
|
|
303
|
-
if client.events.on_tool_end:
|
|
304
|
-
await client.events.on_tool_end(
|
|
305
|
-
update.tool_call_id,
|
|
306
|
-
update.status or "",
|
|
307
|
-
update.raw_output,
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
async def request_permission(
|
|
311
|
-
self,
|
|
312
|
-
options: list[PermissionOption],
|
|
313
|
-
session_id: str,
|
|
314
|
-
tool_call: ToolCallUpdate,
|
|
315
|
-
**kwargs: Any,
|
|
316
|
-
) -> RequestPermissionResponse:
|
|
317
|
-
"""Handle permission requests from the agent."""
|
|
318
|
-
name = tool_call.title or "Unknown"
|
|
319
|
-
raw_input = tool_call.raw_input or {}
|
|
320
|
-
option_list = [{"id": o.option_id, "name": o.name} for o in options]
|
|
321
|
-
|
|
322
|
-
# Default to allow
|
|
323
|
-
selected_id = "allow"
|
|
324
|
-
|
|
325
|
-
if client.events.on_permission:
|
|
326
|
-
selected_id = await client.events.on_permission(
|
|
327
|
-
name, raw_input, option_list
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
return RequestPermissionResponse(
|
|
331
|
-
outcome={"outcome": "selected", "option_id": selected_id}
|
|
332
|
-
)
|
|
333
|
-
|
|
334
|
-
async def write_text_file(self, **kwargs) -> None:
|
|
335
|
-
"""Handle write file requests (stub)."""
|
|
336
|
-
pass
|
|
337
|
-
|
|
338
|
-
async def read_text_file(self, **kwargs) -> dict:
|
|
339
|
-
"""Handle read file requests (stub)."""
|
|
340
|
-
return {"content": ""}
|
|
341
|
-
|
|
342
|
-
async def create_terminal(self, **kwargs) -> dict:
|
|
343
|
-
"""Handle terminal creation (stub)."""
|
|
344
|
-
return {"terminal_id": "stub"}
|
|
345
|
-
|
|
346
|
-
async def terminal_output(self, **kwargs) -> dict:
|
|
347
|
-
"""Handle terminal output requests (stub)."""
|
|
348
|
-
return {"output": ""}
|
|
349
|
-
|
|
350
|
-
async def release_terminal(self, **kwargs) -> None:
|
|
351
|
-
"""Handle terminal release (stub)."""
|
|
352
|
-
pass
|
|
353
|
-
|
|
354
|
-
async def wait_for_terminal_exit(self, **kwargs) -> dict:
|
|
355
|
-
"""Handle terminal exit wait (stub)."""
|
|
356
|
-
return {"exit_code": 0}
|
|
357
|
-
|
|
358
|
-
async def kill_terminal(self, **kwargs) -> None:
|
|
359
|
-
"""Handle terminal kill (stub)."""
|
|
360
|
-
pass
|
|
361
|
-
|
|
362
|
-
async def ext_method(self, method: str, params: dict) -> dict:
|
|
363
|
-
"""Handle extension methods."""
|
|
364
|
-
return {}
|
|
365
|
-
|
|
366
|
-
async def ext_notification(self, method: str, params: dict) -> None:
|
|
367
|
-
"""Handle extension notifications."""
|
|
368
|
-
pass
|
|
369
|
-
|
|
370
|
-
def on_connect(self, conn: Any) -> None:
|
|
371
|
-
"""Called when connected."""
|
|
372
|
-
pass
|
|
373
|
-
|
|
374
|
-
return ClientHandler()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|