henchman-ai 0.1.13__py3-none-any.whl → 0.1.15__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.
henchman/cli/app.py CHANGED
@@ -43,13 +43,17 @@ def _get_provider() -> ModelProvider:
43
43
  registry = get_default_registry()
44
44
 
45
45
  provider_name = settings.providers.default or "deepseek"
46
- provider_settings = getattr(settings.providers, provider_name, None)
46
+ provider_settings = getattr(settings.providers, provider_name, {})
47
47
 
48
- if provider_settings:
48
+ if isinstance(provider_settings, dict):
49
+ # Ensure api_key is handled correctly (backward compatibility or env var)
50
+ kwargs = provider_settings.copy()
51
+ if not kwargs.get("api_key"):
52
+ kwargs["api_key"] = os.environ.get("ANTHROPIC_API_KEY") if provider_name == "anthropic" else os.environ.get("HENCHMAN_API_KEY")
53
+
49
54
  return registry.create(
50
55
  provider_name,
51
- api_key=getattr(provider_settings, "api_key", None) or "",
52
- model=getattr(provider_settings, "model", None),
56
+ **kwargs
53
57
  )
54
58
  except Exception: # pragma: no cover
55
59
  pass
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  from henchman.cli.commands import Command, CommandContext
9
9
  from henchman.cli.commands.chat import ChatCommand
10
10
  from henchman.cli.commands.mcp import McpCommand
11
+ from henchman.cli.commands.model import ModelCommand
11
12
  from henchman.cli.commands.plan import PlanCommand
12
13
  from henchman.cli.commands.rag import RagCommand
13
14
  from henchman.cli.commands.skill import SkillCommand
@@ -57,10 +58,10 @@ class HelpCommand(Command):
57
58
  ctx.console.print(" /skill - Manage and execute learned skills")
58
59
  ctx.console.print(" /chat - Manage chat sessions (save, list, resume)")
59
60
  ctx.console.print(" /mcp - Manage MCP server connections")
61
+ ctx.console.print(" /model - Show or change model/provider")
60
62
  ctx.console.print(" /quit - Exit the CLI")
61
63
  ctx.console.print(" /clear - Clear the screen")
62
64
  ctx.console.print(" /tools - List available tools")
63
- ctx.console.print(" /model - Show or change the model")
64
65
  ctx.console.print("")
65
66
 
66
67
 
@@ -212,6 +213,7 @@ def get_builtin_commands() -> list[Command]:
212
213
  ToolsCommand(),
213
214
  ChatCommand(),
214
215
  McpCommand(),
216
+ ModelCommand(),
215
217
  PlanCommand(),
216
218
  RagCommand(),
217
219
  SkillCommand(),
@@ -0,0 +1,285 @@
1
+ """Model and provider management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING
7
+
8
+ from henchman.cli.commands import Command, CommandContext
9
+ from henchman.config import load_settings
10
+ from henchman.providers import get_default_registry
11
+
12
+ if TYPE_CHECKING:
13
+ from henchman.providers.base import ModelProvider
14
+
15
+
16
+ class ModelCommand(Command):
17
+ """Show or change the model and provider."""
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ """Command name.
22
+
23
+ Returns:
24
+ Command name string.
25
+ """
26
+ return "model"
27
+
28
+ @property
29
+ def description(self) -> str:
30
+ """Command description.
31
+
32
+ Returns:
33
+ Description string.
34
+ """
35
+ return "Show or change the model and provider"
36
+
37
+ @property
38
+ def usage(self) -> str:
39
+ """Command usage.
40
+
41
+ Returns:
42
+ Usage string.
43
+ """
44
+ return "/model [list|set <provider> [<model>]]"
45
+
46
+ async def execute(self, ctx: CommandContext) -> None:
47
+ """Execute the model command.
48
+
49
+ Args:
50
+ ctx: Command context.
51
+ """
52
+ args = ctx.args
53
+ if not args:
54
+ await self._show_current(ctx)
55
+ elif args[0] == "list":
56
+ await self._list_providers(ctx)
57
+ elif args[0] == "set" and len(args) >= 2:
58
+ await self._set_provider(ctx, args[1], args[2] if len(args) > 2 else None)
59
+ else:
60
+ ctx.console.print(f"[yellow]Usage: {self.usage}[/]")
61
+
62
+ async def _show_current(self, ctx: CommandContext) -> None:
63
+ """Show current provider and model.
64
+
65
+ Args:
66
+ ctx: Command context.
67
+ """
68
+ if not ctx.agent:
69
+ ctx.console.print("[yellow]No active agent. Cannot show current model.[/]")
70
+ return
71
+
72
+ provider = ctx.agent.provider
73
+ settings = load_settings()
74
+ registry = get_default_registry()
75
+
76
+ ctx.console.print("\n[bold blue]Current Configuration[/]\n")
77
+ ctx.console.print(f" Provider: [cyan]{provider.name}[/]")
78
+
79
+ # Show model if available
80
+ if hasattr(provider, "default_model"):
81
+ ctx.console.print(f" Model: [cyan]{provider.default_model}[/]")
82
+
83
+ # Show available providers
84
+ available = registry.list_providers()
85
+ ctx.console.print(f"\n Available providers: [dim]{', '.join(available)}[/]")
86
+ ctx.console.print(f"\n Use [cyan]/model list[/] to see all providers")
87
+ ctx.console.print(f" Use [cyan]/model set <provider> [model][/] to switch")
88
+ ctx.console.print("")
89
+
90
+ async def _list_providers(self, ctx: CommandContext) -> None:
91
+ """List all available providers and models.
92
+
93
+ Args:
94
+ ctx: Command context.
95
+ """
96
+ registry = get_default_registry()
97
+ providers = registry.list_providers()
98
+
99
+ ctx.console.print("\n[bold blue]Available Providers[/]\n")
100
+
101
+ for provider_name in sorted(providers):
102
+ try:
103
+ provider_class = registry.get(provider_name)
104
+
105
+ # Get example configuration
106
+ example_config = self._get_example_config(provider_name)
107
+
108
+ ctx.console.print(f" [cyan]{provider_name}[/]")
109
+ if hasattr(provider_class, "__doc__") and provider_class.__doc__:
110
+ doc_lines = provider_class.__doc__.strip().split('\n')
111
+ first_line = doc_lines[0].strip()
112
+ ctx.console.print(f" [dim]{first_line}[/]")
113
+
114
+ if example_config:
115
+ ctx.console.print(f" [yellow]Config:[/] {example_config}")
116
+
117
+ # Show environment variables needed
118
+ env_vars = self._get_env_vars(provider_name)
119
+ if env_vars:
120
+ ctx.console.print(f" [yellow]Env vars:[/] {env_vars}")
121
+
122
+ ctx.console.print("")
123
+ except Exception as e:
124
+ ctx.console.print(f" [red]{provider_name}[/] - Error: {e}")
125
+
126
+ async def _set_provider(
127
+ self,
128
+ ctx: CommandContext,
129
+ provider_name: str,
130
+ model_name: str | None = None
131
+ ) -> None:
132
+ """Switch to a different provider.
133
+
134
+ Args:
135
+ ctx: Command context.
136
+ provider_name: Name of the provider to switch to.
137
+ model_name: Optional model name to use.
138
+
139
+ Raises:
140
+ ValueError: If provider cannot be created.
141
+ """
142
+ if not ctx.repl:
143
+ ctx.console.print("[yellow]Cannot switch providers without REPL context.[/]")
144
+ return
145
+
146
+ try:
147
+ # Get registry and create new provider
148
+ registry = get_default_registry()
149
+
150
+ if provider_name not in registry.list_providers():
151
+ ctx.console.print(f"[red]Provider '{provider_name}' not found.[/]")
152
+ ctx.console.print(f"Available providers: {', '.join(registry.list_providers())}")
153
+ return
154
+
155
+ # Try to get API key from environment or settings
156
+ api_key = self._get_api_key_for_provider(provider_name)
157
+
158
+ # Create provider instance
159
+ provider_kwargs = {"api_key": api_key or ""}
160
+ if model_name:
161
+ provider_kwargs["model"] = model_name
162
+
163
+ new_provider = registry.create(provider_name, **provider_kwargs)
164
+
165
+ # Test the provider with a simple call
166
+ ctx.console.print(f"[dim]Testing {provider_name} connection...[/]")
167
+ try:
168
+ # Simple test to verify provider works
169
+ if hasattr(new_provider, "default_model"):
170
+ ctx.console.print(f"[green]✓ Connected to {provider_name}[/]")
171
+ if model_name:
172
+ ctx.console.print(f"[green]✓ Using model: {model_name}[/]")
173
+ else:
174
+ ctx.console.print(f"[green]✓ Using default model: {new_provider.default_model}[/]")
175
+ else:
176
+ ctx.console.print(f"[green]✓ Connected to {provider_name}[/]")
177
+ except Exception as e:
178
+ ctx.console.print(f"[yellow]⚠ Connection test failed: {e}[/]")
179
+ ctx.console.print("[yellow]Provider created but may not work correctly.[/]")
180
+
181
+ # Update the agent with new provider
182
+ old_provider = ctx.agent.provider
183
+ ctx.agent.provider = new_provider
184
+
185
+ # Update REPL's provider reference
186
+ ctx.repl.provider = new_provider
187
+
188
+ ctx.console.print(f"\n[bold green]✓ Switched from {old_provider.name} to {new_provider.name}[/]")
189
+
190
+ # Show any configuration needed
191
+ if not api_key:
192
+ env_var = self._get_env_var_name(provider_name)
193
+ ctx.console.print(f"\n[yellow]⚠ No API key found for {provider_name}[/]")
194
+ ctx.console.print(f" Set environment variable: [cyan]{env_var}=your-api-key[/]")
195
+ ctx.console.print(f" Or configure in [cyan]~/.henchman/settings.yaml[/]:")
196
+ ctx.console.print(f" providers:")
197
+ ctx.console.print(f" {provider_name}:")
198
+ ctx.console.print(f" api_key: your-api-key")
199
+
200
+ except Exception as e:
201
+ ctx.console.print(f"[red]Failed to switch provider: {e}[/]")
202
+ ctx.console.print("[dim]Check that the provider is properly configured.[/]")
203
+
204
+ def _get_example_config(self, provider_name: str) -> str:
205
+ """Get example configuration for a provider.
206
+
207
+ Args:
208
+ provider_name: Name of the provider.
209
+
210
+ Returns:
211
+ Example configuration string.
212
+ """
213
+ examples = {
214
+ "deepseek": "deepseek-chat (default), deepseek-coder",
215
+ "openai": "gpt-4-turbo, gpt-3.5-turbo",
216
+ "anthropic": "claude-3-opus, claude-3-sonnet",
217
+ "ollama": "llama2, mistral, codellama",
218
+ }
219
+ return examples.get(provider_name, "Check provider documentation")
220
+
221
+ def _get_env_vars(self, provider_name: str) -> str:
222
+ """Get environment variables needed for a provider.
223
+
224
+ Args:
225
+ provider_name: Name of the provider.
226
+
227
+ Returns:
228
+ Environment variable names.
229
+ """
230
+ env_vars = {
231
+ "deepseek": "DEEPSEEK_API_KEY",
232
+ "openai": "OPENAI_API_KEY",
233
+ "anthropic": "ANTHROPIC_API_KEY",
234
+ "ollama": "OLLAMA_HOST (optional, defaults to http://localhost:11434)",
235
+ }
236
+ return env_vars.get(provider_name, "Check provider documentation")
237
+
238
+ def _get_env_var_name(self, provider_name: str) -> str:
239
+ """Get the environment variable name for a provider's API key.
240
+
241
+ Args:
242
+ provider_name: Name of the provider.
243
+
244
+ Returns:
245
+ Environment variable name.
246
+ """
247
+ mapping = {
248
+ "deepseek": "DEEPSEEK_API_KEY",
249
+ "openai": "OPENAI_API_KEY",
250
+ "anthropic": "ANTHROPIC_API_KEY",
251
+ "ollama": "OLLAMA_API_KEY", # Ollama doesn't usually need API key
252
+ }
253
+ return mapping.get(provider_name, f"{provider_name.upper()}_API_KEY")
254
+
255
+ def _get_api_key_for_provider(self, provider_name: str) -> str | None:
256
+ """Get API key for a provider from environment or settings.
257
+
258
+ Args:
259
+ provider_name: Name of the provider.
260
+
261
+ Returns:
262
+ API key if found, None otherwise.
263
+ """
264
+ # Try environment variables first
265
+ env_var = self._get_env_var_name(provider_name)
266
+ api_key = os.environ.get(env_var)
267
+
268
+ if api_key:
269
+ return api_key
270
+
271
+ # Try generic HENCHMAN_API_KEY
272
+ api_key = os.environ.get("HENCHMAN_API_KEY")
273
+ if api_key:
274
+ return api_key
275
+
276
+ # Try settings
277
+ try:
278
+ settings = load_settings()
279
+ provider_settings = getattr(settings.providers, provider_name, None)
280
+ if provider_settings and hasattr(provider_settings, "api_key"):
281
+ return provider_settings.api_key
282
+ except Exception:
283
+ pass
284
+
285
+ return None
henchman/cli/input.py CHANGED
@@ -210,14 +210,14 @@ class KeyMonitor:
210
210
  self._in_raw_mode = True
211
211
  with self.input.attach(lambda: None):
212
212
  while not self._stop_event.is_set() and not self._exit_event.is_set() and self._suspended.is_set():
213
- # Check for keys every 100ms
213
+ # Check for keys every 10ms
214
214
  keys = self.input.read_keys()
215
215
  for key in keys:
216
216
  if key.key == Keys.Escape:
217
217
  self._stop_event.set()
218
218
  elif key.key == Keys.ControlC:
219
219
  self._exit_event.set()
220
- await asyncio.sleep(0.1)
220
+ await asyncio.sleep(0.01)
221
221
  finally:
222
222
  self._in_raw_mode = False
223
223
  # If we were suspended, wait a bit before potentially re-entering raw mode
henchman/cli/repl.py CHANGED
@@ -198,6 +198,30 @@ class Repl:
198
198
 
199
199
  return status
200
200
 
201
+ def _get_rich_status_message(self) -> str:
202
+ """Get rich status message for persistent display."""
203
+ from henchman.utils.tokens import TokenCounter
204
+
205
+ parts = []
206
+
207
+ # Plan Mode
208
+ plan_mode = self.session.plan_mode if self.session else False
209
+ parts.append("[yellow]PLAN[/]" if plan_mode else "[blue]CHAT[/]")
210
+
211
+ # Tokens
212
+ try:
213
+ msgs = self.agent.get_messages_for_api()
214
+ tokens = TokenCounter.count_messages(msgs)
215
+ parts.append(f"Tokens: ~[cyan]{tokens}[/]")
216
+ except Exception:
217
+ pass
218
+
219
+ # RAG Status
220
+ if self.rag_system and getattr(self.rag_system, "is_indexing", False):
221
+ parts.append("[cyan]RAG: Indexing...[/]")
222
+
223
+ return " | ".join(parts)
224
+
201
225
  def _register_builtin_tools(self) -> None:
202
226
  """Register built-in tools with the registry."""
203
227
  from henchman.tools.builtins import (
@@ -209,6 +233,7 @@ class Repl:
209
233
  ReadFileTool,
210
234
  ShellTool,
211
235
  WebFetchTool,
236
+ DuckDuckGoSearchTool,
212
237
  WriteFileTool,
213
238
  )
214
239
 
@@ -222,6 +247,7 @@ class Repl:
222
247
  GrepTool(),
223
248
  ShellTool(),
224
249
  WebFetchTool(),
250
+ DuckDuckGoSearchTool(),
225
251
  ]
226
252
  for tool in tools:
227
253
  self.tool_registry.register(tool)
@@ -322,6 +348,8 @@ class Repl:
322
348
  KeyboardInterrupt: If user presses Ctrl+C.
323
349
  EOFError: If user presses Ctrl+D.
324
350
  """
351
+ # Ensure a fresh line for the prompt
352
+ self.console.print()
325
353
  return await self.prompt_session.prompt_async(self.config.prompt)
326
354
 
327
355
  async def process_input(self, user_input: str) -> bool:
@@ -417,13 +445,16 @@ class Repl:
417
445
  self.current_monitor = monitor
418
446
  monitor_task = asyncio.create_task(monitor.monitor())
419
447
 
420
- # Run the agent stream processing as a separate task so we can cancel it
421
- agent_task = asyncio.create_task(
422
- self._process_agent_stream(
423
- self.agent.run(user_input),
424
- assistant_content
448
+ from rich.status import Status
449
+ with self.console.status(self._get_rich_status_message(), spinner="dots") as status_obj:
450
+ # Run the agent stream processing as a separate task so we can cancel it
451
+ agent_task = asyncio.create_task(
452
+ self._process_agent_stream(
453
+ self.agent.run(user_input),
454
+ assistant_content,
455
+ status_obj
456
+ )
425
457
  )
426
- )
427
458
 
428
459
  try:
429
460
  while not agent_task.done():
@@ -437,7 +468,7 @@ class Repl:
437
468
  agent_task.cancel()
438
469
  break
439
470
  # Small sleep to keep the loop responsive
440
- await asyncio.sleep(0.05)
471
+ await asyncio.sleep(0)
441
472
 
442
473
  if not agent_task.done():
443
474
  try:
@@ -458,7 +489,8 @@ class Repl:
458
489
  async def _process_agent_stream(
459
490
  self,
460
491
  event_stream: AsyncIterator[AgentEvent],
461
- content_collector: list[str] | None = None
492
+ content_collector: list[str] | None = None,
493
+ status_obj: Status | None = None, # New parameter
462
494
  ) -> None:
463
495
  """Process an agent event stream, handling tool calls properly.
464
496
 
@@ -499,6 +531,10 @@ class Repl:
499
531
  accumulated_content: list[str] = []
500
532
 
501
533
  async for event in event_stream:
534
+ # Update status continuously
535
+ if status_obj:
536
+ status_obj.update(self._get_rich_status_message())
537
+
502
538
  if event.type == EventType.CONTENT:
503
539
  # Stream content to console
504
540
  self.console.print(event.data, end="")
henchman/core/session.py CHANGED
@@ -353,18 +353,32 @@ class SessionManager:
353
353
  """Load a session from disk.
354
354
 
355
355
  Args:
356
- session_id: ID of session to load.
356
+ session_id: ID or ID prefix of session to load.
357
357
 
358
358
  Returns:
359
359
  Loaded Session instance.
360
360
 
361
361
  Raises:
362
- FileNotFoundError: If session doesn't exist.
362
+ FileNotFoundError: If session doesn't exist or is ambiguous.
363
363
  """
364
364
  path = self._get_session_path(session_id)
365
- if not path.exists():
365
+ if path.exists():
366
+ return Session.from_json(path.read_text())
367
+
368
+ # If not found by exact ID, try as prefix
369
+ if not self.data_dir.exists():
370
+ raise FileNotFoundError(f"Session not found: {session_id}")
371
+
372
+ matches = list(self.data_dir.glob(f"{session_id}*.json"))
373
+ if not matches:
366
374
  raise FileNotFoundError(f"Session not found: {session_id}")
367
- return Session.from_json(path.read_text())
375
+
376
+ if len(matches) > 1:
377
+ # Prefer exact match if somehow multiple match prefix but one is exact
378
+ # (though glob already failed exact match if we are here)
379
+ raise ValueError(f"Ambiguous session ID prefix: {session_id}")
380
+
381
+ return Session.from_json(matches[0].read_text())
368
382
 
369
383
  def load_by_tag(
370
384
  self,
@@ -19,11 +19,14 @@ from henchman.providers.base import (
19
19
  ToolCall,
20
20
  ToolDeclaration,
21
21
  )
22
+ from henchman.utils.ratelimit import AsyncRateLimiter
23
+ from henchman.utils.tokens import TokenCounter
22
24
 
23
25
  __all__ = ["AnthropicProvider"]
24
26
 
25
27
  # Available Claude models
26
28
  ANTHROPIC_MODELS = [
29
+ "claude-opus-4-6",
27
30
  "claude-sonnet-4-20250514",
28
31
  "claude-3-7-sonnet-20250219",
29
32
  "claude-3-5-sonnet-20241022",
@@ -50,6 +53,7 @@ class AnthropicProvider(ModelProvider):
50
53
  api_key: str | None = None,
51
54
  model: str = "claude-sonnet-4-20250514",
52
55
  max_tokens: int = 8192,
56
+ tokens_per_minute: int = 30000,
53
57
  ) -> None:
54
58
  """Initialize the Anthropic provider.
55
59
 
@@ -57,11 +61,13 @@ class AnthropicProvider(ModelProvider):
57
61
  api_key: API key for authentication. Defaults to ANTHROPIC_API_KEY env var.
58
62
  model: Default model to use.
59
63
  max_tokens: Maximum tokens in response.
64
+ tokens_per_minute: Maximum tokens per minute (rate limit).
60
65
  """
61
66
  self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY", "")
62
67
  self.default_model = model
63
68
  self.max_tokens = max_tokens
64
69
  self._client = AsyncAnthropic(api_key=self.api_key or "placeholder")
70
+ self._rate_limiter = AsyncRateLimiter(tokens_per_minute)
65
71
 
66
72
  @property
67
73
  def name(self) -> str:
@@ -187,6 +193,11 @@ class AnthropicProvider(ModelProvider):
187
193
  # All other messages must have non-empty content
188
194
  if not (message.content or '').strip():
189
195
  raise ValueError(f"Message with role '{message.role}' cannot have empty content")
196
+
197
+ # Rate limiting: wait for capacity based on input tokens
198
+ input_tokens = TokenCounter.count_messages(messages, model=self.default_model)
199
+ await self._rate_limiter.wait_for_capacity(input_tokens)
200
+
190
201
  system_prompt, formatted_messages = self._format_messages(messages)
191
202
 
192
203
  params: dict[str, Any] = {
@@ -202,6 +213,7 @@ class AnthropicProvider(ModelProvider):
202
213
  if tools:
203
214
  params["tools"] = [self._format_tool(t) for t in tools]
204
215
 
216
+ total_output_tokens = 0
205
217
  async with self._client.messages.stream(**params) as stream:
206
218
  pending_tool_calls: dict[str, dict[str, Any]] = {}
207
219
  current_tool_id: str | None = None
@@ -226,10 +238,15 @@ class AnthropicProvider(ModelProvider):
226
238
  delta = event.delta
227
239
  if delta.type == "text_delta":
228
240
  content = delta.text
241
+ total_output_tokens += TokenCounter.count_text(content, model=self.default_model)
229
242
  elif delta.type == "thinking_delta":
230
243
  thinking = delta.thinking
244
+ total_output_tokens += TokenCounter.count_text(thinking, model=self.default_model)
231
245
  elif delta.type == "input_json_delta" and current_tool_id:
232
246
  pending_tool_calls[current_tool_id]["arguments"] += delta.partial_json
247
+ # Note: we don't count JSON tokens precisely here as they come in,
248
+ # but we could count the delta text.
249
+ total_output_tokens += TokenCounter.count_text(delta.partial_json, model=self.default_model)
233
250
 
234
251
  elif event.type == "content_block_stop":
235
252
  current_tool_id = None
@@ -261,3 +278,6 @@ class AnthropicProvider(ModelProvider):
261
278
  finish_reason=finish_reason,
262
279
  thinking=thinking,
263
280
  )
281
+
282
+ # Record final usage
283
+ await self._rate_limiter.add_usage(input_tokens + total_output_tokens)
@@ -10,6 +10,7 @@ from henchman.tools.builtins.ls import LsTool
10
10
  from henchman.tools.builtins.rag_search import RagSearchTool
11
11
  from henchman.tools.builtins.shell import ShellTool
12
12
  from henchman.tools.builtins.web_fetch import WebFetchTool
13
+ from henchman.tools.builtins.web_search import DuckDuckGoSearchTool
13
14
 
14
15
  __all__ = [
15
16
  "AskUserTool",
@@ -21,5 +22,6 @@ __all__ = [
21
22
  "ReadFileTool",
22
23
  "ShellTool",
23
24
  "WebFetchTool",
25
+ "DuckDuckGoSearchTool",
24
26
  "WriteFileTool",
25
27
  ]
@@ -101,6 +101,10 @@ class ShellTool(Tool):
101
101
  success=False,
102
102
  error=f"Timeout after {timeout} seconds",
103
103
  )
104
+ except asyncio.CancelledError:
105
+ process.kill()
106
+ await process.wait()
107
+ raise
104
108
 
105
109
  # Decode output
106
110
  stdout_text = stdout.decode("utf-8", errors="replace")