hanzo-mcp 0.7.6__py3-none-any.whl → 0.8.0__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 hanzo-mcp might be problematic. Click here for more details.

Files changed (178) hide show
  1. hanzo_mcp/__init__.py +7 -1
  2. hanzo_mcp/__main__.py +1 -1
  3. hanzo_mcp/analytics/__init__.py +2 -2
  4. hanzo_mcp/analytics/posthog_analytics.py +76 -82
  5. hanzo_mcp/cli.py +31 -36
  6. hanzo_mcp/cli_enhanced.py +94 -72
  7. hanzo_mcp/cli_plugin.py +27 -17
  8. hanzo_mcp/config/__init__.py +2 -2
  9. hanzo_mcp/config/settings.py +112 -88
  10. hanzo_mcp/config/tool_config.py +32 -34
  11. hanzo_mcp/dev_server.py +66 -67
  12. hanzo_mcp/prompts/__init__.py +94 -12
  13. hanzo_mcp/prompts/enhanced_prompts.py +809 -0
  14. hanzo_mcp/prompts/example_custom_prompt.py +6 -5
  15. hanzo_mcp/prompts/project_todo_reminder.py +0 -1
  16. hanzo_mcp/prompts/tool_explorer.py +10 -7
  17. hanzo_mcp/server.py +17 -21
  18. hanzo_mcp/server_enhanced.py +15 -22
  19. hanzo_mcp/tools/__init__.py +56 -28
  20. hanzo_mcp/tools/agent/__init__.py +16 -19
  21. hanzo_mcp/tools/agent/agent.py +82 -65
  22. hanzo_mcp/tools/agent/agent_tool.py +152 -122
  23. hanzo_mcp/tools/agent/agent_tool_v1_deprecated.py +66 -62
  24. hanzo_mcp/tools/agent/clarification_protocol.py +55 -50
  25. hanzo_mcp/tools/agent/clarification_tool.py +11 -10
  26. hanzo_mcp/tools/agent/claude_cli_tool.py +21 -20
  27. hanzo_mcp/tools/agent/claude_desktop_auth.py +130 -144
  28. hanzo_mcp/tools/agent/cli_agent_base.py +59 -53
  29. hanzo_mcp/tools/agent/code_auth.py +102 -107
  30. hanzo_mcp/tools/agent/code_auth_tool.py +28 -27
  31. hanzo_mcp/tools/agent/codex_cli_tool.py +20 -19
  32. hanzo_mcp/tools/agent/critic_tool.py +86 -73
  33. hanzo_mcp/tools/agent/gemini_cli_tool.py +21 -20
  34. hanzo_mcp/tools/agent/grok_cli_tool.py +21 -20
  35. hanzo_mcp/tools/agent/iching_tool.py +404 -139
  36. hanzo_mcp/tools/agent/network_tool.py +89 -73
  37. hanzo_mcp/tools/agent/prompt.py +2 -1
  38. hanzo_mcp/tools/agent/review_tool.py +101 -98
  39. hanzo_mcp/tools/agent/swarm_alias.py +87 -0
  40. hanzo_mcp/tools/agent/swarm_tool.py +246 -161
  41. hanzo_mcp/tools/agent/swarm_tool_v1_deprecated.py +134 -92
  42. hanzo_mcp/tools/agent/tool_adapter.py +21 -11
  43. hanzo_mcp/tools/common/__init__.py +1 -1
  44. hanzo_mcp/tools/common/base.py +3 -5
  45. hanzo_mcp/tools/common/batch_tool.py +46 -39
  46. hanzo_mcp/tools/common/config_tool.py +120 -84
  47. hanzo_mcp/tools/common/context.py +1 -5
  48. hanzo_mcp/tools/common/context_fix.py +5 -3
  49. hanzo_mcp/tools/common/critic_tool.py +4 -8
  50. hanzo_mcp/tools/common/decorators.py +58 -56
  51. hanzo_mcp/tools/common/enhanced_base.py +29 -32
  52. hanzo_mcp/tools/common/fastmcp_pagination.py +91 -94
  53. hanzo_mcp/tools/common/forgiving_edit.py +91 -87
  54. hanzo_mcp/tools/common/mode.py +15 -17
  55. hanzo_mcp/tools/common/mode_loader.py +27 -24
  56. hanzo_mcp/tools/common/paginated_base.py +61 -53
  57. hanzo_mcp/tools/common/paginated_response.py +72 -79
  58. hanzo_mcp/tools/common/pagination.py +50 -53
  59. hanzo_mcp/tools/common/permissions.py +4 -4
  60. hanzo_mcp/tools/common/personality.py +186 -138
  61. hanzo_mcp/tools/common/plugin_loader.py +54 -54
  62. hanzo_mcp/tools/common/stats.py +65 -47
  63. hanzo_mcp/tools/common/test_helpers.py +31 -0
  64. hanzo_mcp/tools/common/thinking_tool.py +4 -8
  65. hanzo_mcp/tools/common/tool_disable.py +17 -12
  66. hanzo_mcp/tools/common/tool_enable.py +13 -14
  67. hanzo_mcp/tools/common/tool_list.py +36 -28
  68. hanzo_mcp/tools/common/truncate.py +23 -23
  69. hanzo_mcp/tools/config/__init__.py +4 -4
  70. hanzo_mcp/tools/config/config_tool.py +42 -29
  71. hanzo_mcp/tools/config/index_config.py +37 -34
  72. hanzo_mcp/tools/config/mode_tool.py +175 -55
  73. hanzo_mcp/tools/database/__init__.py +15 -12
  74. hanzo_mcp/tools/database/database_manager.py +77 -75
  75. hanzo_mcp/tools/database/graph.py +137 -91
  76. hanzo_mcp/tools/database/graph_add.py +30 -18
  77. hanzo_mcp/tools/database/graph_query.py +178 -102
  78. hanzo_mcp/tools/database/graph_remove.py +33 -28
  79. hanzo_mcp/tools/database/graph_search.py +97 -75
  80. hanzo_mcp/tools/database/graph_stats.py +91 -59
  81. hanzo_mcp/tools/database/sql.py +107 -79
  82. hanzo_mcp/tools/database/sql_query.py +30 -24
  83. hanzo_mcp/tools/database/sql_search.py +29 -25
  84. hanzo_mcp/tools/database/sql_stats.py +47 -35
  85. hanzo_mcp/tools/editor/neovim_command.py +25 -28
  86. hanzo_mcp/tools/editor/neovim_edit.py +21 -23
  87. hanzo_mcp/tools/editor/neovim_session.py +60 -54
  88. hanzo_mcp/tools/filesystem/__init__.py +31 -30
  89. hanzo_mcp/tools/filesystem/ast_multi_edit.py +329 -249
  90. hanzo_mcp/tools/filesystem/ast_tool.py +4 -4
  91. hanzo_mcp/tools/filesystem/base.py +1 -1
  92. hanzo_mcp/tools/filesystem/batch_search.py +316 -224
  93. hanzo_mcp/tools/filesystem/content_replace.py +4 -4
  94. hanzo_mcp/tools/filesystem/diff.py +71 -59
  95. hanzo_mcp/tools/filesystem/directory_tree.py +7 -7
  96. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +49 -37
  97. hanzo_mcp/tools/filesystem/edit.py +4 -4
  98. hanzo_mcp/tools/filesystem/find.py +173 -80
  99. hanzo_mcp/tools/filesystem/find_files.py +73 -52
  100. hanzo_mcp/tools/filesystem/git_search.py +157 -104
  101. hanzo_mcp/tools/filesystem/grep.py +8 -8
  102. hanzo_mcp/tools/filesystem/multi_edit.py +4 -8
  103. hanzo_mcp/tools/filesystem/read.py +12 -10
  104. hanzo_mcp/tools/filesystem/rules_tool.py +59 -43
  105. hanzo_mcp/tools/filesystem/search_tool.py +263 -207
  106. hanzo_mcp/tools/filesystem/symbols_tool.py +94 -54
  107. hanzo_mcp/tools/filesystem/tree.py +35 -33
  108. hanzo_mcp/tools/filesystem/unix_aliases.py +13 -18
  109. hanzo_mcp/tools/filesystem/watch.py +37 -36
  110. hanzo_mcp/tools/filesystem/write.py +4 -8
  111. hanzo_mcp/tools/jupyter/__init__.py +4 -4
  112. hanzo_mcp/tools/jupyter/base.py +4 -5
  113. hanzo_mcp/tools/jupyter/jupyter.py +67 -47
  114. hanzo_mcp/tools/jupyter/notebook_edit.py +4 -4
  115. hanzo_mcp/tools/jupyter/notebook_read.py +4 -7
  116. hanzo_mcp/tools/llm/__init__.py +5 -7
  117. hanzo_mcp/tools/llm/consensus_tool.py +72 -52
  118. hanzo_mcp/tools/llm/llm_manage.py +101 -60
  119. hanzo_mcp/tools/llm/llm_tool.py +226 -166
  120. hanzo_mcp/tools/llm/provider_tools.py +25 -26
  121. hanzo_mcp/tools/lsp/__init__.py +1 -1
  122. hanzo_mcp/tools/lsp/lsp_tool.py +228 -143
  123. hanzo_mcp/tools/mcp/__init__.py +2 -3
  124. hanzo_mcp/tools/mcp/mcp_add.py +27 -25
  125. hanzo_mcp/tools/mcp/mcp_remove.py +7 -8
  126. hanzo_mcp/tools/mcp/mcp_stats.py +23 -22
  127. hanzo_mcp/tools/mcp/mcp_tool.py +129 -98
  128. hanzo_mcp/tools/memory/__init__.py +39 -21
  129. hanzo_mcp/tools/memory/knowledge_tools.py +124 -99
  130. hanzo_mcp/tools/memory/memory_tools.py +90 -108
  131. hanzo_mcp/tools/search/__init__.py +7 -2
  132. hanzo_mcp/tools/search/find_tool.py +297 -212
  133. hanzo_mcp/tools/search/unified_search.py +366 -314
  134. hanzo_mcp/tools/shell/__init__.py +8 -7
  135. hanzo_mcp/tools/shell/auto_background.py +56 -49
  136. hanzo_mcp/tools/shell/base.py +1 -1
  137. hanzo_mcp/tools/shell/base_process.py +75 -75
  138. hanzo_mcp/tools/shell/bash_session.py +2 -2
  139. hanzo_mcp/tools/shell/bash_session_executor.py +4 -4
  140. hanzo_mcp/tools/shell/bash_tool.py +24 -31
  141. hanzo_mcp/tools/shell/command_executor.py +12 -12
  142. hanzo_mcp/tools/shell/logs.py +43 -33
  143. hanzo_mcp/tools/shell/npx.py +13 -13
  144. hanzo_mcp/tools/shell/npx_background.py +24 -21
  145. hanzo_mcp/tools/shell/npx_tool.py +18 -22
  146. hanzo_mcp/tools/shell/open.py +19 -21
  147. hanzo_mcp/tools/shell/pkill.py +31 -26
  148. hanzo_mcp/tools/shell/process_tool.py +32 -32
  149. hanzo_mcp/tools/shell/processes.py +57 -58
  150. hanzo_mcp/tools/shell/run_background.py +24 -25
  151. hanzo_mcp/tools/shell/run_command.py +5 -5
  152. hanzo_mcp/tools/shell/run_command_windows.py +5 -5
  153. hanzo_mcp/tools/shell/session_storage.py +3 -3
  154. hanzo_mcp/tools/shell/streaming_command.py +141 -126
  155. hanzo_mcp/tools/shell/uvx.py +24 -25
  156. hanzo_mcp/tools/shell/uvx_background.py +35 -33
  157. hanzo_mcp/tools/shell/uvx_tool.py +18 -22
  158. hanzo_mcp/tools/todo/__init__.py +6 -2
  159. hanzo_mcp/tools/todo/todo.py +50 -37
  160. hanzo_mcp/tools/todo/todo_read.py +5 -8
  161. hanzo_mcp/tools/todo/todo_write.py +5 -7
  162. hanzo_mcp/tools/vector/__init__.py +40 -28
  163. hanzo_mcp/tools/vector/ast_analyzer.py +176 -143
  164. hanzo_mcp/tools/vector/git_ingester.py +170 -179
  165. hanzo_mcp/tools/vector/index_tool.py +96 -44
  166. hanzo_mcp/tools/vector/infinity_store.py +283 -228
  167. hanzo_mcp/tools/vector/mock_infinity.py +39 -40
  168. hanzo_mcp/tools/vector/project_manager.py +88 -78
  169. hanzo_mcp/tools/vector/vector.py +59 -42
  170. hanzo_mcp/tools/vector/vector_index.py +30 -27
  171. hanzo_mcp/tools/vector/vector_search.py +64 -45
  172. hanzo_mcp/types.py +6 -4
  173. {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/METADATA +1 -1
  174. hanzo_mcp-0.8.0.dist-info/RECORD +185 -0
  175. hanzo_mcp-0.7.6.dist-info/RECORD +0 -182
  176. {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/WHEEL +0 -0
  177. {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
  178. {hanzo_mcp-0.7.6.dist-info → hanzo_mcp-0.8.0.dist-info}/top_level.txt +0 -0
@@ -1,25 +1,22 @@
1
1
  """Streaming command execution with disk-based logging and session management."""
2
2
 
3
- import asyncio
4
- import json
5
3
  import os
6
- import re
7
- import shutil
8
- import subprocess
9
- import tempfile
4
+ import json
10
5
  import time
11
6
  import uuid
12
- from datetime import datetime, timedelta
7
+ import shutil
8
+ import asyncio
9
+ import subprocess
10
+ from typing import Any, Dict, List, Union, Optional
13
11
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional, Tuple, Union
12
+ from datetime import datetime, timedelta
15
13
 
16
- from hanzo_mcp.tools.common.base import BaseTool
17
14
  from hanzo_mcp.tools.shell.base_process import BaseProcessTool
18
15
 
19
16
 
20
17
  class StreamingCommandTool(BaseProcessTool):
21
18
  """Execute commands with disk-based streaming and session persistence.
22
-
19
+
23
20
  Features:
24
21
  - All output streamed directly to disk (no memory usage)
25
22
  - Session-based organization of logs
@@ -27,46 +24,46 @@ class StreamingCommandTool(BaseProcessTool):
27
24
  - Forgiving parameter handling for AI usage
28
25
  - Automatic session detection from MCP context
29
26
  """
30
-
27
+
31
28
  name = "streaming_command"
32
29
  description = "Run commands with disk-based output streaming and easy resumption"
33
-
30
+
34
31
  # Base directory for all session data
35
32
  SESSION_BASE_DIR = Path.home() / ".hanzo" / "sessions"
36
-
33
+
37
34
  # Chunk size for streaming (25k tokens ≈ 100KB)
38
35
  STREAM_CHUNK_SIZE = 100_000
39
-
36
+
40
37
  # Session retention
41
38
  SESSION_RETENTION_DAYS = 30
42
-
39
+
43
40
  def __init__(self):
44
41
  """Initialize the streaming command tool."""
45
42
  super().__init__()
46
43
  self.session_id = self._get_or_create_session()
47
44
  self.session_dir = self.SESSION_BASE_DIR / self.session_id
48
45
  self.session_dir.mkdir(parents=True, exist_ok=True)
49
-
46
+
50
47
  # Create subdirectories
51
48
  self.commands_dir = self.session_dir / "commands"
52
49
  self.commands_dir.mkdir(exist_ok=True)
53
-
50
+
54
51
  # Session metadata file
55
52
  self.session_meta_file = self.session_dir / "session.json"
56
53
  self._update_session_metadata()
57
-
54
+
58
55
  # Cleanup old sessions on init
59
56
  self._cleanup_old_sessions()
60
-
57
+
61
58
  def _get_or_create_session(self) -> str:
62
59
  """Get session ID from MCP context or create a new one.
63
-
60
+
64
61
  Returns:
65
62
  Session ID string
66
63
  """
67
64
  # Try to get from environment (MCP might set this)
68
65
  session_id = os.environ.get("MCP_SESSION_ID")
69
-
66
+
70
67
  if not session_id:
71
68
  # Try to get from Claude Desktop session marker
72
69
  claude_session = os.environ.get("CLAUDE_SESSION_ID")
@@ -76,9 +73,9 @@ class StreamingCommandTool(BaseProcessTool):
76
73
  # Generate new session ID with timestamp
77
74
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
78
75
  session_id = f"session_{timestamp}_{uuid.uuid4().hex[:8]}"
79
-
76
+
80
77
  return session_id
81
-
78
+
82
79
  def _update_session_metadata(self) -> None:
83
80
  """Update session metadata file."""
84
81
  metadata = {
@@ -89,9 +86,9 @@ class StreamingCommandTool(BaseProcessTool):
89
86
  "session_id": os.environ.get("MCP_SESSION_ID"),
90
87
  "claude_session": os.environ.get("CLAUDE_SESSION_ID"),
91
88
  "user": os.environ.get("USER"),
92
- }
89
+ },
93
90
  }
94
-
91
+
95
92
  # Merge with existing metadata if present
96
93
  if self.session_meta_file.exists():
97
94
  try:
@@ -100,37 +97,39 @@ class StreamingCommandTool(BaseProcessTool):
100
97
  metadata["created"] = existing.get("created", metadata["created"])
101
98
  except Exception:
102
99
  pass
103
-
100
+
104
101
  with open(self.session_meta_file, "w") as f:
105
102
  json.dump(metadata, f, indent=2)
106
-
103
+
107
104
  def _cleanup_old_sessions(self) -> None:
108
105
  """Remove sessions older than retention period."""
109
106
  if not self.SESSION_BASE_DIR.exists():
110
107
  return
111
-
108
+
112
109
  cutoff = datetime.now() - timedelta(days=self.SESSION_RETENTION_DAYS)
113
-
110
+
114
111
  for session_dir in self.SESSION_BASE_DIR.iterdir():
115
112
  if not session_dir.is_dir():
116
113
  continue
117
-
114
+
118
115
  meta_file = session_dir / "session.json"
119
116
  if meta_file.exists():
120
117
  try:
121
118
  with open(meta_file, "r") as f:
122
119
  meta = json.load(f)
123
- last_accessed = datetime.fromisoformat(meta.get("last_accessed", ""))
120
+ last_accessed = datetime.fromisoformat(
121
+ meta.get("last_accessed", "")
122
+ )
124
123
  if last_accessed < cutoff:
125
124
  shutil.rmtree(session_dir)
126
125
  except Exception:
127
126
  # If we can't read metadata, check directory mtime
128
127
  if datetime.fromtimestamp(session_dir.stat().st_mtime) < cutoff:
129
128
  shutil.rmtree(session_dir)
130
-
129
+
131
130
  def _normalize_command_ref(self, ref: Union[str, int, None]) -> Optional[str]:
132
131
  """Normalize various command reference formats.
133
-
132
+
134
133
  Args:
135
134
  ref: Command reference - can be:
136
135
  - Full command ID (UUID)
@@ -138,15 +137,15 @@ class StreamingCommandTool(BaseProcessTool):
138
137
  - Index number (1, 2, 3...)
139
138
  - "last" or "latest"
140
139
  - None
141
-
140
+
142
141
  Returns:
143
142
  Full command ID or None
144
143
  """
145
144
  if not ref:
146
145
  return None
147
-
146
+
148
147
  ref_str = str(ref).strip().lower()
149
-
148
+
150
149
  # Handle special cases
151
150
  if ref_str in ["last", "latest", "recent"]:
152
151
  # Get most recent command
@@ -155,37 +154,39 @@ class StreamingCommandTool(BaseProcessTool):
155
154
  return None
156
155
  latest = max(commands, key=lambda p: p.stat().st_mtime)
157
156
  return latest.parent.name
158
-
157
+
159
158
  # Handle numeric index (1-based for user friendliness)
160
159
  if ref_str.isdigit():
161
160
  index = int(ref_str) - 1
162
- commands = sorted(self.commands_dir.glob("*/metadata.json"),
163
- key=lambda p: p.stat().st_mtime)
161
+ commands = sorted(
162
+ self.commands_dir.glob("*/metadata.json"),
163
+ key=lambda p: p.stat().st_mtime,
164
+ )
164
165
  if 0 <= index < len(commands):
165
166
  return commands[index].parent.name
166
167
  return None
167
-
168
+
168
169
  # Handle short ID (first 8 chars)
169
170
  if len(ref_str) >= 8:
170
171
  # Could be short or full ID
171
172
  for cmd_dir in self.commands_dir.iterdir():
172
173
  if cmd_dir.name.startswith(ref_str):
173
174
  return cmd_dir.name
174
-
175
+
175
176
  return None
176
-
177
+
177
178
  async def call(self, ctx: Any, **kwargs) -> Dict[str, Any]:
178
179
  """MCP tool entry point.
179
-
180
+
180
181
  Args:
181
182
  ctx: MCP context
182
183
  **kwargs: Tool arguments
183
-
184
+
184
185
  Returns:
185
186
  Tool result
186
187
  """
187
188
  return await self.run(**kwargs)
188
-
189
+
189
190
  async def run(
190
191
  self,
191
192
  command: Optional[str] = None,
@@ -199,7 +200,7 @@ class StreamingCommandTool(BaseProcessTool):
199
200
  chunk_size: Optional[Union[int, str]] = None,
200
201
  ) -> Dict[str, Any]:
201
202
  """Execute or continue reading a command with maximum forgiveness.
202
-
203
+
203
204
  Args:
204
205
  command/cmd: The command to execute (either works)
205
206
  working_dir/cwd: Directory to run in (either works)
@@ -207,7 +208,7 @@ class StreamingCommandTool(BaseProcessTool):
207
208
  continue_from/resume: Continue reading output from a command
208
209
  from_byte: Specific byte position to read from
209
210
  chunk_size: Custom chunk size for this read
210
-
211
+
211
212
  Returns:
212
213
  Command output with metadata for easy continuation
213
214
  """
@@ -215,7 +216,7 @@ class StreamingCommandTool(BaseProcessTool):
215
216
  command = command or cmd
216
217
  working_dir = working_dir or cwd
217
218
  continue_from = continue_from or resume
218
-
219
+
219
220
  # Convert string numbers to int
220
221
  if isinstance(timeout, str) and timeout.isdigit():
221
222
  timeout = int(timeout)
@@ -223,13 +224,13 @@ class StreamingCommandTool(BaseProcessTool):
223
224
  from_byte = int(from_byte)
224
225
  if isinstance(chunk_size, str) and chunk_size.isdigit():
225
226
  chunk_size = int(chunk_size)
226
-
227
+
227
228
  chunk_size = chunk_size or self.STREAM_CHUNK_SIZE
228
-
229
+
229
230
  # Handle continuation
230
231
  if continue_from:
231
232
  return await self._continue_reading(continue_from, from_byte, chunk_size)
232
-
233
+
233
234
  # Need a command for new execution
234
235
  if not command:
235
236
  return {
@@ -237,10 +238,12 @@ class StreamingCommandTool(BaseProcessTool):
237
238
  "hint": "To continue a previous command, use 'continue_from' with command ID or number.",
238
239
  "recent_commands": await self._get_recent_commands(),
239
240
  }
240
-
241
+
241
242
  # Execute new command
242
- return await self._execute_new_command(command, working_dir, timeout, chunk_size)
243
-
243
+ return await self._execute_new_command(
244
+ command, working_dir, timeout, chunk_size
245
+ )
246
+
244
247
  async def _execute_new_command(
245
248
  self,
246
249
  command: str,
@@ -253,12 +256,12 @@ class StreamingCommandTool(BaseProcessTool):
253
256
  cmd_id = str(uuid.uuid4())
254
257
  cmd_dir = self.commands_dir / cmd_id
255
258
  cmd_dir.mkdir()
256
-
259
+
257
260
  # File paths
258
261
  output_file = cmd_dir / "output.log"
259
262
  error_file = cmd_dir / "error.log"
260
263
  metadata_file = cmd_dir / "metadata.json"
261
-
264
+
262
265
  # Save metadata
263
266
  metadata = {
264
267
  "command_id": cmd_id,
@@ -268,10 +271,10 @@ class StreamingCommandTool(BaseProcessTool):
268
271
  "timeout": timeout,
269
272
  "status": "running",
270
273
  }
271
-
274
+
272
275
  with open(metadata_file, "w") as f:
273
276
  json.dump(metadata, f, indent=2)
274
-
277
+
275
278
  # Start process with output redirection
276
279
  try:
277
280
  process = await asyncio.create_subprocess_shell(
@@ -280,7 +283,7 @@ class StreamingCommandTool(BaseProcessTool):
280
283
  stderr=asyncio.subprocess.PIPE,
281
284
  cwd=working_dir,
282
285
  )
283
-
286
+
284
287
  # Create tasks for streaming stdout and stderr to files
285
288
  async def stream_to_file(stream, file_path):
286
289
  """Stream from async pipe to file."""
@@ -291,32 +294,38 @@ class StreamingCommandTool(BaseProcessTool):
291
294
  break
292
295
  f.write(chunk)
293
296
  f.flush() # Ensure immediate write
294
-
297
+
295
298
  # Start streaming tasks
296
- stdout_task = asyncio.create_task(stream_to_file(process.stdout, output_file))
297
- stderr_task = asyncio.create_task(stream_to_file(process.stderr, error_file))
298
-
299
+ stdout_task = asyncio.create_task(
300
+ stream_to_file(process.stdout, output_file)
301
+ )
302
+ stderr_task = asyncio.create_task(
303
+ stream_to_file(process.stderr, error_file)
304
+ )
305
+
299
306
  # Wait for initial output or timeout
300
307
  start_time = time.time()
301
- initial_timeout = min(timeout or 5, 5) # Wait max 5 seconds for initial output
302
-
308
+ initial_timeout = min(
309
+ timeout or 5, 5
310
+ ) # Wait max 5 seconds for initial output
311
+
303
312
  while time.time() - start_time < initial_timeout:
304
313
  if output_file.stat().st_size > 0 or error_file.stat().st_size > 0:
305
314
  break
306
315
  await asyncio.sleep(0.1)
307
-
316
+
308
317
  # Read initial chunk
309
318
  output_content = ""
310
319
  error_content = ""
311
-
320
+
312
321
  if output_file.exists() and output_file.stat().st_size > 0:
313
322
  with open(output_file, "r", errors="replace") as f:
314
323
  output_content = f.read(chunk_size)
315
-
324
+
316
325
  if error_file.exists() and error_file.stat().st_size > 0:
317
326
  with open(error_file, "r", errors="replace") as f:
318
327
  error_content = f.read(1000) # Just first 1KB of errors
319
-
328
+
320
329
  # Check if process completed quickly
321
330
  try:
322
331
  await asyncio.wait_for(process.wait(), timeout=0.1)
@@ -325,16 +334,16 @@ class StreamingCommandTool(BaseProcessTool):
325
334
  except asyncio.TimeoutError:
326
335
  exit_code = None
327
336
  status = "running"
328
-
337
+
329
338
  # Update metadata
330
339
  metadata["status"] = status
331
340
  if exit_code is not None:
332
341
  metadata["exit_code"] = exit_code
333
342
  metadata["end_time"] = datetime.now().isoformat()
334
-
343
+
335
344
  with open(metadata_file, "w") as f:
336
345
  json.dump(metadata, f, indent=2)
337
-
346
+
338
347
  # Build response
339
348
  result = {
340
349
  "command_id": cmd_id,
@@ -345,13 +354,13 @@ class StreamingCommandTool(BaseProcessTool):
345
354
  "bytes_read": len(output_content),
346
355
  "session_path": str(cmd_dir),
347
356
  }
348
-
357
+
349
358
  if error_content:
350
359
  result["stderr"] = error_content
351
-
360
+
352
361
  if exit_code is not None:
353
362
  result["exit_code"] = exit_code
354
-
363
+
355
364
  # Add continuation info if more output available
356
365
  total_size = output_file.stat().st_size
357
366
  if total_size > len(output_content) or status == "running":
@@ -366,30 +375,30 @@ class StreamingCommandTool(BaseProcessTool):
366
375
  f"Command {'is still running' if status == 'running' else 'has more output'}. "
367
376
  f"Use any of: {', '.join(result['continue_hints'])}"
368
377
  )
369
-
378
+
370
379
  # Ensure tasks complete
371
380
  if status == "completed":
372
381
  await stdout_task
373
382
  await stderr_task
374
-
383
+
375
384
  return result
376
-
385
+
377
386
  except Exception as e:
378
387
  # Update metadata with error
379
388
  metadata["status"] = "error"
380
389
  metadata["error"] = str(e)
381
390
  metadata["end_time"] = datetime.now().isoformat()
382
-
391
+
383
392
  with open(metadata_file, "w") as f:
384
393
  json.dump(metadata, f, indent=2)
385
-
394
+
386
395
  return {
387
396
  "error": str(e),
388
397
  "command_id": cmd_id,
389
398
  "short_id": cmd_id[:8],
390
399
  "command": command,
391
400
  }
392
-
401
+
393
402
  async def _continue_reading(
394
403
  self,
395
404
  ref: Union[str, int],
@@ -399,33 +408,33 @@ class StreamingCommandTool(BaseProcessTool):
399
408
  """Continue reading output from a previous command."""
400
409
  # Normalize reference
401
410
  cmd_id = self._normalize_command_ref(ref)
402
-
411
+
403
412
  if not cmd_id:
404
413
  return {
405
414
  "error": f"Command not found: {ref}",
406
415
  "hint": "Use 'list' to see available commands",
407
416
  "recent_commands": await self._get_recent_commands(),
408
417
  }
409
-
418
+
410
419
  cmd_dir = self.commands_dir / cmd_id
411
420
  if not cmd_dir.exists():
412
421
  return {"error": f"Command directory not found: {cmd_id}"}
413
-
422
+
414
423
  # Load metadata
415
424
  metadata_file = cmd_dir / "metadata.json"
416
425
  with open(metadata_file, "r") as f:
417
426
  metadata = json.load(f)
418
-
427
+
419
428
  # Determine start position
420
429
  output_file = cmd_dir / "output.log"
421
430
  if not output_file.exists():
422
431
  return {"error": "No output file found"}
423
-
432
+
424
433
  # If no from_byte specified, read from where we left off
425
434
  if from_byte is None:
426
435
  # Try to determine from previous reads (could track this)
427
436
  from_byte = 0 # For now, start from beginning if not specified
428
-
437
+
429
438
  # Read chunk
430
439
  try:
431
440
  with open(output_file, "r", errors="replace") as f:
@@ -433,10 +442,10 @@ class StreamingCommandTool(BaseProcessTool):
433
442
  content = f.read(chunk_size)
434
443
  new_position = f.tell()
435
444
  file_size = output_file.stat().st_size
436
-
445
+
437
446
  # Check if process is still running
438
447
  status = metadata.get("status", "unknown")
439
-
448
+
440
449
  # Build response
441
450
  result = {
442
451
  "command_id": cmd_id,
@@ -449,13 +458,13 @@ class StreamingCommandTool(BaseProcessTool):
449
458
  "read_to": new_position,
450
459
  "total_bytes": file_size,
451
460
  }
452
-
461
+
453
462
  # Add stderr if needed
454
463
  error_file = cmd_dir / "error.log"
455
464
  if error_file.exists() and error_file.stat().st_size > 0:
456
465
  with open(error_file, "r", errors="replace") as f:
457
466
  result["stderr"] = f.read(1000)
458
-
467
+
459
468
  # Add continuation info
460
469
  if new_position < file_size or status == "running":
461
470
  result["has_more"] = True
@@ -468,82 +477,88 @@ class StreamingCommandTool(BaseProcessTool):
468
477
  f"{file_size - new_position} bytes remaining. "
469
478
  f"Use: {result['continue_hints'][0]}"
470
479
  )
471
-
480
+
472
481
  return result
473
-
482
+
474
483
  except Exception as e:
475
484
  return {"error": f"Error reading output: {str(e)}"}
476
-
485
+
477
486
  async def _get_recent_commands(self, limit: int = 5) -> List[Dict[str, Any]]:
478
487
  """Get list of recent commands for hints."""
479
488
  commands = []
480
-
481
- for cmd_dir in sorted(self.commands_dir.iterdir(),
482
- key=lambda p: p.stat().st_mtime,
483
- reverse=True)[:limit]:
489
+
490
+ for cmd_dir in sorted(
491
+ self.commands_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True
492
+ )[:limit]:
484
493
  try:
485
494
  with open(cmd_dir / "metadata.json", "r") as f:
486
495
  meta = json.load(f)
487
-
496
+
488
497
  output_size = 0
489
498
  output_file = cmd_dir / "output.log"
490
499
  if output_file.exists():
491
500
  output_size = output_file.stat().st_size
492
-
493
- commands.append({
494
- "id": meta["command_id"][:8],
495
- "command": meta["command"][:50] + "..." if len(meta["command"]) > 50 else meta["command"],
496
- "status": meta.get("status", "unknown"),
497
- "output_size": output_size,
498
- "time": meta.get("start_time", ""),
499
- })
501
+
502
+ commands.append(
503
+ {
504
+ "id": meta["command_id"][:8],
505
+ "command": (
506
+ meta["command"][:50] + "..."
507
+ if len(meta["command"]) > 50
508
+ else meta["command"]
509
+ ),
510
+ "status": meta.get("status", "unknown"),
511
+ "output_size": output_size,
512
+ "time": meta.get("start_time", ""),
513
+ }
514
+ )
500
515
  except Exception:
501
516
  continue
502
-
517
+
503
518
  return commands
504
-
519
+
505
520
  async def list(self, limit: Optional[int] = 10) -> Dict[str, Any]:
506
521
  """List recent commands in this session.
507
-
522
+
508
523
  Args:
509
524
  limit: Maximum number of commands to show
510
-
525
+
511
526
  Returns:
512
527
  List of recent commands with details
513
528
  """
514
529
  commands = await self._get_recent_commands(limit or 10)
515
-
530
+
516
531
  return {
517
532
  "session_id": self.session_id,
518
533
  "session_path": str(self.session_dir),
519
534
  "commands": commands,
520
535
  "hint": "Use continue_from='<id>' or resume='last' to read output",
521
536
  }
522
-
537
+
523
538
  async def tail(
524
539
  self,
525
540
  ref: Optional[Union[str, int]] = None,
526
541
  lines: Optional[int] = 20,
527
542
  ) -> Dict[str, Any]:
528
543
  """Get the tail of a command's output (like 'tail -f').
529
-
544
+
530
545
  Args:
531
546
  ref: Command reference (defaults to 'last')
532
547
  lines: Number of lines to show
533
-
548
+
534
549
  Returns:
535
550
  Last N lines of output
536
551
  """
537
552
  ref = ref or "last"
538
553
  cmd_id = self._normalize_command_ref(ref)
539
-
554
+
540
555
  if not cmd_id:
541
556
  return {"error": f"Command not found: {ref}"}
542
-
557
+
543
558
  output_file = self.commands_dir / cmd_id / "output.log"
544
559
  if not output_file.exists():
545
560
  return {"error": "No output file found"}
546
-
561
+
547
562
  try:
548
563
  # Use tail command for efficiency
549
564
  result = subprocess.run(
@@ -551,7 +566,7 @@ class StreamingCommandTool(BaseProcessTool):
551
566
  capture_output=True,
552
567
  text=True,
553
568
  )
554
-
569
+
555
570
  return {
556
571
  "command_id": cmd_id[:8],
557
572
  "output": result.stdout,
@@ -559,7 +574,7 @@ class StreamingCommandTool(BaseProcessTool):
559
574
  }
560
575
  except Exception as e:
561
576
  return {"error": f"Error tailing output: {str(e)}"}
562
-
577
+
563
578
  def get_params_schema(self) -> Dict[str, Any]:
564
579
  """Get parameter schema - very forgiving."""
565
580
  return {
@@ -604,24 +619,24 @@ class StreamingCommandTool(BaseProcessTool):
604
619
  },
605
620
  "required": [], # No required fields for maximum forgiveness
606
621
  }
607
-
622
+
608
623
  def get_command_args(self, command: str, **kwargs) -> List[str]:
609
624
  """Get the command arguments for subprocess.
610
-
625
+
611
626
  Args:
612
627
  command: The command or script to run
613
628
  **kwargs: Additional arguments (not used for shell commands)
614
-
629
+
615
630
  Returns:
616
631
  List of command arguments for subprocess
617
632
  """
618
633
  # For shell commands, we use shell=True, so return the command as-is
619
634
  return [command]
620
-
635
+
621
636
  def get_tool_name(self) -> str:
622
637
  """Get the name of the tool being used.
623
-
638
+
624
639
  Returns:
625
640
  Tool name
626
641
  """
627
- return "streaming_command"
642
+ return "streaming_command"