tunacode-cli 0.0.70__py3-none-any.whl → 0.0.78.6__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (90) hide show
  1. tunacode/cli/commands/__init__.py +0 -2
  2. tunacode/cli/commands/implementations/__init__.py +0 -3
  3. tunacode/cli/commands/implementations/debug.py +2 -2
  4. tunacode/cli/commands/implementations/development.py +10 -8
  5. tunacode/cli/commands/implementations/model.py +357 -29
  6. tunacode/cli/commands/implementations/system.py +3 -2
  7. tunacode/cli/commands/implementations/template.py +0 -2
  8. tunacode/cli/commands/registry.py +8 -7
  9. tunacode/cli/commands/slash/loader.py +2 -1
  10. tunacode/cli/commands/slash/validator.py +2 -1
  11. tunacode/cli/main.py +19 -1
  12. tunacode/cli/repl.py +90 -229
  13. tunacode/cli/repl_components/command_parser.py +2 -1
  14. tunacode/cli/repl_components/error_recovery.py +8 -5
  15. tunacode/cli/repl_components/output_display.py +1 -10
  16. tunacode/cli/repl_components/tool_executor.py +1 -13
  17. tunacode/configuration/defaults.py +2 -2
  18. tunacode/configuration/key_descriptions.py +284 -0
  19. tunacode/configuration/settings.py +0 -1
  20. tunacode/constants.py +6 -42
  21. tunacode/core/agents/__init__.py +43 -2
  22. tunacode/core/agents/agent_components/__init__.py +7 -0
  23. tunacode/core/agents/agent_components/agent_config.py +162 -158
  24. tunacode/core/agents/agent_components/agent_helpers.py +31 -2
  25. tunacode/core/agents/agent_components/node_processor.py +180 -146
  26. tunacode/core/agents/agent_components/response_state.py +123 -6
  27. tunacode/core/agents/agent_components/state_transition.py +116 -0
  28. tunacode/core/agents/agent_components/streaming.py +296 -0
  29. tunacode/core/agents/agent_components/task_completion.py +19 -6
  30. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  31. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  32. tunacode/core/agents/main.py +522 -370
  33. tunacode/core/agents/main_legact.py +538 -0
  34. tunacode/core/agents/prompts.py +66 -0
  35. tunacode/core/agents/utils.py +29 -122
  36. tunacode/core/setup/__init__.py +0 -2
  37. tunacode/core/setup/config_setup.py +88 -227
  38. tunacode/core/setup/config_wizard.py +230 -0
  39. tunacode/core/setup/coordinator.py +2 -1
  40. tunacode/core/state.py +16 -64
  41. tunacode/core/token_usage/usage_tracker.py +3 -1
  42. tunacode/core/tool_authorization.py +352 -0
  43. tunacode/core/tool_handler.py +67 -60
  44. tunacode/prompts/system.xml +751 -0
  45. tunacode/services/mcp.py +97 -1
  46. tunacode/setup.py +0 -23
  47. tunacode/tools/base.py +54 -1
  48. tunacode/tools/bash.py +14 -0
  49. tunacode/tools/glob.py +4 -2
  50. tunacode/tools/grep.py +7 -17
  51. tunacode/tools/prompts/glob_prompt.xml +1 -1
  52. tunacode/tools/prompts/grep_prompt.xml +1 -0
  53. tunacode/tools/prompts/list_dir_prompt.xml +1 -1
  54. tunacode/tools/prompts/react_prompt.xml +23 -0
  55. tunacode/tools/prompts/read_file_prompt.xml +1 -1
  56. tunacode/tools/react.py +153 -0
  57. tunacode/tools/run_command.py +15 -0
  58. tunacode/types.py +14 -79
  59. tunacode/ui/completers.py +434 -50
  60. tunacode/ui/config_dashboard.py +585 -0
  61. tunacode/ui/console.py +63 -11
  62. tunacode/ui/input.py +8 -3
  63. tunacode/ui/keybindings.py +0 -18
  64. tunacode/ui/model_selector.py +395 -0
  65. tunacode/ui/output.py +40 -19
  66. tunacode/ui/panels.py +173 -49
  67. tunacode/ui/path_heuristics.py +91 -0
  68. tunacode/ui/prompt_manager.py +1 -20
  69. tunacode/ui/tool_ui.py +30 -8
  70. tunacode/utils/api_key_validation.py +93 -0
  71. tunacode/utils/config_comparator.py +340 -0
  72. tunacode/utils/models_registry.py +593 -0
  73. tunacode/utils/text_utils.py +18 -1
  74. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
  75. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
  76. tunacode/cli/commands/implementations/plan.py +0 -50
  77. tunacode/cli/commands/implementations/todo.py +0 -217
  78. tunacode/context.py +0 -71
  79. tunacode/core/setup/git_safety_setup.py +0 -186
  80. tunacode/prompts/system.md +0 -359
  81. tunacode/prompts/system.md.bak +0 -487
  82. tunacode/tools/exit_plan_mode.py +0 -273
  83. tunacode/tools/present_plan.py +0 -288
  84. tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
  85. tunacode/tools/prompts/present_plan_prompt.xml +0 -20
  86. tunacode/tools/prompts/todo_prompt.xml +0 -96
  87. tunacode/tools/todo.py +0 -456
  88. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
  89. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  90. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/services/mcp.py CHANGED
@@ -5,6 +5,8 @@ Provides Model Context Protocol (MCP) server management functionality.
5
5
  Handles MCP server initialization, configuration validation, and client connections.
6
6
  """
7
7
 
8
+ import asyncio
9
+ import logging
8
10
  import os
9
11
  from contextlib import asynccontextmanager
10
12
  from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple
@@ -16,12 +18,16 @@ from tunacode.types import MCPServers
16
18
 
17
19
  if TYPE_CHECKING:
18
20
  from mcp.client.stdio import ReadStream, WriteStream
21
+ from pydantic_ai import Agent
19
22
 
20
23
  from tunacode.core.state import StateManager
21
24
 
22
25
  # Module-level cache for MCP server instances
23
26
  _MCP_SERVER_CACHE: Dict[str, MCPServerStdio] = {}
24
27
  _MCP_CONFIG_HASH: Optional[int] = None
28
+ _MCP_SERVER_AGENTS: Dict[str, "Agent"] = {}
29
+
30
+ logger = logging.getLogger(__name__)
25
31
 
26
32
 
27
33
  class QuietMCPServer(MCPServerStdio):
@@ -58,6 +64,80 @@ class QuietMCPServer(MCPServerStdio):
58
64
  yield read_stream, write_stream
59
65
 
60
66
 
67
+ async def cleanup_mcp_servers(server_names: Optional[List[str]] = None) -> None:
68
+ """Clean up MCP server connections and clear cache.
69
+
70
+ Args:
71
+ server_names: Optional list of specific server names to clean up.
72
+ If None, all servers will be cleaned up.
73
+ """
74
+ global _MCP_SERVER_CACHE, _MCP_SERVER_AGENTS
75
+
76
+ servers_to_cleanup = server_names or list(_MCP_SERVER_CACHE.keys())
77
+
78
+ cleanup_tasks = []
79
+ for server_name in servers_to_cleanup:
80
+ if server_name in _MCP_SERVER_CACHE:
81
+ logger.debug(f"Cleaning up MCP server: {server_name}")
82
+ cleanup_tasks.append(_cleanup_single_server(server_name))
83
+
84
+ if cleanup_tasks:
85
+ try:
86
+ await asyncio.gather(*cleanup_tasks, return_exceptions=True)
87
+ except Exception as e:
88
+ logger.warning(f"Error during MCP server cleanup: {e}")
89
+
90
+ # Clear caches for cleaned up servers
91
+ if server_names is None:
92
+ # Clear all
93
+ _MCP_SERVER_CACHE.clear()
94
+ _MCP_SERVER_AGENTS.clear()
95
+ logger.debug("Cleared all MCP server caches")
96
+ else:
97
+ # Clear specific servers
98
+ for server_name in server_names:
99
+ _MCP_SERVER_CACHE.pop(server_name, None)
100
+ _MCP_SERVER_AGENTS.pop(server_name, None)
101
+ logger.debug(f"Cleared cache for MCP server: {server_name}")
102
+
103
+
104
+ async def _cleanup_single_server(server_name: str) -> None:
105
+ """Clean up a single MCP server instance.
106
+
107
+ Args:
108
+ server_name: Name of the server to clean up
109
+ """
110
+ if server_name not in _MCP_SERVER_CACHE:
111
+ return
112
+
113
+ server = _MCP_SERVER_CACHE[server_name]
114
+ agent = _MCP_SERVER_AGENTS.get(server_name)
115
+
116
+ try:
117
+ # Use agent's run_mcp_servers context manager if available
118
+ if agent and hasattr(agent, "run_mcp_servers"):
119
+ # The agent should handle proper cleanup via context manager exit
120
+ logger.debug(f"Agent cleanup for {server_name} handled by run_mcp_servers context")
121
+ else:
122
+ # Fallback: try to stop the server subprocess directly
123
+ if hasattr(server, "is_running") and server.is_running:
124
+ # MCPServerStdio doesn't expose direct cleanup, so we log this
125
+ logger.debug(f"MCP server {server_name} is running but no direct cleanup available")
126
+ except Exception as e:
127
+ logger.warning(f"Error cleaning up MCP server {server_name}: {e}")
128
+
129
+
130
+ def register_mcp_agent(server_name: str, agent: "Agent") -> None:
131
+ """Register an agent that manages MCP servers for cleanup tracking.
132
+
133
+ Args:
134
+ server_name: Name of the server configuration
135
+ agent: Agent instance that manages the server
136
+ """
137
+ _MCP_SERVER_AGENTS[server_name] = agent
138
+ logger.debug(f"Registered agent for MCP server: {server_name}")
139
+
140
+
61
141
  def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
62
142
  """Load MCP servers from configuration with caching.
63
143
 
@@ -82,7 +162,22 @@ def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
82
162
  # Return cached servers
83
163
  return list(_MCP_SERVER_CACHE.values())
84
164
 
85
- # Config changed or first load - clear cache and rebuild
165
+ # Config changed or first load - cleanup old servers and rebuild cache
166
+ if _MCP_CONFIG_HASH is not None and _MCP_SERVER_CACHE:
167
+ # Config changed - schedule cleanup of stale servers
168
+ removed_servers = [name for name in _MCP_SERVER_CACHE.keys() if name not in mcp_servers]
169
+ if removed_servers:
170
+ logger.debug(
171
+ f"Configuration changed, cleaning up removed MCP servers: {removed_servers}"
172
+ )
173
+ # Schedule async cleanup - use asyncio.create_task to avoid blocking
174
+ try:
175
+ loop = asyncio.get_event_loop()
176
+ loop.create_task(cleanup_mcp_servers(removed_servers))
177
+ except RuntimeError:
178
+ # No event loop running, schedule for later
179
+ logger.debug("No event loop available, deferring MCP server cleanup")
180
+
86
181
  _MCP_SERVER_CACHE.clear()
87
182
  _MCP_CONFIG_HASH = current_hash
88
183
 
@@ -96,6 +191,7 @@ def get_mcp_servers(state_manager: "StateManager") -> List[MCPServerStdio]:
96
191
  # Create new instance
97
192
  mcp_instance = MCPServerStdio(**conf)
98
193
  _MCP_SERVER_CACHE[server_name] = mcp_instance
194
+ logger.debug(f"Created MCP server instance: {server_name}")
99
195
 
100
196
  loaded_servers.append(_MCP_SERVER_CACHE[server_name])
101
197
  except Exception as e:
tunacode/setup.py CHANGED
@@ -5,13 +5,9 @@ Package setup and metadata configuration for the TunaCode CLI.
5
5
  Provides high-level setup functions for initializing the application and its agents.
6
6
  """
7
7
 
8
- from typing import Any, Optional
9
-
10
8
  from tunacode.core.setup import (
11
- AgentSetup,
12
9
  ConfigSetup,
13
10
  EnvironmentSetup,
14
- GitSafetySetup,
15
11
  SetupCoordinator,
16
12
  TemplateSetup,
17
13
  )
@@ -39,25 +35,6 @@ async def setup(
39
35
  coordinator.register_step(config_setup)
40
36
  coordinator.register_step(EnvironmentSetup(state_manager))
41
37
  coordinator.register_step(TemplateSetup(state_manager))
42
- coordinator.register_step(GitSafetySetup(state_manager))
43
38
 
44
39
  # Run all setup steps
45
40
  await coordinator.run_setup(force_setup=run_setup, wizard_mode=wizard_mode)
46
-
47
-
48
- async def setup_agent(agent: Optional[Any], state_manager: StateManager) -> None:
49
- """
50
- Setup the agent separately.
51
-
52
- This is called from other parts of the codebase when an agent needs to be initialized.
53
-
54
- Args:
55
- agent: The agent instance to initialize.
56
- state_manager (StateManager): The state manager instance.
57
- """
58
- if agent is not None:
59
- agent_setup = AgentSetup(state_manager, agent)
60
- if await agent_setup.should_run():
61
- await agent_setup.execute()
62
- if not await agent_setup.validate():
63
- raise RuntimeError("Agent setup failed validation")
tunacode/tools/base.py CHANGED
@@ -4,8 +4,9 @@ This module provides a base class that implements common patterns
4
4
  for all tools including error handling, UI logging, and ModelRetry support.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  from abc import ABC, abstractmethod
8
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict, List, Optional
9
10
 
10
11
  from pydantic_ai.exceptions import ModelRetry
11
12
 
@@ -27,6 +28,7 @@ class BaseTool(ABC):
27
28
  self.logger = get_logger(self.__class__.__name__)
28
29
  self._prompt_cache: Optional[str] = None
29
30
  self._context: Dict[str, Any] = {}
31
+ self._resources: List[Any] = [] # Track resources for cleanup
30
32
 
31
33
  async def execute(self, *args, **kwargs) -> ToolResult:
32
34
  """Execute the tool with error handling and logging.
@@ -35,6 +37,7 @@ class BaseTool(ABC):
35
37
  - UI logging of the operation
36
38
  - Exception handling (except ModelRetry and ToolExecutionError)
37
39
  - Consistent error message formatting
40
+ - Resource cleanup on any exception
38
41
 
39
42
  Returns:
40
43
  str: Success message
@@ -62,6 +65,9 @@ class BaseTool(ABC):
62
65
  except Exception as e:
63
66
  # Handle any other exceptions
64
67
  await self._handle_error(e, *args, **kwargs)
68
+ finally:
69
+ # Ensure resource cleanup even on success or failure
70
+ await self.cleanup()
65
71
 
66
72
  @property
67
73
  @abstractmethod
@@ -84,6 +90,53 @@ class BaseTool(ABC):
84
90
  """
85
91
  pass
86
92
 
93
+ async def cleanup(self) -> None:
94
+ """Clean up any resources created during tool execution.
95
+
96
+ This method is called automatically in a finally block after tool execution.
97
+ Subclasses should override this to implement tool-specific cleanup logic.
98
+
99
+ The base implementation handles cleanup of any resources registered via
100
+ register_resource(), attempting to call close() or cleanup() methods.
101
+ """
102
+ for resource in self._resources:
103
+ try:
104
+ if hasattr(resource, "close"):
105
+ if asyncio.iscoroutinefunction(resource.close):
106
+ await resource.close()
107
+ else:
108
+ resource.close()
109
+ elif hasattr(resource, "cleanup"):
110
+ if asyncio.iscoroutinefunction(resource.cleanup):
111
+ await resource.cleanup()
112
+ else:
113
+ resource.cleanup()
114
+ except Exception as e:
115
+ self.logger.warning(f"Failed to clean up resource {resource}: {e}")
116
+
117
+ # Clear the resource list
118
+ self._resources.clear()
119
+
120
+ def register_resource(self, resource: Any) -> None:
121
+ """Register a resource for automatic cleanup.
122
+
123
+ Resources registered here will be automatically cleaned up in the finally
124
+ block of execute(), regardless of success or failure.
125
+
126
+ Args:
127
+ resource: Any object with a close() or cleanup() method
128
+ """
129
+ self._resources.append(resource)
130
+
131
+ async def __aenter__(self):
132
+ """Enter async context manager."""
133
+ return self
134
+
135
+ async def __aexit__(self, exc_type, _, __):
136
+ """Exit async context manager and cleanup resources."""
137
+ await self.cleanup()
138
+ return False
139
+
87
140
  async def _handle_error(self, error: Exception, *args, **kwargs) -> ToolResult:
88
141
  """Handle errors by logging and raising proper exceptions.
89
142
 
tunacode/tools/bash.py CHANGED
@@ -178,6 +178,7 @@ class BashTool(BaseTool):
178
178
  # Set working directory
179
179
  exec_cwd = cwd or os.getcwd()
180
180
 
181
+ process = None
181
182
  try:
182
183
  # Execute command with timeout
183
184
  process = await asyncio.create_subprocess_shell(
@@ -238,6 +239,19 @@ class BashTool(BaseTool):
238
239
  f"Shell not found. Cannot execute command: {command}\n"
239
240
  "This typically indicates a system configuration issue."
240
241
  )
242
+ finally:
243
+ # Ensure process cleanup regardless of success or failure
244
+ if process is not None and process.returncode is None:
245
+ try:
246
+ # Multi-stage escalation: graceful → terminate → kill
247
+ try:
248
+ process.terminate()
249
+ await asyncio.wait_for(process.wait(), timeout=5.0)
250
+ except asyncio.TimeoutError:
251
+ process.kill()
252
+ await asyncio.wait_for(process.wait(), timeout=1.0)
253
+ except Exception as cleanup_error:
254
+ self.logger.warning(f"Failed to cleanup process: {cleanup_error}")
241
255
 
242
256
  def _format_output(
243
257
  self,
tunacode/tools/glob.py CHANGED
@@ -553,9 +553,11 @@ async def glob(
553
553
  directory: Directory to search in (default: current directory)
554
554
  recursive: Whether to search recursively (default: True)
555
555
  include_hidden: Whether to include hidden files/directories (default: False)
556
- exclude_dirs: Additional directories to exclude from search (default: common build/cache dirs)
556
+ exclude_dirs: Additional directories to exclude from search
557
+ (default: common build/cache dirs)
557
558
  max_results: Maximum number of results to return (default: 5000)
558
- sort_by: How to sort results - "modified", "size", "alphabetical", or "depth" (default: "modified")
559
+ sort_by: How to sort results - "modified", "size", "alphabetical", or "depth"
560
+ (default: "modified")
559
561
  case_sensitive: Whether pattern matching is case-sensitive (default: False)
560
562
  use_gitignore: Whether to respect .gitignore patterns (default: True)
561
563
 
tunacode/tools/grep.py CHANGED
@@ -242,7 +242,10 @@ Usage:
242
242
  raise ToolExecutionError(f"Unknown search type: {search_type}")
243
243
 
244
244
  # 5️⃣ Format and return results with strategy info
245
- strategy_info = f"Strategy: {search_type} (was {original_search_type}), Files: {len(candidates)}/{5000}"
245
+ strategy_info = (
246
+ f"Strategy: {search_type} (was {original_search_type}), "
247
+ f"Files: {len(candidates)}/{5000}"
248
+ )
246
249
  formatted_results = self._result_formatter.format_results(results, pattern, config)
247
250
 
248
251
  if return_format == "list":
@@ -281,7 +284,6 @@ Usage:
281
284
  def run_enhanced_ripgrep():
282
285
  """Execute ripgrep search using the new executor."""
283
286
  start_time = time.time()
284
- first_match_time = None
285
287
  results = []
286
288
 
287
289
  # Configure timeout from settings
@@ -306,17 +308,8 @@ Usage:
306
308
  context_after=config.context_lines,
307
309
  )
308
310
 
309
- # Track first match time for metrics
310
- if search_results and first_match_time is None:
311
- first_match_time = time.time() - start_time
312
-
313
- # Check if we exceeded the first match deadline
314
- if first_match_time > config.first_match_deadline:
315
- if self._config.get("debug", False):
316
- logger.debug(
317
- f"Search exceeded first match deadline: {first_match_time:.2f}s"
318
- )
319
- raise TooBroadPatternError(pattern, config.first_match_deadline)
311
+ # Ripgrep doesn't provide timing info for first match, so we rely on
312
+ # the overall timeout mechanism instead of first_match_deadline
320
313
 
321
314
  # Parse results
322
315
  for result_line in search_results:
@@ -363,10 +356,7 @@ Usage:
363
356
  )
364
357
 
365
358
  if self._config.get("debug", False):
366
- logger.debug(
367
- f"Ripgrep search completed in {total_time:.2f}s "
368
- f"(first match: {first_match_time:.2f}s if found)"
369
- )
359
+ logger.debug(f"Ripgrep search completed in {total_time:.2f}s")
370
360
 
371
361
  return results
372
362
 
@@ -6,7 +6,7 @@
6
6
  - Returns matching file paths sorted by modification time
7
7
  - Use this tool when you need to find files by name patterns
8
8
  - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
9
- - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
9
+ - You have the capability to call multiple tools in a single response. When you need multiple glob patterns, list each call up front so the read-only scheduler can execute them together as one batch.
10
10
  </description>
11
11
 
12
12
  <parameters>
@@ -11,6 +11,7 @@ A powerful search tool built on ripgrep
11
11
  - Use Task tool for open-ended searches requiring multiple rounds
12
12
  - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code)
13
13
  - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true`
14
+ - When investigating several patterns or directories at once, queue every `grep` call within the same response so they form a single batched execution.
14
15
  </description>
15
16
 
16
17
  <parameters>
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <tool_prompt>
3
3
  <description>
4
- Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search.
4
+ Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. When inspecting multiple directories, enumerate every `list_dir` call you intend to run in the same response so they execute together as a parallel batch.
5
5
  </description>
6
6
 
7
7
  <parameters>
@@ -0,0 +1,23 @@
1
+ <tool>
2
+ <description>
3
+ Record a ReAct-style think/observe timeline, retrieve it, or clear it for the current session.
4
+ </description>
5
+ <parameters>
6
+ <parameter name="action" required="true">
7
+ <type>string</type>
8
+ <description>One of think, observe, get, clear.</description>
9
+ </parameter>
10
+ <parameter name="thoughts" required="false">
11
+ <type>string</type>
12
+ <description>Reasoning text for think entries.</description>
13
+ </parameter>
14
+ <parameter name="next_action" required="false">
15
+ <type>string</type>
16
+ <description>Planned action to pair with think entries.</description>
17
+ </parameter>
18
+ <parameter name="result" required="false">
19
+ <type>string</type>
20
+ <description>Observation details for observe entries.</description>
21
+ </parameter>
22
+ </parameters>
23
+ </tool>
@@ -13,7 +13,7 @@ Usage:
13
13
  - This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
14
14
  - This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.
15
15
  - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
16
- - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
16
+ - You have the capability to call multiple tools in a single response. Enumerate every file you plan to inspect so multiple `read_file` calls can run in parallel rather than waiting for sequential turns.
17
17
  - You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
18
18
  - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
19
19
  </description>
@@ -0,0 +1,153 @@
1
+ """Lightweight ReAct-style scratchpad tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Literal
7
+
8
+ import defusedxml.ElementTree as ET
9
+ from pydantic_ai.exceptions import ModelRetry
10
+
11
+ from tunacode.core.state import StateManager
12
+ from tunacode.types import ToolResult, UILogger
13
+
14
+ from .base import BaseTool
15
+
16
+
17
+ # CLAUDE_ANCHOR[react-tool]: Minimal ReAct scratchpad tool surface
18
+ class ReactTool(BaseTool):
19
+ """Minimal ReAct scratchpad for tracking think/observe steps."""
20
+
21
+ def __init__(self, state_manager: StateManager, ui_logger: UILogger | None = None):
22
+ super().__init__(ui_logger)
23
+ self.state_manager = state_manager
24
+
25
+ @property
26
+ def tool_name(self) -> str:
27
+ return "react"
28
+
29
+ async def _execute(
30
+ self,
31
+ action: Literal["think", "observe", "get", "clear"],
32
+ thoughts: str | None = None,
33
+ next_action: str | None = None,
34
+ result: str | None = None,
35
+ ) -> ToolResult:
36
+ scratchpad = self._ensure_scratchpad()
37
+
38
+ if action == "think":
39
+ if not thoughts:
40
+ raise ModelRetry("Provide thoughts when using react think action")
41
+ if not next_action:
42
+ raise ModelRetry("Specify next_action when recording react thoughts")
43
+
44
+ entry = {
45
+ "type": "think",
46
+ "thoughts": thoughts,
47
+ "next_action": next_action,
48
+ }
49
+ self.state_manager.append_react_entry(entry)
50
+ return "Recorded think step"
51
+
52
+ if action == "observe":
53
+ if not result:
54
+ raise ModelRetry("Provide result when using react observe action")
55
+
56
+ entry = {
57
+ "type": "observe",
58
+ "result": result,
59
+ }
60
+ self.state_manager.append_react_entry(entry)
61
+ return "Recorded observation"
62
+
63
+ if action == "get":
64
+ timeline = scratchpad.get("timeline", [])
65
+ if not timeline:
66
+ return "React scratchpad is empty"
67
+
68
+ formatted = [
69
+ f"{index + 1}. {item['type']}: {self._format_entry(item)}"
70
+ for index, item in enumerate(timeline)
71
+ ]
72
+ return "\n".join(formatted)
73
+
74
+ if action == "clear":
75
+ self.state_manager.clear_react_scratchpad()
76
+ return "React scratchpad cleared"
77
+
78
+ raise ModelRetry("Invalid react action. Use one of: think, observe, get, clear")
79
+
80
+ def _format_entry(self, item: Dict[str, Any]) -> str:
81
+ if item["type"] == "think":
82
+ return f"thoughts='{item['thoughts']}', next_action='{item['next_action']}'"
83
+ if item["type"] == "observe":
84
+ return f"result='{item['result']}'"
85
+ return str(item)
86
+
87
+ def _ensure_scratchpad(self) -> dict[str, Any]:
88
+ scratchpad = self.state_manager.get_react_scratchpad()
89
+ scratchpad.setdefault("timeline", [])
90
+ return scratchpad
91
+
92
+ def _get_base_prompt(self) -> str:
93
+ prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
94
+ if prompt_file.exists():
95
+ try:
96
+ tree = ET.parse(prompt_file)
97
+ root = tree.getroot()
98
+ description = root.find("description")
99
+ if description is not None and description.text:
100
+ return description.text.strip()
101
+ except Exception:
102
+ pass
103
+ return "Use this tool to record think/observe notes and manage the react scratchpad"
104
+
105
+ def _get_parameters_schema(self) -> Dict[str, Any]:
106
+ prompt_file = Path(__file__).parent / "prompts" / "react_prompt.xml"
107
+ if prompt_file.exists():
108
+ try:
109
+ tree = ET.parse(prompt_file)
110
+ root = tree.getroot()
111
+ parameters = root.find("parameters")
112
+ if parameters is not None:
113
+ schema: Dict[str, Any] = {
114
+ "type": "object",
115
+ "properties": {},
116
+ "required": ["action"],
117
+ }
118
+ for param in parameters.findall("parameter"):
119
+ name = param.get("name")
120
+ param_type = param.find("type")
121
+ description = param.find("description")
122
+ if name and param_type is not None:
123
+ schema["properties"][name] = {
124
+ "type": param_type.text.strip(),
125
+ "description": description.text.strip()
126
+ if description is not None and description.text
127
+ else "",
128
+ }
129
+ return schema
130
+ except Exception:
131
+ pass
132
+ return {
133
+ "type": "object",
134
+ "properties": {
135
+ "action": {
136
+ "type": "string",
137
+ "description": "react operation to perform",
138
+ },
139
+ "thoughts": {
140
+ "type": "string",
141
+ "description": "Thought content for think action",
142
+ },
143
+ "next_action": {
144
+ "type": "string",
145
+ "description": "Planned next action for think action",
146
+ },
147
+ "result": {
148
+ "type": "string",
149
+ "description": "Observation message for observe action",
150
+ },
151
+ },
152
+ "required": ["action"],
153
+ }
@@ -144,6 +144,7 @@ class RunCommandTool(BaseTool):
144
144
  CommandSecurityError: If command fails security validation
145
145
  Exception: Any command execution errors
146
146
  """
147
+ process = None
147
148
  try:
148
149
  # Use secure subprocess execution with validation
149
150
  process = safe_subprocess_popen(
@@ -158,6 +159,20 @@ class RunCommandTool(BaseTool):
158
159
  except CommandSecurityError as e:
159
160
  # Security validation failed - return error without execution
160
161
  return f"Security validation failed: {str(e)}"
162
+ finally:
163
+ # Ensure process cleanup regardless of success or failure
164
+ if process is not None and process.poll() is None:
165
+ try:
166
+ # Multi-stage escalation: graceful → terminate → kill
167
+ process.terminate()
168
+ try:
169
+ process.wait(timeout=5.0)
170
+ except subprocess.TimeoutExpired:
171
+ process.kill()
172
+ process.wait(timeout=1.0)
173
+ except Exception as cleanup_error:
174
+ self.logger.warning(f"Failed to cleanup process: {cleanup_error}")
175
+
161
176
  output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT
162
177
  error = stderr.strip() or CMD_OUTPUT_NO_ERRORS
163
178
  resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip()