ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -8,9 +8,11 @@ via the KillBash tool.
8
8
  import asyncio
9
9
  import concurrent.futures
10
10
  import contextlib
11
+ import os
11
12
  import threading
12
13
  import time
13
14
  import uuid
15
+ import weakref
14
16
  from dataclasses import dataclass, field
15
17
  from typing import Any, Dict, List, Optional
16
18
 
@@ -41,67 +43,271 @@ class BackgroundTask:
41
43
  done_event: asyncio.Event = field(default_factory=asyncio.Event)
42
44
 
43
45
 
44
- _tasks: Dict[str, BackgroundTask] = {}
45
- _tasks_lock = threading.Lock()
46
- _background_loop: Optional[asyncio.AbstractEventLoop] = None
47
- _background_thread: Optional[threading.Thread] = None
48
- _loop_lock = threading.Lock()
49
- _shutdown_registered = False
46
+ DEFAULT_TASK_TTL_SEC = float(os.getenv("RIPPERDOC_BASH_TASK_TTL_SEC", "3600"))
50
47
 
51
48
 
52
- def _safe_log_exception(message: str, **extra: Any) -> None:
53
- """Log an exception but never let logging failures bubble up."""
54
- try:
55
- logger.exception(message, extra=extra)
56
- except (OSError, RuntimeError, ValueError):
57
- pass
49
+ class BackgroundShellManager:
50
+ """Manager for background shell tasks with proper lifecycle control.
58
51
 
52
+ This class encapsulates all global state for background shell management,
53
+ providing better testability and proper resource cleanup.
54
+ """
59
55
 
60
- def _ensure_background_loop() -> asyncio.AbstractEventLoop:
61
- """Create (or return) a dedicated loop for background processes."""
62
- global _background_loop, _background_thread
56
+ _instance: Optional["BackgroundShellManager"] = None
57
+ _instance_lock = threading.Lock()
58
+
59
+ def __init__(self) -> None:
60
+ """Initialize the manager. Use get_instance() for singleton access."""
61
+ self._tasks: Dict[str, BackgroundTask] = {}
62
+ self._tasks_lock = threading.Lock()
63
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
64
+ self._thread: Optional[threading.Thread] = None
65
+ self._loop_lock = threading.Lock()
66
+ self._shutdown_event = threading.Event()
67
+ self._shutdown_registered = False
68
+ self._is_shutting_down = False
69
+
70
+ @classmethod
71
+ def get_instance(cls) -> "BackgroundShellManager":
72
+ """Get or create the singleton instance."""
73
+ if cls._instance is None:
74
+ with cls._instance_lock:
75
+ if cls._instance is None:
76
+ cls._instance = cls()
77
+ return cls._instance
78
+
79
+ @classmethod
80
+ def reset_instance(cls) -> None:
81
+ """Reset the singleton instance. Useful for testing.
82
+
83
+ This method shuts down the current instance and clears it,
84
+ allowing a fresh instance to be created on next access.
85
+ """
86
+ with cls._instance_lock:
87
+ if cls._instance is not None:
88
+ cls._instance.shutdown()
89
+ cls._instance = None
90
+
91
+ @classmethod
92
+ def _set_instance_for_testing(cls, instance: Optional["BackgroundShellManager"]) -> None:
93
+ """Set a custom instance for testing purposes."""
94
+ with cls._instance_lock:
95
+ cls._instance = instance
96
+
97
+ @property
98
+ def tasks(self) -> Dict[str, BackgroundTask]:
99
+ """Access to tasks dict (for internal use)."""
100
+ return self._tasks
101
+
102
+ @property
103
+ def tasks_lock(self) -> threading.Lock:
104
+ """Access to tasks lock (for internal use)."""
105
+ return self._tasks_lock
106
+
107
+ def ensure_loop(self) -> asyncio.AbstractEventLoop:
108
+ """Create (or return) a dedicated loop for background processes."""
109
+ if self._loop and self._loop.is_running():
110
+ return self._loop
111
+
112
+ with self._loop_lock:
113
+ if self._loop and self._loop.is_running():
114
+ return self._loop
115
+
116
+ loop = asyncio.new_event_loop()
117
+ ready = threading.Event()
118
+ shutdown_event = self._shutdown_event
119
+
120
+ def _run_loop() -> None:
121
+ asyncio.set_event_loop(loop)
122
+ ready.set()
123
+ try:
124
+ loop.run_forever()
125
+ finally:
126
+ # Ensure cleanup happens even if loop is stopped abruptly
127
+ shutdown_event.set()
128
+
129
+ # Use non-daemon thread to ensure atexit handlers can complete
130
+ thread = threading.Thread(
131
+ target=_run_loop,
132
+ name="ripperdoc-bg-loop",
133
+ daemon=False, # Non-daemon for proper shutdown
134
+ )
135
+ thread.start()
136
+ ready.wait()
137
+
138
+ self._loop = loop
139
+ self._thread = thread
140
+ self._register_shutdown_hook()
141
+ return loop
142
+
143
+ def _register_shutdown_hook(self) -> None:
144
+ """Register atexit handler for cleanup."""
145
+ if self._shutdown_registered:
146
+ return
147
+
148
+ # Use weakref to avoid preventing garbage collection
149
+ manager_ref = weakref.ref(self)
150
+
151
+ def _shutdown_callback() -> None:
152
+ manager = manager_ref()
153
+ if manager is not None:
154
+ manager.shutdown()
155
+
156
+ atexit.register(_shutdown_callback)
157
+ self._shutdown_registered = True
158
+
159
+ def submit_to_loop(self, coro: Any) -> concurrent.futures.Future:
160
+ """Run a coroutine on the background loop and return a thread-safe future."""
161
+ loop = self.ensure_loop()
162
+ return asyncio.run_coroutine_threadsafe(coro, loop)
163
+
164
+ def shutdown(self, force: bool = False) -> None:
165
+ """Stop background tasks/loop to avoid resource leaks.
166
+
167
+ Args:
168
+ force: If True, use minimal timeouts for faster exit.
169
+ """
170
+ if self._is_shutting_down:
171
+ # If already shutting down and force is requested, just mark done
172
+ if force:
173
+ self._shutdown_event.set()
174
+ return
175
+ self._is_shutting_down = True
176
+
177
+ loop = self._loop
178
+ thread = self._thread
179
+
180
+ if not loop or loop.is_closed():
181
+ self._loop = None
182
+ self._thread = None
183
+ self._is_shutting_down = False
184
+ return
185
+
186
+ # Use shorter timeouts for faster exit
187
+ async_timeout = 0.5 if force else 2.0
188
+ join_timeout = 0.5 if force else 1.0
63
189
 
64
- if _background_loop and _background_loop.is_running():
65
- return _background_loop
190
+ try:
191
+ if loop.is_running():
192
+ try:
193
+ fut = asyncio.run_coroutine_threadsafe(
194
+ self._shutdown_loop_async(loop, force=force), loop
195
+ )
196
+ fut.result(timeout=async_timeout)
197
+ except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
198
+ logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
199
+ try:
200
+ loop.call_soon_threadsafe(loop.stop)
201
+ except (RuntimeError, OSError):
202
+ logger.debug("Failed to stop background loop", exc_info=True)
203
+ else:
204
+ # If loop isn't running, try to run cleanup synchronously
205
+ try:
206
+ loop.run_until_complete(self._shutdown_loop_async(loop, force=force))
207
+ except RuntimeError:
208
+ pass # Loop may already be closed
209
+ finally:
210
+ if thread and thread.is_alive():
211
+ thread.join(timeout=join_timeout)
212
+ # If thread is still alive after timeout, don't block further
213
+ if thread.is_alive():
214
+ logger.debug("Background thread did not stop in time, continuing shutdown")
215
+ with contextlib.suppress(Exception):
216
+ if not loop.is_closed():
217
+ loop.close()
218
+ self._loop = None
219
+ self._thread = None
220
+ self._shutdown_event.set()
221
+ self._is_shutting_down = False
222
+
223
+ async def _shutdown_loop_async(
224
+ self, loop: asyncio.AbstractEventLoop, force: bool = False
225
+ ) -> None:
226
+ """Drain running background processes before stopping the loop.
227
+
228
+ Args:
229
+ loop: The event loop to shutdown.
230
+ force: If True, use minimal timeouts for faster exit.
231
+ """
232
+ with self._tasks_lock:
233
+ tasks = list(self._tasks.values())
234
+ self._tasks.clear()
235
+
236
+ # Use shorter timeouts when force is True
237
+ wait_timeout = 0.3 if force else 1.5
238
+ kill_timeout = 0.2 if force else 0.5
239
+
240
+ for task in tasks:
241
+ try:
242
+ task.killed = True
243
+ with contextlib.suppress(ProcessLookupError):
244
+ task.process.kill()
245
+ try:
246
+ with contextlib.suppress(ProcessLookupError):
247
+ await asyncio.wait_for(task.process.wait(), timeout=wait_timeout)
248
+ except asyncio.TimeoutError:
249
+ with contextlib.suppress(ProcessLookupError, PermissionError):
250
+ task.process.kill()
251
+ with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
252
+ await asyncio.wait_for(task.process.wait(), timeout=kill_timeout)
253
+ task.exit_code = task.process.returncode or -1
254
+ except (OSError, RuntimeError, asyncio.CancelledError) as exc:
255
+ if not isinstance(exc, asyncio.CancelledError):
256
+ _safe_log_exception(
257
+ "Error shutting down background task",
258
+ task_id=task.id,
259
+ command=task.command,
260
+ )
261
+ finally:
262
+ await _finalize_reader_tasks(task.reader_tasks, timeout=0.3 if force else 1.0)
263
+ task.done_event.set()
264
+
265
+ current = asyncio.current_task()
266
+ pending = [t for t in asyncio.all_tasks(loop) if t is not current]
267
+ for pending_task in pending:
268
+ pending_task.cancel()
269
+ if pending:
270
+ with contextlib.suppress(Exception):
271
+ await asyncio.gather(*pending, return_exceptions=True)
66
272
 
67
- with _loop_lock:
68
- if _background_loop and _background_loop.is_running():
69
- return _background_loop
273
+ with contextlib.suppress(Exception):
274
+ await loop.shutdown_asyncgens()
70
275
 
71
- loop = asyncio.new_event_loop()
72
- ready = threading.Event()
73
276
 
74
- def _run_loop() -> None:
75
- asyncio.set_event_loop(loop)
76
- ready.set()
77
- loop.run_forever()
277
+ # Module-level functions that delegate to the singleton manager
278
+ # These maintain backward compatibility with existing code
78
279
 
79
- thread = threading.Thread(
80
- target=_run_loop,
81
- name="ripperdoc-bg-loop",
82
- daemon=True,
83
- )
84
- thread.start()
85
- ready.wait()
280
+ def _get_manager() -> BackgroundShellManager:
281
+ """Get the singleton manager instance."""
282
+ return BackgroundShellManager.get_instance()
86
283
 
87
- _background_loop = loop
88
- _background_thread = thread
89
- _register_shutdown_hook()
90
- return loop
91
284
 
285
+ def _get_tasks_lock() -> threading.Lock:
286
+ """Get the tasks lock from the manager."""
287
+ return _get_manager().tasks_lock
92
288
 
93
- def _register_shutdown_hook() -> None:
94
- global _shutdown_registered
95
- if _shutdown_registered:
96
- return
97
- atexit.register(shutdown_background_shell)
98
- _shutdown_registered = True
289
+
290
+ def _get_tasks() -> Dict[str, BackgroundTask]:
291
+ """Get the tasks dict from the manager."""
292
+ return _get_manager().tasks
293
+
294
+
295
+ def _safe_log_exception(message: str, **extra: Any) -> None:
296
+ """Log an exception but never let logging failures bubble up."""
297
+ try:
298
+ logger.exception(message, extra=extra)
299
+ except (OSError, RuntimeError, ValueError):
300
+ pass
301
+
302
+
303
+ def _ensure_background_loop() -> asyncio.AbstractEventLoop:
304
+ """Create (or return) a dedicated loop for background processes."""
305
+ return _get_manager().ensure_loop()
99
306
 
100
307
 
101
308
  def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
102
309
  """Run a coroutine on the background loop and return a thread-safe future."""
103
- loop = _ensure_background_loop()
104
- return asyncio.run_coroutine_threadsafe(coro, loop)
310
+ return _get_manager().submit_to_loop(coro)
105
311
 
106
312
 
107
313
  async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
@@ -112,7 +318,7 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
112
318
  if not chunk:
113
319
  break
114
320
  text = chunk.decode("utf-8", errors="replace")
115
- with _tasks_lock:
321
+ with _get_tasks_lock():
116
322
  sink.append(text)
117
323
  except (OSError, RuntimeError, asyncio.CancelledError) as exc:
118
324
  if isinstance(exc, asyncio.CancelledError):
@@ -147,25 +353,26 @@ async def _monitor_task(task: BackgroundTask) -> None:
147
353
  await asyncio.wait_for(task.process.wait(), timeout=task.timeout)
148
354
  else:
149
355
  await task.process.wait()
150
- with _tasks_lock:
356
+ with _get_tasks_lock():
151
357
  task.exit_code = task.process.returncode
152
358
  except asyncio.TimeoutError:
153
359
  logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
154
- with _tasks_lock:
360
+ with _get_tasks_lock():
155
361
  task.timed_out = True
156
362
  task.process.kill()
157
363
  await task.process.wait()
158
- with _tasks_lock:
364
+ with _get_tasks_lock():
159
365
  task.exit_code = -1
160
366
  except asyncio.CancelledError:
161
367
  return
162
368
  except (OSError, RuntimeError, ProcessLookupError) as exc:
163
369
  logger.warning(
164
370
  "Error monitoring background task: %s: %s",
165
- type(exc).__name__, exc,
371
+ type(exc).__name__,
372
+ exc,
166
373
  extra={"task_id": task.id, "command": task.command},
167
374
  )
168
- with _tasks_lock:
375
+ with _get_tasks_lock():
169
376
  task.exit_code = -1
170
377
  finally:
171
378
  # Ensure readers are finished before marking done.
@@ -195,8 +402,8 @@ async def _start_background_command(
195
402
  start_time=_loop_time(),
196
403
  timeout=timeout,
197
404
  )
198
- with _tasks_lock:
199
- _tasks[task_id] = record
405
+ with _get_tasks_lock():
406
+ _get_tasks()[task_id] = record
200
407
 
201
408
  # Start stream pumps and monitor task.
202
409
  if process.stdout:
@@ -246,11 +453,12 @@ def get_background_status(task_id: str, consume: bool = True) -> dict:
246
453
 
247
454
  If consume is True, buffered stdout/stderr are cleared after reading.
248
455
  """
249
- with _tasks_lock:
250
- if task_id not in _tasks:
456
+ tasks = _get_tasks()
457
+ with _get_tasks_lock():
458
+ if task_id not in tasks:
251
459
  raise KeyError(f"No background task found with id '{task_id}'")
252
460
 
253
- task = _tasks[task_id]
461
+ task = tasks[task_id]
254
462
  stdout = "".join(task.stdout_chunks)
255
463
  stderr = "".join(task.stderr_chunks)
256
464
 
@@ -276,8 +484,9 @@ async def kill_background_task(task_id: str) -> bool:
276
484
  KILL_WAIT_SECONDS = 2.0
277
485
 
278
486
  async def _kill(task_id: str) -> bool:
279
- with _tasks_lock:
280
- task = _tasks.get(task_id)
487
+ tasks = _get_tasks()
488
+ with _get_tasks_lock():
489
+ task = tasks.get(task_id)
281
490
  if not task:
282
491
  return False
283
492
 
@@ -295,7 +504,7 @@ async def kill_background_task(task_id: str) -> bool:
295
504
  task.process.kill()
296
505
  await asyncio.wait_for(task.process.wait(), timeout=1.0)
297
506
 
298
- with _tasks_lock:
507
+ with _get_tasks_lock():
299
508
  task.exit_code = task.process.returncode or -1
300
509
  return True
301
510
  finally:
@@ -308,82 +517,45 @@ async def kill_background_task(task_id: str) -> bool:
308
517
 
309
518
  def list_background_tasks() -> List[str]:
310
519
  """Return known background task ids."""
311
- with _tasks_lock:
312
- return list(_tasks.keys())
313
-
314
-
315
- async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
316
- """Drain running background processes before stopping the loop."""
317
- with _tasks_lock:
318
- tasks = list(_tasks.values())
319
- _tasks.clear()
320
-
321
- for task in tasks:
322
- try:
323
- task.killed = True
324
- with contextlib.suppress(ProcessLookupError):
325
- task.process.kill()
326
- try:
327
- with contextlib.suppress(ProcessLookupError):
328
- await asyncio.wait_for(task.process.wait(), timeout=1.5)
329
- except asyncio.TimeoutError:
330
- with contextlib.suppress(ProcessLookupError, PermissionError):
331
- task.process.kill()
332
- with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
333
- await asyncio.wait_for(task.process.wait(), timeout=0.5)
334
- task.exit_code = task.process.returncode or -1
335
- except (OSError, RuntimeError, asyncio.CancelledError) as exc:
336
- if not isinstance(exc, asyncio.CancelledError):
337
- _safe_log_exception(
338
- "Error shutting down background task",
339
- task_id=task.id,
340
- command=task.command,
341
- )
342
- finally:
343
- await _finalize_reader_tasks(task.reader_tasks)
344
- task.done_event.set()
345
-
346
- current = asyncio.current_task()
347
- pending = [t for t in asyncio.all_tasks(loop) if t is not current]
348
- for pending_task in pending:
349
- pending_task.cancel()
350
- if pending:
351
- with contextlib.suppress(Exception):
352
- await asyncio.gather(*pending, return_exceptions=True)
353
-
354
- with contextlib.suppress(Exception):
355
- await loop.shutdown_asyncgens()
356
-
357
-
358
- def shutdown_background_shell() -> None:
359
- """Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
360
- global _background_loop, _background_thread
520
+ _prune_background_tasks()
521
+ with _get_tasks_lock():
522
+ return list(_get_tasks().keys())
523
+
524
+
525
+ def _prune_background_tasks(max_age_seconds: Optional[float] = None) -> int:
526
+ """Remove finished background tasks older than the TTL."""
527
+ ttl = DEFAULT_TASK_TTL_SEC if max_age_seconds is None else max_age_seconds
528
+ if ttl is None or ttl <= 0:
529
+ return 0
530
+ now = _loop_time()
531
+ removed = 0
532
+ tasks = _get_tasks()
533
+ with _get_tasks_lock():
534
+ for task_id, task in list(tasks.items()):
535
+ if task.exit_code is None:
536
+ continue
537
+ age = (now - task.start_time) if task.start_time else 0.0
538
+ if age > ttl:
539
+ tasks.pop(task_id, None)
540
+ removed += 1
541
+ return removed
542
+
543
+
544
+ def shutdown_background_shell(force: bool = False) -> None:
545
+ """Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings.
546
+
547
+ This function maintains backward compatibility by delegating to the manager.
548
+
549
+ Args:
550
+ force: If True, use minimal timeouts for faster exit.
551
+ """
552
+ _get_manager().shutdown(force=force)
361
553
 
362
- loop = _background_loop
363
- thread = _background_thread
364
554
 
365
- if not loop or loop.is_closed():
366
- _background_loop = None
367
- _background_thread = None
368
- return
555
+ def reset_background_shell_for_testing() -> None:
556
+ """Reset all background shell state. Useful for testing.
369
557
 
370
- try:
371
- if loop.is_running():
372
- try:
373
- fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
374
- fut.result(timeout=3)
375
- except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
376
- logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
377
- try:
378
- loop.call_soon_threadsafe(loop.stop)
379
- except (RuntimeError, OSError):
380
- logger.debug("Failed to stop background loop", exc_info=True)
381
- else:
382
- loop.run_until_complete(_shutdown_loop(loop))
383
- finally:
384
- if thread and thread.is_alive():
385
- thread.join(timeout=2)
386
- with contextlib.suppress(Exception):
387
- loop.close()
388
- _background_loop = None
389
- _background_thread = None
558
+ This function shuts down the current manager instance and clears it,
559
+ allowing a fresh instance to be created on next access.
560
+ """
561
+ BackgroundShellManager.reset_instance()
@@ -40,7 +40,7 @@ class BashOutputTool(Tool[BashOutputInput, BashOutputData]):
40
40
  async def description(self) -> str:
41
41
  return "Read output and status from a background bash command started with BashTool(run_in_background=True)."
42
42
 
43
- async def prompt(self, safe_mode: bool = False) -> str:
43
+ async def prompt(self, yolo_mode: bool = False) -> str:
44
44
  return "Fetch buffered output and status for a background bash task by id."
45
45
 
46
46
  @property