agentpool 2.1.9__py3-none-any.whl → 2.5.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.
Files changed (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,571 @@
1
+ """File operation routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import fnmatch
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ import shutil
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from fastapi import APIRouter, HTTPException, Query
14
+
15
+ from agentpool_server.opencode_server.dependencies import StateDep
16
+ from agentpool_server.opencode_server.models import (
17
+ FileContent,
18
+ FileNode,
19
+ FindMatch,
20
+ Symbol,
21
+ )
22
+ from agentpool_server.opencode_server.models.file import SubmatchInfo
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from fsspec.asyn import AsyncFileSystem
27
+
28
+
29
+ router = APIRouter(tags=["file"])
30
+
31
+
32
+ # Directories to skip when searching
33
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", "dist", "build"}
34
+
35
+ # Sensitive files that should never be exposed via the API
36
+ BLOCKED_FILES = {".env", ".env.local", ".env.production", ".env.development", ".env.test"}
37
+
38
+
39
+ def _validate_path(root: Path, user_path: str) -> Path:
40
+ """Validate and resolve a user-provided path, ensuring it stays within root.
41
+
42
+ Args:
43
+ root: The root directory (working_dir) that paths must stay within.
44
+ user_path: The user-provided relative path.
45
+
46
+ Returns:
47
+ The resolved absolute path that is guaranteed to be within root.
48
+
49
+ Raises:
50
+ HTTPException: If the path escapes root or is blocked.
51
+ """
52
+ # Resolve the root to handle any symlinks in the root itself
53
+ resolved_root = root.resolve()
54
+
55
+ # Join and resolve the full path (this handles ../, symlinks, etc.)
56
+ target = (root / user_path).resolve()
57
+
58
+ # Check that the resolved path is within the resolved root
59
+ try:
60
+ target.relative_to(resolved_root)
61
+ except ValueError:
62
+ raise HTTPException(
63
+ status_code=403,
64
+ detail="Access denied: path escapes project directory",
65
+ ) from None
66
+
67
+ # Check for blocked files
68
+ if target.name in BLOCKED_FILES:
69
+ raise HTTPException(
70
+ status_code=403,
71
+ detail=f"Access denied: {target.name} files are protected",
72
+ )
73
+
74
+ return target
75
+
76
+
77
+ def _validate_path_str(root: str, user_path: str) -> str:
78
+ """Validate path for fsspec filesystem (string-based).
79
+
80
+ Args:
81
+ root: The root directory path as string.
82
+ user_path: The user-provided relative path.
83
+
84
+ Returns:
85
+ The validated absolute path as string.
86
+
87
+ Raises:
88
+ HTTPException: If the path escapes root or is blocked.
89
+ """
90
+ validated = _validate_path(Path(root), user_path)
91
+ return str(validated)
92
+
93
+
94
+ def _get_fs(state: StateDep) -> tuple[AsyncFileSystem, str] | None:
95
+ """Get the fsspec filesystem from the agent's environment if available.
96
+
97
+ Returns:
98
+ Tuple of (filesystem, base_path) or None if not available.
99
+ base_path is the root directory to use for operations.
100
+ """
101
+ try:
102
+ fs = state.agent.env.get_fs()
103
+ # Use env's cwd if set, otherwise use state.working_dir
104
+ env = state.agent.env
105
+ base_path = env.cwd or state.working_dir
106
+ except NotImplementedError:
107
+ return None
108
+ else:
109
+ return (fs, base_path)
110
+
111
+
112
+ def _is_local_fs(fs: AsyncFileSystem) -> bool:
113
+ """Check if filesystem is a local filesystem."""
114
+ return getattr(fs, "local_file", False)
115
+
116
+
117
+ def _has_ripgrep() -> bool:
118
+ """Check if ripgrep is available."""
119
+ return shutil.which("rg") is not None
120
+
121
+
122
+ async def _search_with_ripgrep(
123
+ pattern: str,
124
+ base_path: str,
125
+ max_matches: int = 100,
126
+ ) -> list[FindMatch]:
127
+ """Search using ripgrep for better performance on local filesystems.
128
+
129
+ Args:
130
+ pattern: Regex pattern to search for.
131
+ base_path: Directory to search in.
132
+ max_matches: Maximum number of matches to return.
133
+
134
+ Returns:
135
+ List of FindMatch objects.
136
+ """
137
+ # Build ripgrep command with JSON output
138
+ cmd = [
139
+ "rg",
140
+ "--json",
141
+ "--max-count",
142
+ str(max_matches),
143
+ "--no-binary",
144
+ ]
145
+
146
+ # Add exclude patterns for SKIP_DIRS
147
+ for skip_dir in SKIP_DIRS:
148
+ cmd.extend(["--glob", f"!{skip_dir}/"])
149
+
150
+ cmd.extend(["-e", pattern, base_path])
151
+
152
+ # Run ripgrep asynchronously
153
+ proc = await asyncio.create_subprocess_exec(
154
+ *cmd,
155
+ stdout=asyncio.subprocess.PIPE,
156
+ stderr=asyncio.subprocess.PIPE,
157
+ )
158
+ stdout, _ = await proc.communicate()
159
+
160
+ matches: list[FindMatch] = []
161
+ base_path_prefix = base_path.rstrip("/") + "/"
162
+
163
+ for line in stdout.decode("utf-8", errors="replace").splitlines():
164
+ if not line.strip():
165
+ continue
166
+ try:
167
+ data = json.loads(line)
168
+ if data.get("type") != "match":
169
+ continue
170
+
171
+ match_data = data.get("data", {})
172
+ path = match_data.get("path", {}).get("text", "")
173
+ line_number = match_data.get("line_number", 0)
174
+ line_text = match_data.get("lines", {}).get("text", "").rstrip("\n")
175
+ absolute_offset = match_data.get("absolute_offset", 0)
176
+
177
+ # Convert to relative path
178
+ rel_path = path[len(base_path_prefix) :] if path.startswith(base_path_prefix) else path
179
+
180
+ # Extract submatches
181
+ submatches = []
182
+ for sm in match_data.get("submatches", []):
183
+ match_text = sm.get("match", {}).get("text", "")
184
+ start = sm.get("start", 0)
185
+ end = sm.get("end", 0)
186
+ submatches.append(SubmatchInfo.create(match_text, start, end))
187
+
188
+ matches.append(
189
+ FindMatch.create(
190
+ path=rel_path,
191
+ lines=line_text.strip(),
192
+ line_number=line_number,
193
+ absolute_offset=absolute_offset,
194
+ submatches=submatches,
195
+ )
196
+ )
197
+
198
+ if len(matches) >= max_matches:
199
+ break
200
+ except json.JSONDecodeError:
201
+ continue
202
+
203
+ return matches
204
+
205
+
206
+ async def _find_files_with_ripgrep(
207
+ query: str,
208
+ base_path: str,
209
+ max_results: int = 100,
210
+ ) -> list[str]:
211
+ """Find files using ripgrep --files for better performance.
212
+
213
+ Args:
214
+ query: Glob pattern to match file names.
215
+ base_path: Directory to search in.
216
+ max_results: Maximum number of results to return.
217
+
218
+ Returns:
219
+ List of relative file paths.
220
+ """
221
+ # Build ripgrep command to list files matching glob
222
+ cmd = ["rg", "--files"]
223
+
224
+ # Add exclude patterns for SKIP_DIRS
225
+ for skip_dir in SKIP_DIRS:
226
+ cmd.extend(["--glob", f"!{skip_dir}/"])
227
+
228
+ # Add the file name pattern as a glob
229
+ # rg --files --glob supports matching anywhere in the path
230
+ # If query doesn't contain glob chars, wrap it with * for substring matching
231
+ glob_chars = {"*", "?", "[", "]"}
232
+ if not any(c in query for c in glob_chars):
233
+ query = f"*{query}*"
234
+ # Use **/ prefix to match the filename in any directory
235
+ cmd.extend(["--glob", f"**/{query}"])
236
+ cmd.append(base_path)
237
+
238
+ # Run ripgrep asynchronously
239
+ proc = await asyncio.create_subprocess_exec(
240
+ *cmd,
241
+ stdout=asyncio.subprocess.PIPE,
242
+ stderr=asyncio.subprocess.PIPE,
243
+ )
244
+ stdout, _ = await proc.communicate()
245
+
246
+ results: list[str] = []
247
+ base_path_prefix = base_path.rstrip("/") + "/"
248
+
249
+ for line in stdout.decode("utf-8", errors="replace").splitlines():
250
+ if not line.strip():
251
+ continue
252
+
253
+ # Convert to relative path
254
+ rel_path = line[len(base_path_prefix) :] if line.startswith(base_path_prefix) else line
255
+
256
+ results.append(rel_path)
257
+ if len(results) >= max_results:
258
+ break
259
+
260
+ return sorted(results)
261
+
262
+
263
+ @router.get("/file")
264
+ async def list_files(state: StateDep, path: str = Query(default="")) -> list[FileNode]:
265
+ """List files in a directory."""
266
+ working_path = Path(state.working_dir)
267
+
268
+ # Validate path if provided (empty path means root, which is always valid)
269
+ target_p = _validate_path(working_path, path) if path else working_path.resolve()
270
+
271
+ fs_info = _get_fs(state)
272
+
273
+ if fs_info is not None:
274
+ fs, _base_path = fs_info
275
+ # Use fsspec filesystem with validated path
276
+ target = str(target_p)
277
+ try:
278
+ if not await fs._isdir(target):
279
+ raise HTTPException(status_code=404, detail="Directory not found")
280
+
281
+ entries = await fs._ls(target, detail=True)
282
+ nodes = []
283
+ resolved_root = working_path.resolve()
284
+ for entry in entries:
285
+ full_name = entry.get("name", "")
286
+ name = full_name.split("/")[-1]
287
+ if not name:
288
+ continue
289
+ # Skip blocked files in directory listings
290
+ if name in BLOCKED_FILES:
291
+ continue
292
+ node_type = "directory" if entry.get("type") == "directory" else "file"
293
+ size = entry.get("size") if node_type == "file" else None
294
+ # Build relative path from resolved root
295
+ entry_path = Path(full_name)
296
+ try:
297
+ rel_path = str(entry_path.relative_to(resolved_root))
298
+ except ValueError:
299
+ rel_path = name
300
+ nodes.append(FileNode(name=name, path=rel_path or name, type=node_type, size=size))
301
+ return sorted(nodes, key=lambda n: (n.type != "directory", n.name.lower()))
302
+ except FileNotFoundError as err:
303
+ raise HTTPException(status_code=404, detail="Directory not found") from err
304
+ else:
305
+ # Fallback to local Path operations
306
+ if not target_p.is_dir():
307
+ raise HTTPException(status_code=404, detail="Directory not found")
308
+
309
+ nodes = []
310
+ resolved_root = working_path.resolve()
311
+ for entry in target_p.iterdir():
312
+ # Skip blocked files in directory listings
313
+ if entry.name in BLOCKED_FILES:
314
+ continue
315
+ node_type = "directory" if entry.is_dir() else "file"
316
+ size = entry.stat().st_size if entry.is_file() else None
317
+ rel_path = str(entry.relative_to(resolved_root))
318
+ nodes.append(FileNode(name=entry.name, path=rel_path, type=node_type, size=size))
319
+
320
+ return sorted(nodes, key=lambda n: (n.type != "directory", n.name.lower()))
321
+
322
+
323
+ @router.get("/file/content")
324
+ async def read_file(state: StateDep, path: str = Query()) -> FileContent:
325
+ """Read a file's content."""
326
+ working_path = Path(state.working_dir)
327
+
328
+ # Validate path - this checks for traversal, symlink escapes, and blocked files
329
+ target = _validate_path(working_path, path)
330
+
331
+ fs_info = _get_fs(state)
332
+
333
+ if fs_info is not None:
334
+ fs, _base_path = fs_info
335
+ # Use fsspec filesystem with validated path
336
+ full_path = str(target)
337
+ try:
338
+ if not await fs._isfile(full_path):
339
+ raise HTTPException(status_code=404, detail="File not found")
340
+ content = await fs._cat_file(full_path)
341
+ if isinstance(content, bytes):
342
+ content = content.decode("utf-8")
343
+ return FileContent(path=path, content=content)
344
+ except FileNotFoundError as err:
345
+ raise HTTPException(status_code=404, detail="File not found") from err
346
+ except UnicodeDecodeError as err:
347
+ raise HTTPException(status_code=400, detail="Cannot read binary file") from err
348
+ else:
349
+ # Fallback to local Path operations (target already validated)
350
+ if not target.is_file():
351
+ raise HTTPException(status_code=404, detail="File not found")
352
+
353
+ try:
354
+ content = target.read_text(encoding="utf-8")
355
+ return FileContent(path=path, content=content)
356
+ except UnicodeDecodeError as err:
357
+ raise HTTPException(status_code=400, detail="Cannot read binary file") from err
358
+
359
+
360
+ @router.get("/file/status")
361
+ async def get_file_status(state: StateDep) -> list[dict[str, Any]]:
362
+ """Get status of tracked files.
363
+
364
+ Returns empty list - file tracking not yet implemented.
365
+ """
366
+ _ = state
367
+ return []
368
+
369
+
370
+ @router.get("/find")
371
+ async def find_text(state: StateDep, pattern: str = Query()) -> list[FindMatch]: # noqa: PLR0915
372
+ """Search for text pattern in files using regex."""
373
+ # Validate regex pattern
374
+ try:
375
+ re.compile(pattern)
376
+ except re.error as e:
377
+ raise HTTPException(status_code=400, detail=f"Invalid regex: {e}") from e
378
+
379
+ max_matches = 100
380
+ fs_info = _get_fs(state)
381
+
382
+ # Fast path: use ripgrep for local filesystems
383
+ if fs_info is not None:
384
+ fs, base_path = fs_info
385
+ if _is_local_fs(fs) and _has_ripgrep():
386
+ return await _search_with_ripgrep(pattern, base_path, max_matches)
387
+
388
+ # Fallback: use ripgrep directly if no fs but ripgrep available
389
+ if fs_info is None and _has_ripgrep():
390
+ return await _search_with_ripgrep(pattern, state.working_dir, max_matches)
391
+
392
+ # Slow path: manual file iteration
393
+ matches: list[FindMatch] = []
394
+ regex = re.compile(pattern)
395
+
396
+ if fs_info is not None:
397
+ fs, base_path = fs_info
398
+
399
+ # Use fsspec filesystem with walk
400
+ async def search_fs() -> None:
401
+ try:
402
+ # Use find to get all files recursively (limit depth to avoid scanning huge trees)
403
+ all_files = await fs._find(base_path, maxdepth=10, withdirs=False)
404
+ for file_path in all_files:
405
+ if len(matches) >= max_matches:
406
+ return
407
+
408
+ # Skip directories we don't want to search
409
+ parts = file_path.split("/")
410
+ if any(part in SKIP_DIRS for part in parts):
411
+ continue
412
+
413
+ # Get relative path
414
+ if file_path.startswith(base_path):
415
+ rel_path = file_path[len(base_path) :].lstrip("/")
416
+ else:
417
+ rel_path = file_path
418
+
419
+ try:
420
+ content = await fs._cat_file(file_path)
421
+ if isinstance(content, bytes):
422
+ content = content.decode("utf-8")
423
+
424
+ for line_num, line in enumerate(content.splitlines(), 1):
425
+ for match in regex.finditer(line):
426
+ submatches = [
427
+ SubmatchInfo.create(match.group(), match.start(), match.end())
428
+ ]
429
+ matches.append(
430
+ FindMatch.create(
431
+ path=rel_path,
432
+ lines=line.strip(),
433
+ line_number=line_num,
434
+ absolute_offset=match.start(),
435
+ submatches=submatches,
436
+ )
437
+ )
438
+ if len(matches) >= max_matches:
439
+ return
440
+ except (UnicodeDecodeError, PermissionError, OSError):
441
+ continue
442
+ except Exception: # noqa: BLE001
443
+ pass
444
+
445
+ await search_fs()
446
+ else:
447
+ # Fallback to local Path operations
448
+ working_path = Path(state.working_dir)
449
+
450
+ def search_dir(dir_path: Path) -> None:
451
+ if len(matches) >= max_matches:
452
+ return
453
+
454
+ for entry in dir_path.iterdir():
455
+ if len(matches) >= max_matches:
456
+ return
457
+
458
+ if entry.is_dir():
459
+ if entry.name not in SKIP_DIRS:
460
+ search_dir(entry)
461
+ elif entry.is_file():
462
+ try:
463
+ content = entry.read_text(encoding="utf-8")
464
+ for line_num, line in enumerate(content.splitlines(), 1):
465
+ for match in regex.finditer(line):
466
+ rel_path = str(entry.relative_to(working_path))
467
+ submatches = [
468
+ SubmatchInfo.create(match.group(), match.start(), match.end())
469
+ ]
470
+ matches.append(
471
+ FindMatch.create(
472
+ path=rel_path,
473
+ lines=line.strip(),
474
+ line_number=line_num,
475
+ absolute_offset=match.start(),
476
+ submatches=submatches,
477
+ )
478
+ )
479
+ if len(matches) >= max_matches:
480
+ return
481
+ except (UnicodeDecodeError, PermissionError, OSError):
482
+ continue
483
+
484
+ search_dir(working_path)
485
+
486
+ return matches
487
+
488
+
489
+ @router.get("/find/file")
490
+ async def find_files(
491
+ state: StateDep,
492
+ query: str = Query(),
493
+ dirs: str = Query(default="false"),
494
+ ) -> list[str]:
495
+ """Find files by name pattern (glob-style matching)."""
496
+ include_dirs = dirs.lower() == "true"
497
+ max_results = 100
498
+ fs_info = _get_fs(state)
499
+
500
+ # Fast path: use ripgrep for local filesystems (files only, not dirs)
501
+ if not include_dirs and _has_ripgrep():
502
+ if fs_info is not None:
503
+ fs, base_path = fs_info
504
+ if _is_local_fs(fs):
505
+ return await _find_files_with_ripgrep(query, base_path, max_results)
506
+ else:
507
+ return await _find_files_with_ripgrep(query, state.working_dir, max_results)
508
+
509
+ # Slow path: manual file iteration
510
+ results: list[str] = []
511
+
512
+ if fs_info is not None:
513
+ fs, base_path = fs_info
514
+ # Use fsspec filesystem
515
+ try:
516
+ # Get all entries recursively (limit depth to avoid scanning huge trees)
517
+ all_entries = await fs._find(base_path, maxdepth=10, withdirs=include_dirs)
518
+ for entry_path in all_entries:
519
+ if len(results) >= max_results:
520
+ break
521
+
522
+ # Skip directories we don't want to search
523
+ parts = entry_path.split("/")
524
+ if any(part in SKIP_DIRS for part in parts):
525
+ continue
526
+
527
+ name = parts[-1] if parts else entry_path
528
+ if fnmatch.fnmatch(name, query):
529
+ # Get relative path
530
+ if entry_path.startswith(base_path):
531
+ rel_path = entry_path[len(base_path) :].lstrip("/")
532
+ else:
533
+ rel_path = entry_path
534
+ results.append(rel_path)
535
+ except Exception: # noqa: BLE001
536
+ pass
537
+ else:
538
+ # Fallback to local Path operations
539
+ working_path = Path(state.working_dir)
540
+
541
+ def search_dir(dir_path: Path) -> None:
542
+ if len(results) >= max_results:
543
+ return
544
+
545
+ for entry in dir_path.iterdir():
546
+ if len(results) >= max_results:
547
+ return
548
+
549
+ if entry.is_dir():
550
+ if entry.name not in SKIP_DIRS:
551
+ if include_dirs and fnmatch.fnmatch(entry.name, query):
552
+ results.append(str(entry.relative_to(working_path)))
553
+ search_dir(entry)
554
+ elif entry.is_file() and fnmatch.fnmatch(entry.name, query):
555
+ results.append(str(entry.relative_to(working_path)))
556
+
557
+ search_dir(working_path)
558
+
559
+ return sorted(results)
560
+
561
+
562
+ @router.get("/find/symbol")
563
+ async def find_symbols(state: StateDep, query: str = Query()) -> list[Symbol]:
564
+ """Find workspace symbols.
565
+
566
+ Returns empty list - LSP symbol search not yet implemented.
567
+ """
568
+ _ = state
569
+ _ = query
570
+ # TODO: Integrate with LSP or implement basic symbol extraction
571
+ return []
@@ -0,0 +1,94 @@
1
+ """Global routes (health, events)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from fastapi import APIRouter
10
+ from sse_starlette.sse import EventSourceResponse
11
+
12
+ from agentpool_server.opencode_server.dependencies import StateDep
13
+ from agentpool_server.opencode_server.models import ( # noqa: TC001
14
+ Event,
15
+ HealthResponse,
16
+ ServerConnectedEvent,
17
+ )
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import AsyncGenerator
22
+
23
+ from agentpool_server.opencode_server.state import ServerState
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+ router = APIRouter(tags=["global"])
28
+
29
+ VERSION = "0.1.0"
30
+
31
+
32
+ @router.get("/global/health")
33
+ async def get_health() -> HealthResponse:
34
+ """Get server health status."""
35
+ return HealthResponse(healthy=True, version=VERSION)
36
+
37
+
38
+ def _serialize_event(event: Event, wrap_payload: bool = False) -> str:
39
+ """Serialize event, optionally wrapping in payload structure."""
40
+ import json
41
+
42
+ event_data = event.model_dump(by_alias=True, exclude_none=True)
43
+ if wrap_payload:
44
+ return json.dumps({"payload": event_data})
45
+ return json.dumps(event_data)
46
+
47
+
48
+ async def _event_generator(
49
+ state: ServerState, *, wrap_payload: bool = False
50
+ ) -> AsyncGenerator[dict[str, Any]]:
51
+ """Generate SSE events."""
52
+ queue: asyncio.Queue[Event] = asyncio.Queue()
53
+ state.event_subscribers.append(queue)
54
+ subscriber_count = len(state.event_subscribers)
55
+ logger.info("SSE: New client connected (total subscribers: %s)", subscriber_count)
56
+
57
+ # Trigger first subscriber callback if this is the first connection
58
+ if (
59
+ subscriber_count == 1
60
+ and not state._first_subscriber_triggered
61
+ and state.on_first_subscriber is not None
62
+ ):
63
+ state._first_subscriber_triggered = True
64
+ state.create_background_task(state.on_first_subscriber(), name="on_first_subscriber")
65
+
66
+ try:
67
+ # Send initial connected event
68
+ connected = ServerConnectedEvent()
69
+ data = _serialize_event(connected, wrap_payload=wrap_payload)
70
+ logger.info("SSE: Sending connected event: %s", data)
71
+ yield {"data": data}
72
+ # Stream events
73
+ while True:
74
+ event = await queue.get()
75
+ data = _serialize_event(event, wrap_payload=wrap_payload)
76
+ logger.info("SSE: Sending event: %s", event.type)
77
+ yield {"data": data}
78
+ finally:
79
+ state.event_subscribers.remove(queue)
80
+ logger.info(
81
+ "SSE: Client disconnected (remaining subscribers: %s)", len(state.event_subscribers)
82
+ )
83
+
84
+
85
+ @router.get("/global/event")
86
+ async def get_global_events(state: StateDep) -> EventSourceResponse:
87
+ """Get global events as SSE stream (uses payload wrapper)."""
88
+ return EventSourceResponse(_event_generator(state, wrap_payload=True), sep="\n")
89
+
90
+
91
+ @router.get("/event")
92
+ async def get_events(state: StateDep) -> EventSourceResponse:
93
+ """Get events as SSE stream (no payload wrapper)."""
94
+ return EventSourceResponse(_event_generator(state, wrap_payload=False), sep="\n")