voice-vibecoder 2.8.1__tar.gz → 2.9.0__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.
Files changed (44) hide show
  1. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/CHANGELOG.md +8 -0
  2. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/PKG-INFO +2 -1
  3. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/pyproject.toml +2 -1
  4. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/__init__.py +1 -1
  5. voice_vibecoder-2.9.0/src/voice_vibecoder/code_providers/openclaw.py +323 -0
  6. voice_vibecoder-2.8.1/src/voice_vibecoder/code_providers/openclaw.py +0 -183
  7. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/.github/workflows/ci.yml +0 -0
  8. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/.github/workflows/release.yml +0 -0
  9. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/.gitignore +0 -0
  10. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/LICENSE +0 -0
  11. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/README.md +0 -0
  12. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/app.py +0 -0
  13. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/index.html +0 -0
  14. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/app_config.py +0 -0
  15. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/audio.py +0 -0
  16. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/cli_serve.py +0 -0
  17. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  18. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  19. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  20. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  21. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/config.py +0 -0
  22. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/instances.py +0 -0
  23. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/server.py +0 -0
  24. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/session.py +0 -0
  25. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  26. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  27. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  28. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  29. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  30. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  31. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  32. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  33. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  34. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  35. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/panels.py +0 -0
  36. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/screen.py +0 -0
  37. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/setup.py +0 -0
  38. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/state.py +0 -0
  39. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/styles.py +0 -0
  40. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  41. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  42. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  43. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  44. {voice_vibecoder-2.8.1 → voice_vibecoder-2.9.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.9.0 (2026-02-21)
6
+
7
+ ### Features
8
+
9
+ - Add WebSocket streaming support to OpenClaw provider
10
+ ([`97e107a`](https://github.com/snokam/voice-vibecoder/commit/97e107a8371a02783b51258bc75c0d64807b3964))
11
+
12
+
5
13
  ## v2.8.1 (2026-02-21)
6
14
 
7
15
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.8.1
3
+ Version: 2.9.0
4
4
  Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
5
5
  Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
6
6
  Project-URL: Repository, https://github.com/snokam/voice-vibecoder
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: claude-agent-sdk>=0.1.30
24
+ Requires-Dist: cryptography>=42.0
24
25
  Requires-Dist: numpy>=1.24
25
26
  Requires-Dist: platformdirs>=4.0
26
27
  Requires-Dist: rich>=13.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.8.1"
7
+ version = "2.9.0"
8
8
  description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Topic :: Software Development",
25
25
  ]
26
26
  dependencies = [
27
+ "cryptography>=42.0",
27
28
  "textual>=0.50",
28
29
  "rich>=13.0",
29
30
  "websockets>=12",
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.8.1"
6
+ __version__ = "2.9.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -0,0 +1,323 @@
1
+ """OpenClaw Agent code provider.
2
+
3
+ Routes coding tasks to OpenClaw Gateway via WebSocket for streaming output.
4
+ Falls back to CLI if WebSocket connection fails.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import time
16
+ import uuid
17
+ from typing import Any, Awaitable, Callable
18
+
19
+ from voice_vibecoder.code_providers import AgentOutput, AgentResult
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Try to import cryptography for Ed25519 signing
24
+ try:
25
+ from cryptography.hazmat.primitives import serialization
26
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
27
+ HAS_CRYPTO = True
28
+ except ImportError:
29
+ HAS_CRYPTO = False
30
+ logger.warning("cryptography not installed, WebSocket auth will not work")
31
+
32
+
33
+ def _load_device_keys(state_dir: str) -> tuple[str, Ed25519PrivateKey | None, str | None]:
34
+ """Load device identity from OpenClaw state directory."""
35
+ device_path = os.path.join(state_dir, "identity", "device.json")
36
+ auth_path = os.path.join(state_dir, "identity", "device-auth.json")
37
+
38
+ device_id = None
39
+ private_key = None
40
+ token = None
41
+
42
+ try:
43
+ with open(device_path) as f:
44
+ device = json.load(f)
45
+ device_id = device["deviceId"]
46
+ if HAS_CRYPTO:
47
+ pem = device["privateKeyPem"].encode()
48
+ private_key = serialization.load_pem_private_key(pem, password=None)
49
+ except Exception as e:
50
+ logger.debug("Could not load device.json: %s", e)
51
+
52
+ try:
53
+ with open(auth_path) as f:
54
+ auth = json.load(f)
55
+ if "tokens" in auth and "operator" in auth["tokens"]:
56
+ token = auth["tokens"]["operator"]["token"]
57
+ except Exception as e:
58
+ logger.debug("Could not load device-auth.json: %s", e)
59
+
60
+ return device_id, private_key, token
61
+
62
+
63
+ def _sign_challenge(private_key: Ed25519PrivateKey, nonce: str, ts: int) -> tuple[str, int]:
64
+ """Sign the gateway challenge."""
65
+ message = f"{nonce}:{ts}".encode()
66
+ signature = private_key.sign(message)
67
+ return base64.urlsafe_b64encode(signature).decode().rstrip("="), ts
68
+
69
+
70
+ class OpenClawRunner:
71
+ """AgentRunner implementation using OpenClaw Gateway WebSocket."""
72
+
73
+ def __init__(
74
+ self,
75
+ session_id: str | None = None,
76
+ agent_id: str | None = None,
77
+ env: dict[str, str] | None = None,
78
+ gateway_url: str | None = None,
79
+ state_dir: str | None = None,
80
+ **_kwargs,
81
+ ) -> None:
82
+ self._session_id = session_id or f"voice-{uuid.uuid4().hex[:8]}"
83
+ self._agent_id = agent_id
84
+ self._env = env or {}
85
+ self._gateway_url = gateway_url or os.environ.get("OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789")
86
+ self._state_dir = state_dir or os.environ.get("OPENCLAW_STATE_DIR", "/data/olaf")
87
+ self._task: asyncio.Task | None = None
88
+ self._ws = None
89
+
90
+ # Load device identity
91
+ self._device_id, self._private_key, self._device_token = _load_device_keys(self._state_dir)
92
+
93
+ async def _connect_websocket(self) -> Any:
94
+ """Connect to OpenClaw Gateway via WebSocket."""
95
+ try:
96
+ import websockets
97
+ except ImportError:
98
+ logger.warning("websockets not installed")
99
+ return None
100
+
101
+ if not self._device_id or not self._private_key:
102
+ logger.warning("No device identity found, cannot connect via WebSocket")
103
+ return None
104
+
105
+ try:
106
+ ws = await asyncio.wait_for(
107
+ websockets.connect(self._gateway_url),
108
+ timeout=5.0
109
+ )
110
+
111
+ # Wait for challenge
112
+ challenge_msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
113
+ challenge = json.loads(challenge_msg)
114
+
115
+ if challenge.get("event") != "connect.challenge":
116
+ logger.warning("Unexpected challenge: %s", challenge)
117
+ await ws.close()
118
+ return None
119
+
120
+ nonce = challenge["payload"]["nonce"]
121
+ ts = challenge["payload"]["ts"]
122
+
123
+ # Sign challenge
124
+ signature, signed_at = _sign_challenge(self._private_key, nonce, ts)
125
+
126
+ # Get public key in base64
127
+ public_key_bytes = self._private_key.public_key().public_bytes(
128
+ encoding=serialization.Encoding.Raw,
129
+ format=serialization.PublicFormat.Raw
130
+ )
131
+ public_key_b64 = base64.urlsafe_b64encode(public_key_bytes).decode().rstrip("=")
132
+
133
+ # Send connect request
134
+ connect_req = {
135
+ "type": "req",
136
+ "id": str(uuid.uuid4()),
137
+ "method": "connect",
138
+ "params": {
139
+ "minProtocol": 3,
140
+ "maxProtocol": 3,
141
+ "client": {
142
+ "id": "voice-server",
143
+ "version": "1.0.0",
144
+ "platform": "linux",
145
+ "mode": "backend"
146
+ },
147
+ "role": "operator",
148
+ "scopes": ["operator.read", "operator.write"],
149
+ "caps": [],
150
+ "commands": [],
151
+ "permissions": {},
152
+ "auth": {"deviceToken": self._device_token} if self._device_token else {},
153
+ "device": {
154
+ "id": self._device_id,
155
+ "publicKey": public_key_b64,
156
+ "signature": signature,
157
+ "signedAt": signed_at,
158
+ "nonce": nonce
159
+ }
160
+ }
161
+ }
162
+
163
+ await ws.send(json.dumps(connect_req))
164
+
165
+ # Wait for response
166
+ response_msg = await asyncio.wait_for(ws.recv(), timeout=5.0)
167
+ response = json.loads(response_msg)
168
+
169
+ if not response.get("ok"):
170
+ error = response.get("error", {}).get("message", "Unknown error")
171
+ logger.warning("Gateway connect failed: %s", error)
172
+ await ws.close()
173
+ return None
174
+
175
+ logger.info("Connected to OpenClaw Gateway via WebSocket")
176
+ return ws
177
+
178
+ except Exception as e:
179
+ logger.warning("WebSocket connection failed: %s", e)
180
+ return None
181
+
182
+ async def _run_via_websocket(
183
+ self,
184
+ ws,
185
+ message: str,
186
+ cwd: str,
187
+ session_id: str,
188
+ on_output: Callable[[AgentOutput], None] | None = None,
189
+ ) -> AgentResult | None:
190
+ """Run agent via WebSocket for streaming output."""
191
+ try:
192
+ # Send agent request
193
+ req_id = str(uuid.uuid4())
194
+ agent_req = {
195
+ "type": "req",
196
+ "id": req_id,
197
+ "method": "agent.run",
198
+ "params": {
199
+ "sessionId": session_id,
200
+ "message": message,
201
+ "cwd": cwd,
202
+ "local": True
203
+ }
204
+ }
205
+
206
+ await ws.send(json.dumps(agent_req))
207
+
208
+ result_text = ""
209
+
210
+ # Read streaming responses
211
+ while True:
212
+ msg = await asyncio.wait_for(ws.recv(), timeout=300.0)
213
+ data = json.loads(msg)
214
+
215
+ # Handle events (streaming output)
216
+ if data.get("type") == "event":
217
+ event = data.get("event", "")
218
+ payload = data.get("payload", {})
219
+
220
+ if event == "agent.output":
221
+ text = payload.get("text", "")
222
+ if text and on_output:
223
+ on_output(AgentOutput(category="text", content=text))
224
+ elif event == "agent.tool":
225
+ tool = payload.get("tool", "")
226
+ if on_output:
227
+ on_output(AgentOutput(category="tool_call", content=f"[{tool}]"))
228
+
229
+ # Handle response (completion)
230
+ elif data.get("type") == "res" and data.get("id") == req_id:
231
+ if data.get("ok"):
232
+ result_text = data.get("payload", {}).get("result", "")
233
+ else:
234
+ error = data.get("error", {}).get("message", "Unknown error")
235
+ raise RuntimeError(f"Agent error: {error}")
236
+ break
237
+
238
+ return AgentResult(text=result_text.strip(), session_id=session_id)
239
+
240
+ except Exception as e:
241
+ logger.warning("WebSocket agent run failed: %s", e)
242
+ return None
243
+
244
+ async def _run_via_cli(
245
+ self,
246
+ message: str,
247
+ cwd: str,
248
+ session_id: str,
249
+ on_output: Callable[[AgentOutput], None] | None = None,
250
+ ) -> AgentResult:
251
+ """Fallback: run agent via CLI."""
252
+ if on_output:
253
+ on_output(AgentOutput(category="text", content="🦞 Running via CLI..."))
254
+
255
+ full_message = f"[Working directory: {cwd}]\n\n{message}"
256
+
257
+ cmd = [
258
+ "openclaw", "agent",
259
+ "--session-id", session_id,
260
+ "--message", full_message,
261
+ "--local",
262
+ ]
263
+
264
+ env = os.environ.copy()
265
+ env["PATH"] = f"/home/olaf/.npm-global/bin:/home/olaf/.local/bin:{env.get('PATH', '')}"
266
+ env["OPENCLAW_STATE_DIR"] = self._state_dir
267
+ if self._env:
268
+ env.update(self._env)
269
+
270
+ process = await asyncio.create_subprocess_exec(
271
+ *cmd,
272
+ stdout=asyncio.subprocess.PIPE,
273
+ stderr=asyncio.subprocess.PIPE,
274
+ env=env,
275
+ cwd=cwd,
276
+ )
277
+
278
+ stdout, stderr = await process.communicate()
279
+ result_text = stdout.decode("utf-8", errors="replace").strip()
280
+
281
+ if process.returncode != 0 and not result_text:
282
+ raise RuntimeError(f"CLI failed: {stderr.decode()}")
283
+
284
+ return AgentResult(text=result_text, session_id=session_id)
285
+
286
+ async def run(
287
+ self,
288
+ message: str,
289
+ cwd: str,
290
+ session_id: str | None = None,
291
+ on_output: Callable[[AgentOutput], None] | None = None,
292
+ can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None = None,
293
+ ) -> AgentResult:
294
+ """Run a coding task via OpenClaw."""
295
+ self._task = asyncio.current_task()
296
+ effective_session = session_id or self._session_id
297
+
298
+ if on_output:
299
+ on_output(AgentOutput(category="text", content="🦞 Connecting to OpenClaw..."))
300
+
301
+ # Try WebSocket first
302
+ ws = await self._connect_websocket()
303
+ if ws:
304
+ try:
305
+ result = await self._run_via_websocket(ws, message, cwd, effective_session, on_output)
306
+ if result:
307
+ return result
308
+ finally:
309
+ await ws.close()
310
+
311
+ # Fallback to CLI
312
+ if on_output:
313
+ on_output(AgentOutput(category="text", content="WebSocket unavailable, using CLI..."))
314
+
315
+ return await self._run_via_cli(message, cwd, effective_session, on_output)
316
+
317
+ def cancel(self) -> bool:
318
+ """Cancel the running task."""
319
+ task = self._task
320
+ if task and not task.done():
321
+ task.cancel()
322
+ return True
323
+ return False
@@ -1,183 +0,0 @@
1
- """OpenClaw Agent code provider.
2
-
3
- Routes coding tasks to OpenClaw via CLI. Uses `openclaw agent --local`
4
- for embedded agent execution with full tool access.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import json
11
- import logging
12
- import os
13
- import re
14
- import uuid
15
- from typing import Any, Awaitable, Callable
16
-
17
- from voice_vibecoder.code_providers import AgentOutput, AgentResult
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- def _extract_tool_info(line: str) -> tuple[str, str] | None:
23
- """Extract tool name and description from agent output lines."""
24
- match = re.match(r"^\[(\w+)\]\s*(.*)$", line.strip())
25
- if match:
26
- return match.group(1), match.group(2)
27
- return None
28
-
29
-
30
- class OpenClawRunner:
31
- """AgentRunner implementation using OpenClaw CLI."""
32
-
33
- def __init__(
34
- self,
35
- session_id: str | None = None,
36
- agent_id: str | None = None,
37
- env: dict[str, str] | None = None,
38
- **_kwargs,
39
- ) -> None:
40
- self._session_id = session_id or f"voice-{uuid.uuid4().hex[:8]}"
41
- self._agent_id = agent_id
42
- self._env = env
43
- self._task: asyncio.Task | None = None
44
- self._process: asyncio.subprocess.Process | None = None
45
-
46
- async def run(
47
- self,
48
- message: str,
49
- cwd: str,
50
- session_id: str | None = None,
51
- on_output: Callable[[AgentOutput], None] | None = None,
52
- can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None = None,
53
- ) -> AgentResult:
54
- """Run a coding task via OpenClaw CLI."""
55
- self._task = asyncio.current_task()
56
- effective_session = session_id or self._session_id
57
-
58
- full_message = f"[Working directory: {cwd}]\n\n{message}"
59
-
60
- if on_output:
61
- on_output(AgentOutput(category="text", content="🦞 Running via OpenClaw..."))
62
-
63
- cmd = [
64
- "openclaw", "agent",
65
- "--session-id", effective_session,
66
- "--message", full_message,
67
- "--local",
68
- ]
69
-
70
- if self._agent_id:
71
- cmd.extend(["--agent", self._agent_id])
72
-
73
- env = os.environ.copy()
74
- env["PATH"] = f"/home/olaf/.npm-global/bin:/home/olaf/.local/bin:{env.get('PATH', '')}"
75
- if self._env:
76
- env.update(self._env)
77
-
78
- try:
79
- self._process = await asyncio.create_subprocess_exec(
80
- *cmd,
81
- stdout=asyncio.subprocess.PIPE,
82
- stderr=asyncio.subprocess.PIPE,
83
- env=env,
84
- cwd=cwd,
85
- )
86
-
87
- result_text = ""
88
-
89
- async def read_stdout():
90
- nonlocal result_text
91
- assert self._process and self._process.stdout
92
-
93
- buffer = ""
94
- while True:
95
- chunk = await self._process.stdout.read(1024)
96
- if not chunk:
97
- break
98
-
99
- text = chunk.decode("utf-8", errors="replace")
100
- buffer += text
101
-
102
- while "\n" in buffer:
103
- line, buffer = buffer.split("\n", 1)
104
- line = line.strip()
105
-
106
- if not line:
107
- continue
108
-
109
- try:
110
- data = json.loads(line)
111
- if isinstance(data, dict):
112
- if "result" in data:
113
- result_text = data["result"]
114
- elif "text" in data:
115
- result_text = data["text"]
116
- continue
117
- except json.JSONDecodeError:
118
- pass
119
-
120
- result_text += line + "\n"
121
-
122
- if on_output:
123
- tool_info = _extract_tool_info(line)
124
- if tool_info:
125
- tool_name, tool_desc = tool_info
126
- category = (
127
- "file_edit" if tool_name in ("Edit", "Write")
128
- else "bash" if tool_name == "Bash"
129
- else "tool_call"
130
- )
131
- on_output(AgentOutput(category=category, content=f"[{tool_name}] {tool_desc}"))
132
- else:
133
- on_output(AgentOutput(category="text", content=line))
134
-
135
- if buffer.strip():
136
- try:
137
- data = json.loads(buffer)
138
- if isinstance(data, dict) and "result" in data:
139
- result_text = data["result"]
140
- except json.JSONDecodeError:
141
- result_text += buffer
142
-
143
- async def read_stderr():
144
- assert self._process and self._process.stderr
145
- async for line in self._process.stderr:
146
- text = line.decode("utf-8", errors="replace").strip()
147
- if text:
148
- logger.debug("stderr: %s", text)
149
-
150
- await asyncio.gather(read_stdout(), read_stderr())
151
- await self._process.wait()
152
-
153
- if self._process.returncode != 0 and not result_text:
154
- raise RuntimeError(f"OpenClaw CLI failed with exit code {self._process.returncode}")
155
-
156
- return AgentResult(
157
- text=result_text.strip(),
158
- session_id=effective_session,
159
- )
160
-
161
- except asyncio.CancelledError:
162
- if self._process:
163
- self._process.terminate()
164
- try:
165
- await asyncio.wait_for(self._process.wait(), timeout=5.0)
166
- except asyncio.TimeoutError:
167
- self._process.kill()
168
- raise
169
- finally:
170
- self._process = None
171
-
172
- def cancel(self) -> bool:
173
- """Cancel the running task."""
174
- if self._process:
175
- self._process.terminate()
176
- return True
177
-
178
- task = self._task
179
- if task and not task.done():
180
- task.cancel()
181
- return True
182
-
183
- return False
File without changes
File without changes