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