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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-acp
3
- Version: 0.3.2
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` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-acp"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "ACP-compatible agent for Claude Code (Python version)"
5
5
  authors = [
6
6
  { name = "yazelin" },
@@ -11,7 +11,7 @@ from .agent import ClaudeAcpAgent
11
11
  from .client import ClaudeClient, ClaudeEvents
12
12
  from .acp_client import AcpClient, AcpClientEvents
13
13
 
14
- __version__ = "0.3.2"
14
+ __version__ = "0.3.4"
15
15
 
16
16
  __all__ = [
17
17
  "ClaudeAcpAgent",
@@ -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 permission callback for bidirectional communication
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:
@@ -156,7 +156,7 @@ wheels = [
156
156
 
157
157
  [[package]]
158
158
  name = "claude-code-acp"
159
- version = "0.3.0"
159
+ version = "0.3.4"
160
160
  source = { editable = "." }
161
161
  dependencies = [
162
162
  { name = "agent-client-protocol" },
@@ -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