openhands 1.3.0__py3-none-any.whl

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.
Files changed (43) hide show
  1. openhands-1.3.0.dist-info/METADATA +56 -0
  2. openhands-1.3.0.dist-info/RECORD +43 -0
  3. openhands-1.3.0.dist-info/WHEEL +4 -0
  4. openhands-1.3.0.dist-info/entry_points.txt +3 -0
  5. openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
  6. openhands_cli/__init__.py +9 -0
  7. openhands_cli/acp_impl/README.md +68 -0
  8. openhands_cli/acp_impl/__init__.py +1 -0
  9. openhands_cli/acp_impl/agent.py +483 -0
  10. openhands_cli/acp_impl/event.py +512 -0
  11. openhands_cli/acp_impl/main.py +21 -0
  12. openhands_cli/acp_impl/test_utils.py +174 -0
  13. openhands_cli/acp_impl/utils/__init__.py +14 -0
  14. openhands_cli/acp_impl/utils/convert.py +103 -0
  15. openhands_cli/acp_impl/utils/mcp.py +66 -0
  16. openhands_cli/acp_impl/utils/resources.py +189 -0
  17. openhands_cli/agent_chat.py +236 -0
  18. openhands_cli/argparsers/main_parser.py +78 -0
  19. openhands_cli/argparsers/serve_parser.py +31 -0
  20. openhands_cli/gui_launcher.py +224 -0
  21. openhands_cli/listeners/__init__.py +4 -0
  22. openhands_cli/listeners/pause_listener.py +83 -0
  23. openhands_cli/locations.py +14 -0
  24. openhands_cli/pt_style.py +33 -0
  25. openhands_cli/runner.py +190 -0
  26. openhands_cli/setup.py +136 -0
  27. openhands_cli/simple_main.py +71 -0
  28. openhands_cli/tui/__init__.py +6 -0
  29. openhands_cli/tui/settings/mcp_screen.py +225 -0
  30. openhands_cli/tui/settings/settings_screen.py +226 -0
  31. openhands_cli/tui/settings/store.py +132 -0
  32. openhands_cli/tui/status.py +110 -0
  33. openhands_cli/tui/tui.py +120 -0
  34. openhands_cli/tui/utils.py +14 -0
  35. openhands_cli/tui/visualizer.py +22 -0
  36. openhands_cli/user_actions/__init__.py +18 -0
  37. openhands_cli/user_actions/agent_action.py +82 -0
  38. openhands_cli/user_actions/exit_session.py +18 -0
  39. openhands_cli/user_actions/settings_action.py +176 -0
  40. openhands_cli/user_actions/types.py +17 -0
  41. openhands_cli/user_actions/utils.py +199 -0
  42. openhands_cli/utils.py +122 -0
  43. openhands_cli/version_check.py +83 -0
@@ -0,0 +1,512 @@
1
+ """Utility functions for ACP implementation."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from acp import SessionNotification
6
+ from acp.schema import (
7
+ AgentMessageChunk,
8
+ AgentPlanUpdate,
9
+ AgentThoughtChunk,
10
+ ContentToolCallContent,
11
+ FileEditToolCallContent,
12
+ PlanEntry,
13
+ PlanEntryStatus,
14
+ TerminalToolCallContent,
15
+ TextContentBlock,
16
+ ToolCallLocation,
17
+ ToolCallProgress,
18
+ ToolCallStart,
19
+ ToolCallStatus,
20
+ ToolKind,
21
+ )
22
+
23
+ from openhands.sdk import Action
24
+ from openhands.sdk.event import (
25
+ ActionEvent,
26
+ AgentErrorEvent,
27
+ Condensation,
28
+ CondensationRequest,
29
+ ConversationStateUpdateEvent,
30
+ Event,
31
+ MessageEvent,
32
+ ObservationBaseEvent,
33
+ ObservationEvent,
34
+ PauseEvent,
35
+ SystemPromptEvent,
36
+ UserRejectObservation,
37
+ )
38
+
39
+
40
+ if TYPE_CHECKING:
41
+ from acp import AgentSideConnection
42
+
43
+
44
+ from openhands.sdk import get_logger
45
+ from openhands.sdk.tool.builtins.finish import FinishAction, FinishObservation
46
+ from openhands.sdk.tool.builtins.think import ThinkAction, ThinkObservation
47
+ from openhands.tools.file_editor.definition import (
48
+ FileEditorAction,
49
+ )
50
+ from openhands.tools.task_tracker.definition import (
51
+ TaskTrackerAction,
52
+ TaskTrackerObservation,
53
+ TaskTrackerStatusType,
54
+ )
55
+ from openhands.tools.terminal.definition import TerminalAction
56
+
57
+
58
+ logger = get_logger(__name__)
59
+
60
+
61
+ def extract_action_locations(action: Action) -> list[ToolCallLocation] | None:
62
+ """Extract file locations from an action if available.
63
+
64
+ Returns a list of ToolCallLocation objects if the action contains location
65
+ information (e.g., file paths, directories), otherwise returns None.
66
+
67
+ Supports:
68
+ - file_editor: path, view_range, insert_line
69
+ - Other tools with 'path' or 'directory' attributes
70
+
71
+ Args:
72
+ action: Action to extract locations from
73
+
74
+ Returns:
75
+ List of ToolCallLocation objects or None
76
+ """
77
+ locations = []
78
+ if isinstance(action, FileEditorAction):
79
+ # Handle FileEditorAction specifically
80
+ if action.path:
81
+ location = ToolCallLocation(path=action.path)
82
+ if action.view_range and len(action.view_range) > 0:
83
+ location.line = action.view_range[0]
84
+ elif action.insert_line is not None:
85
+ location.line = action.insert_line
86
+ locations.append(location)
87
+ return locations if locations else None
88
+
89
+
90
+ def _event_visualize_to_plain(event: Event) -> str:
91
+ """Convert Rich Text object to plain string.
92
+
93
+ Args:
94
+ text: Rich Text object or string
95
+
96
+ Returns:
97
+ Plain text string
98
+ """
99
+ text = event.visualize
100
+ text = text.plain
101
+ return str(text)
102
+
103
+
104
+ class EventSubscriber:
105
+ """Subscriber for handling OpenHands events and converting them to ACP
106
+ notifications.
107
+
108
+ This class subscribes to events from an OpenHands conversation and converts
109
+ them to ACP session update notifications that are streamed back to the client.
110
+ """
111
+
112
+ def __init__(self, session_id: str, conn: "AgentSideConnection"):
113
+ """Initialize the event subscriber.
114
+
115
+ Args:
116
+ session_id: The ACP session ID
117
+ conn: The ACP connection for sending notifications
118
+ """
119
+ self.session_id = session_id
120
+ self.conn = conn
121
+
122
+ async def __call__(self, event: Event):
123
+ """Handle incoming events and convert them to ACP notifications.
124
+
125
+ Args:
126
+ event: Event to process (ActionEvent, ObservationEvent, etc.)
127
+ """
128
+ # Skip ConversationStateUpdateEvent (internal state management)
129
+ if isinstance(event, ConversationStateUpdateEvent):
130
+ return
131
+
132
+ # Handle different event types
133
+ if isinstance(event, ActionEvent):
134
+ await self._handle_action_event(event)
135
+ elif isinstance(
136
+ event, ObservationEvent | UserRejectObservation | AgentErrorEvent
137
+ ):
138
+ await self._handle_observation_event(event)
139
+ elif isinstance(event, MessageEvent):
140
+ await self._handle_message_event(event)
141
+ elif isinstance(event, SystemPromptEvent):
142
+ await self._handle_system_prompt_event(event)
143
+ elif isinstance(event, PauseEvent):
144
+ await self._handle_pause_event(event)
145
+ elif isinstance(event, Condensation):
146
+ await self._handle_condensation_event(event)
147
+ elif isinstance(event, CondensationRequest):
148
+ await self._handle_condensation_request_event(event)
149
+
150
+ async def _handle_action_event(self, event: ActionEvent):
151
+ """Handle ActionEvent: send thought as agent_message_chunk, then tool_call.
152
+
153
+ Args:
154
+ event: ActionEvent to process
155
+ """
156
+ try:
157
+ # First, send thoughts/reasoning as agent_message_chunk if available
158
+ thought_text = " ".join([t.text for t in event.thought])
159
+
160
+ if event.reasoning_content and event.reasoning_content.strip():
161
+ await self.conn.sessionUpdate(
162
+ SessionNotification(
163
+ sessionId=self.session_id,
164
+ update=AgentThoughtChunk(
165
+ sessionUpdate="agent_thought_chunk",
166
+ content=TextContentBlock(
167
+ type="text",
168
+ text="**Reasoning**:\n"
169
+ + event.reasoning_content.strip()
170
+ + "\n",
171
+ ),
172
+ ),
173
+ )
174
+ )
175
+
176
+ if thought_text.strip():
177
+ await self.conn.sessionUpdate(
178
+ SessionNotification(
179
+ sessionId=self.session_id,
180
+ update=AgentThoughtChunk(
181
+ sessionUpdate="agent_thought_chunk",
182
+ content=TextContentBlock(
183
+ type="text",
184
+ text="\n**Thought**:\n" + thought_text.strip() + "\n",
185
+ ),
186
+ ),
187
+ )
188
+ )
189
+
190
+ # Generate content for the tool call
191
+ content: (
192
+ list[
193
+ ContentToolCallContent
194
+ | FileEditToolCallContent
195
+ | TerminalToolCallContent
196
+ ]
197
+ | None
198
+ ) = None
199
+ tool_kind_mapping: dict[str, ToolKind] = {
200
+ "terminal": "execute",
201
+ "browser_use": "fetch",
202
+ "browser": "fetch",
203
+ }
204
+ tool_kind = tool_kind_mapping.get(event.tool_name, "other")
205
+ title = event.tool_name
206
+ if event.action:
207
+ action_viz = _event_visualize_to_plain(event)
208
+ if action_viz.strip():
209
+ content = [
210
+ ContentToolCallContent(
211
+ type="content",
212
+ content=TextContentBlock(
213
+ type="text",
214
+ text=action_viz,
215
+ ),
216
+ )
217
+ ]
218
+
219
+ if isinstance(event.action, FileEditorAction):
220
+ if event.action.command == "view":
221
+ tool_kind = "read"
222
+ title = f"Reading {event.action.path}"
223
+ else:
224
+ tool_kind = "edit"
225
+ title = f"Editing {event.action.path}"
226
+ elif isinstance(event.action, TerminalAction):
227
+ title = f"{event.action.command}"
228
+ elif isinstance(event.action, TaskTrackerAction):
229
+ title = "Plan updated"
230
+ elif isinstance(event.action, ThinkAction):
231
+ await self.conn.sessionUpdate(
232
+ SessionNotification(
233
+ sessionId=self.session_id,
234
+ update=AgentThoughtChunk(
235
+ sessionUpdate="agent_thought_chunk",
236
+ content=TextContentBlock(
237
+ type="text",
238
+ text=action_viz,
239
+ ),
240
+ ),
241
+ )
242
+ )
243
+ return
244
+ elif isinstance(event.action, FinishAction):
245
+ await self.conn.sessionUpdate(
246
+ SessionNotification(
247
+ sessionId=self.session_id,
248
+ update=AgentMessageChunk(
249
+ sessionUpdate="agent_message_chunk",
250
+ content=TextContentBlock(
251
+ type="text",
252
+ text=action_viz,
253
+ ),
254
+ ),
255
+ )
256
+ )
257
+ return
258
+
259
+ await self.conn.sessionUpdate(
260
+ SessionNotification(
261
+ sessionId=self.session_id,
262
+ update=ToolCallStart(
263
+ sessionUpdate="tool_call",
264
+ toolCallId=event.tool_call_id,
265
+ title=title,
266
+ kind=tool_kind,
267
+ status="in_progress",
268
+ content=content,
269
+ locations=extract_action_locations(event.action)
270
+ if event.action
271
+ else None,
272
+ rawInput=event.action.model_dump() if event.action else None,
273
+ ),
274
+ )
275
+ )
276
+ except Exception as e:
277
+ logger.debug(f"Error processing ActionEvent: {e}", exc_info=True)
278
+
279
+ async def _handle_observation_event(self, event: ObservationBaseEvent):
280
+ """Handle observation events by sending tool_call_update notification.
281
+
282
+ Handles special observation types (FileEditor, TaskTracker) with custom logic,
283
+ and generic observations with visualization text.
284
+
285
+ Args:
286
+ event: ObservationEvent, UserRejectObservation, or AgentErrorEvent
287
+ """
288
+ try:
289
+ content: ContentToolCallContent | None = None
290
+ status: ToolCallStatus = "completed"
291
+ if isinstance(event, ObservationEvent):
292
+ if isinstance(event.observation, ThinkObservation | FinishObservation):
293
+ # Think and Finish observations are handled in action event
294
+ return
295
+ # Special handling for TaskTrackerObservation
296
+ elif isinstance(event.observation, TaskTrackerObservation):
297
+ observation = event.observation
298
+ # Convert TaskItems to PlanEntries
299
+ entries: list[PlanEntry] = []
300
+ for task in observation.task_list:
301
+ # Map status: todo→pending, in_progress→in_progress,
302
+ # done→completed
303
+ status_map: dict[TaskTrackerStatusType, PlanEntryStatus] = {
304
+ "todo": "pending",
305
+ "in_progress": "in_progress",
306
+ "done": "completed",
307
+ }
308
+ task_status = status_map.get(task.status, "pending")
309
+ task_content = task.title
310
+ # NOTE: we ignore notes for now to keep it concise
311
+ # if task.notes:
312
+ # task_content += f"\n{task.notes}"
313
+ entries.append(
314
+ PlanEntry(
315
+ content=task_content,
316
+ status=task_status,
317
+ priority="medium", # TaskItem doesn't have priority
318
+ )
319
+ )
320
+
321
+ # Send AgentPlanUpdate
322
+ await self.conn.sessionUpdate(
323
+ SessionNotification(
324
+ sessionId=self.session_id,
325
+ update=AgentPlanUpdate(
326
+ sessionUpdate="plan",
327
+ entries=entries,
328
+ ),
329
+ )
330
+ )
331
+ else:
332
+ observation = event.observation
333
+ # Use ContentToolCallContent for view commands and other operations
334
+ viz_text = _event_visualize_to_plain(event)
335
+ if viz_text.strip():
336
+ content = ContentToolCallContent(
337
+ type="content",
338
+ content=TextContentBlock(
339
+ type="text",
340
+ text=viz_text,
341
+ ),
342
+ )
343
+ else:
344
+ # For UserRejectObservation or AgentErrorEvent
345
+ status = "failed"
346
+ viz_text = _event_visualize_to_plain(event)
347
+ if viz_text.strip():
348
+ content = ContentToolCallContent(
349
+ type="content",
350
+ content=TextContentBlock(
351
+ type="text",
352
+ text=viz_text,
353
+ ),
354
+ )
355
+ # Send tool_call_update for all observation types
356
+ await self.conn.sessionUpdate(
357
+ SessionNotification(
358
+ sessionId=self.session_id,
359
+ update=ToolCallProgress(
360
+ sessionUpdate="tool_call_update",
361
+ toolCallId=event.tool_call_id,
362
+ status=status,
363
+ content=[content] if content else None,
364
+ rawOutput=event.model_dump(),
365
+ ),
366
+ )
367
+ )
368
+ except Exception as e:
369
+ logger.debug(f"Error processing observation event: {e}", exc_info=True)
370
+
371
+ async def _handle_message_event(self, event: MessageEvent):
372
+ """Handle MessageEvent by sending AgentMessageChunk or UserMessageChunk.
373
+
374
+ Args:
375
+ event: MessageEvent from agent or user
376
+ """
377
+ try:
378
+ # Get visualization text
379
+ viz_text = _event_visualize_to_plain(event)
380
+ if not viz_text.strip():
381
+ return
382
+
383
+ # Determine which type of message chunk to send based on role
384
+ if event.llm_message.role == "user":
385
+ # NOTE: Zed UI will render user messages when it is sent
386
+ # if we update it again, they will be duplicated
387
+ pass
388
+ else: # assistant or other roles
389
+ await self.conn.sessionUpdate(
390
+ SessionNotification(
391
+ sessionId=self.session_id,
392
+ update=AgentMessageChunk(
393
+ sessionUpdate="agent_message_chunk",
394
+ content=TextContentBlock(
395
+ type="text",
396
+ text=viz_text,
397
+ ),
398
+ ),
399
+ )
400
+ )
401
+ except Exception as e:
402
+ logger.debug(f"Error processing MessageEvent: {e}", exc_info=True)
403
+
404
+ async def _handle_system_prompt_event(self, event: SystemPromptEvent):
405
+ """Handle SystemPromptEvent by sending as AgentThoughtChunk.
406
+
407
+ System prompts are internal setup, so we send them as thought chunks
408
+ to indicate they're part of the agent's internal state.
409
+
410
+ Args:
411
+ event: SystemPromptEvent
412
+ """
413
+ try:
414
+ viz_text = _event_visualize_to_plain(event)
415
+ if not viz_text.strip():
416
+ return
417
+
418
+ await self.conn.sessionUpdate(
419
+ SessionNotification(
420
+ sessionId=self.session_id,
421
+ update=AgentThoughtChunk(
422
+ sessionUpdate="agent_thought_chunk",
423
+ content=TextContentBlock(
424
+ type="text",
425
+ text=viz_text,
426
+ ),
427
+ ),
428
+ )
429
+ )
430
+ except Exception as e:
431
+ logger.debug(f"Error processing SystemPromptEvent: {e}", exc_info=True)
432
+
433
+ async def _handle_pause_event(self, event: PauseEvent):
434
+ """Handle PauseEvent by sending as AgentThoughtChunk.
435
+
436
+ Args:
437
+ event: PauseEvent
438
+ """
439
+ try:
440
+ viz_text = _event_visualize_to_plain(event)
441
+ if not viz_text.strip():
442
+ return
443
+
444
+ await self.conn.sessionUpdate(
445
+ SessionNotification(
446
+ sessionId=self.session_id,
447
+ update=AgentThoughtChunk(
448
+ sessionUpdate="agent_thought_chunk",
449
+ content=TextContentBlock(
450
+ type="text",
451
+ text=viz_text,
452
+ ),
453
+ ),
454
+ )
455
+ )
456
+ except Exception as e:
457
+ logger.debug(f"Error processing PauseEvent: {e}", exc_info=True)
458
+
459
+ async def _handle_condensation_event(self, event: Condensation):
460
+ """Handle Condensation by sending as AgentThoughtChunk.
461
+
462
+ Condensation events indicate memory management is happening, which is
463
+ useful for the user to know but doesn't require special UI treatment.
464
+
465
+ Args:
466
+ event: Condensation event
467
+ """
468
+ try:
469
+ viz_text = _event_visualize_to_plain(event)
470
+ if not viz_text.strip():
471
+ return
472
+
473
+ await self.conn.sessionUpdate(
474
+ SessionNotification(
475
+ sessionId=self.session_id,
476
+ update=AgentThoughtChunk(
477
+ sessionUpdate="agent_thought_chunk",
478
+ content=TextContentBlock(
479
+ type="text",
480
+ text=viz_text,
481
+ ),
482
+ ),
483
+ )
484
+ )
485
+ except Exception as e:
486
+ logger.debug(f"Error processing Condensation: {e}", exc_info=True)
487
+
488
+ async def _handle_condensation_request_event(self, event: CondensationRequest):
489
+ """Handle CondensationRequest by sending as AgentThoughtChunk.
490
+
491
+ Args:
492
+ event: CondensationRequest event
493
+ """
494
+ try:
495
+ viz_text = _event_visualize_to_plain(event)
496
+ if not viz_text.strip():
497
+ return
498
+
499
+ await self.conn.sessionUpdate(
500
+ SessionNotification(
501
+ sessionId=self.session_id,
502
+ update=AgentThoughtChunk(
503
+ sessionUpdate="agent_thought_chunk",
504
+ content=TextContentBlock(
505
+ type="text",
506
+ text=viz_text,
507
+ ),
508
+ ),
509
+ )
510
+ )
511
+ except Exception as e:
512
+ logger.debug(f"Error processing CondensationRequest: {e}", exc_info=True)
@@ -0,0 +1,21 @@
1
+ """OpenHands ACP Main Entry Point."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+
7
+ from .agent import run_acp_server
8
+
9
+
10
+ # Configure logging
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
14
+ handlers=[logging.StreamHandler(sys.stderr)],
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ asyncio.run(run_acp_server())
@@ -0,0 +1,174 @@
1
+ """
2
+ Utilities for testing JSON-RPC servers (ACP testing).
3
+
4
+ This module provides reusable functions for testing JSON-RPC servers,
5
+ specifically designed for testing the Agent Client Protocol (ACP) implementation.
6
+
7
+ Usage:
8
+ from openhands_cli.acp_impl.test_utils import test_jsonrpc_messages
9
+
10
+ success, responses = test_jsonrpc_messages(
11
+ "./dist/openhands",
12
+ ["acp"],
13
+ messages,
14
+ timeout_per_message=5.0,
15
+ verbose=True,
16
+ )
17
+ """
18
+
19
+ import json
20
+ import select
21
+ import subprocess
22
+ import time
23
+ from typing import Any
24
+
25
+
26
+ def send_jsonrpc_and_wait(
27
+ proc: subprocess.Popen,
28
+ message: dict[str, Any],
29
+ timeout: float = 5.0,
30
+ ) -> tuple[bool, dict[str, Any] | None, str]:
31
+ """
32
+ Send a JSON-RPC message and wait for response.
33
+
34
+ Args:
35
+ proc: The subprocess to communicate with
36
+ message: JSON-RPC message dict
37
+ timeout: Timeout in seconds
38
+
39
+ Returns:
40
+ tuple of (success: bool, response: dict | None, error_message: str)
41
+ """
42
+ if not proc.stdin or not proc.stdout:
43
+ return False, None, "stdin or stdout not available"
44
+
45
+ # Send message
46
+ try:
47
+ msg_line = json.dumps(message) + "\n"
48
+ proc.stdin.write(msg_line)
49
+ proc.stdin.flush()
50
+ except Exception as e:
51
+ return False, None, f"Failed to send message: {e}"
52
+
53
+ # Wait for response
54
+ deadline = time.time() + timeout
55
+ while time.time() < deadline:
56
+ if proc.poll() is not None:
57
+ return False, None, "Process terminated unexpectedly"
58
+
59
+ rlist, _, _ = select.select([proc.stdout], [], [], 0.5)
60
+ if rlist:
61
+ line = proc.stdout.readline()
62
+ if line:
63
+ try:
64
+ response = json.loads(line)
65
+ return True, response, ""
66
+ except json.JSONDecodeError as e:
67
+ return (
68
+ False,
69
+ None,
70
+ f"Failed to parse JSON: {e}\nRaw: {line.strip()}",
71
+ )
72
+
73
+ return False, None, "Response timeout"
74
+
75
+
76
+ def validate_jsonrpc_response(response: dict[str, Any]) -> tuple[bool, str]:
77
+ """
78
+ Validate a JSON-RPC response for errors.
79
+
80
+ Args:
81
+ response: The JSON-RPC response dict
82
+
83
+ Returns:
84
+ tuple of (is_valid: bool, error_message: str)
85
+ """
86
+ if "error" in response:
87
+ error = response["error"]
88
+ code = error.get("code", "unknown")
89
+ message = error.get("message", "unknown")
90
+ return False, f"JSON-RPC Error {code}: {message}"
91
+
92
+ if "result" not in response:
93
+ return False, "Response missing 'result' field"
94
+
95
+ return True, ""
96
+
97
+
98
+ def test_jsonrpc_messages(
99
+ executable_path: str,
100
+ args: list[str],
101
+ messages: list[dict[str, Any]],
102
+ timeout_per_message: float = 5.0,
103
+ verbose: bool = True,
104
+ ) -> tuple[bool, list[dict[str, Any]]]:
105
+ """
106
+ Test a JSON-RPC server by sending messages and validating responses.
107
+
108
+ Args:
109
+ executable_path: Path to the executable
110
+ args: Command-line arguments for the executable
111
+ messages: List of JSON-RPC messages to send
112
+ timeout_per_message: Timeout in seconds for each message
113
+ verbose: Print detailed output
114
+
115
+ Returns:
116
+ tuple of (success: bool, responses: list[dict])
117
+ """
118
+ if verbose:
119
+ print(f"šŸš€ Starting: {executable_path} {' '.join(args)}")
120
+
121
+ proc = subprocess.Popen(
122
+ [executable_path] + args,
123
+ stdin=subprocess.PIPE,
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.DEVNULL, # Don't pipe stderr to avoid buffer blocking
126
+ text=True,
127
+ bufsize=1,
128
+ )
129
+
130
+ all_responses = []
131
+ all_passed = True
132
+
133
+ try:
134
+ for i, msg in enumerate(messages, 1):
135
+ if verbose:
136
+ print(
137
+ f"\nšŸ“¤ Message {i}/{len(messages)}: {msg.get('method', 'unknown')}"
138
+ )
139
+
140
+ success, response, error = send_jsonrpc_and_wait(
141
+ proc, msg, timeout_per_message
142
+ )
143
+
144
+ if not success:
145
+ if verbose:
146
+ print(f"āŒ {error}")
147
+ all_passed = False
148
+ continue
149
+
150
+ if response:
151
+ all_responses.append(response)
152
+
153
+ if verbose:
154
+ print(f"šŸ“„ Response: {json.dumps(response)}")
155
+
156
+ is_valid, error_msg = validate_jsonrpc_response(response)
157
+ if not is_valid:
158
+ if verbose:
159
+ print(f"āŒ {error_msg}")
160
+ all_passed = False
161
+ elif verbose:
162
+ print("āœ… Success")
163
+
164
+ return all_passed, all_responses
165
+
166
+ finally:
167
+ if verbose:
168
+ print("\nšŸ›‘ Terminating process...")
169
+ proc.terminate()
170
+ try:
171
+ proc.wait(timeout=5)
172
+ except subprocess.TimeoutExpired:
173
+ proc.kill()
174
+ proc.wait()