agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,139 @@
1
+ """TUI routes for external control of the TUI.
2
+
3
+ These endpoints allow external integrations (e.g., VSCode extension) to control
4
+ the TUI by broadcasting events that the TUI listens for via SSE.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from fastapi import APIRouter
12
+ from pydantic import BaseModel, Field
13
+
14
+ from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
15
+ from agentpool_server.opencode_server.models.events import (
16
+ TuiCommandExecuteEvent,
17
+ TuiPromptAppendEvent,
18
+ TuiToastShowEvent,
19
+ )
20
+
21
+
22
+ router = APIRouter(prefix="/tui", tags=["tui"])
23
+
24
+
25
+ class AppendPromptRequest(BaseModel):
26
+ """Request body for appending text to the prompt."""
27
+
28
+ text: str = Field(..., description="Text to append to the prompt")
29
+
30
+
31
+ class ExecuteCommandRequest(BaseModel):
32
+ """Request body for executing a TUI command."""
33
+
34
+ command: str = Field(
35
+ ...,
36
+ description="Command to execute (e.g., 'prompt.submit', 'prompt.clear', 'session.new')",
37
+ )
38
+
39
+
40
+ class ShowToastRequest(BaseModel):
41
+ """Request body for showing a toast notification."""
42
+
43
+ title: str | None = Field(None, description="Optional toast title")
44
+ message: str = Field(..., description="Toast message")
45
+ variant: Literal["info", "success", "warning", "error"] = Field(
46
+ "info", description="Toast variant"
47
+ )
48
+ duration: int = Field(5000, description="Duration in milliseconds")
49
+
50
+
51
+ @router.post("/append-prompt")
52
+ async def append_prompt(request: AppendPromptRequest, state: StateDep) -> bool:
53
+ """Append text to the TUI prompt.
54
+
55
+ Used by external integrations (e.g., VSCode) to insert text like file
56
+ references into the prompt input.
57
+ """
58
+ await state.broadcast_event(TuiPromptAppendEvent.create(request.text))
59
+ return True
60
+
61
+
62
+ @router.post("/submit-prompt")
63
+ async def submit_prompt(state: StateDep) -> bool:
64
+ """Submit the current prompt.
65
+
66
+ Triggers the TUI to submit whatever is currently in the prompt input.
67
+ """
68
+ await state.broadcast_event(TuiCommandExecuteEvent.create("prompt.submit"))
69
+ return True
70
+
71
+
72
+ @router.post("/clear-prompt")
73
+ async def clear_prompt(state: StateDep) -> bool:
74
+ """Clear the TUI prompt.
75
+
76
+ Clears any text currently in the prompt input.
77
+ """
78
+ await state.broadcast_event(TuiCommandExecuteEvent.create("prompt.clear"))
79
+ return True
80
+
81
+
82
+ @router.post("/execute-command")
83
+ async def execute_command(request: ExecuteCommandRequest, state: StateDep) -> bool:
84
+ """Execute a TUI command.
85
+
86
+ Available commands:
87
+ - session.list, session.new, session.share, session.interrupt, session.compact
88
+ - session.page.up, session.page.down, session.half.page.up, session.half.page.down
89
+ - session.first, session.last
90
+ - prompt.clear, prompt.submit
91
+ - agent.cycle
92
+ """
93
+ await state.broadcast_event(TuiCommandExecuteEvent.create(request.command))
94
+ return True
95
+
96
+
97
+ @router.post("/show-toast")
98
+ async def show_toast(request: ShowToastRequest, state: StateDep) -> bool:
99
+ """Show a toast notification in the TUI."""
100
+ await state.broadcast_event(
101
+ TuiToastShowEvent.create(
102
+ message=request.message,
103
+ variant=request.variant,
104
+ title=request.title,
105
+ duration=request.duration,
106
+ )
107
+ )
108
+ return True
109
+
110
+
111
+ # Additional convenience endpoints matching OpenCode's API
112
+
113
+
114
+ @router.post("/open-help")
115
+ async def open_help(state: StateDep) -> bool:
116
+ """Open the help dialog."""
117
+ await state.broadcast_event(TuiCommandExecuteEvent.create("help.open"))
118
+ return True
119
+
120
+
121
+ @router.post("/open-sessions")
122
+ async def open_sessions(state: StateDep) -> bool:
123
+ """Open the session selector."""
124
+ await state.broadcast_event(TuiCommandExecuteEvent.create("session.list"))
125
+ return True
126
+
127
+
128
+ @router.post("/open-themes")
129
+ async def open_themes(state: StateDep) -> bool:
130
+ """Open the theme selector."""
131
+ await state.broadcast_event(TuiCommandExecuteEvent.create("theme.list"))
132
+ return True
133
+
134
+
135
+ @router.post("/open-models")
136
+ async def open_models(state: StateDep) -> bool:
137
+ """Open the model selector."""
138
+ await state.broadcast_event(TuiCommandExecuteEvent.create("model.list"))
139
+ return True
@@ -0,0 +1,430 @@
1
+ """OpenCode-compatible FastAPI server.
2
+
3
+ This server implements the OpenCode API endpoints to allow OpenCode SDK clients
4
+ to interact with AgentPool agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from fastapi import FastAPI, Request # noqa: TC002
14
+ from fastapi.encoders import jsonable_encoder
15
+ from fastapi.exceptions import RequestValidationError
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import JSONResponse, RedirectResponse, Response
18
+
19
+ from agentpool import AgentPool
20
+ from agentpool_server.opencode_server.routes import (
21
+ agent_router,
22
+ app_router,
23
+ config_router,
24
+ file_router,
25
+ global_router,
26
+ lsp_router,
27
+ message_router,
28
+ pty_router,
29
+ session_router,
30
+ tui_router,
31
+ )
32
+ from agentpool_server.opencode_server.state import ServerState
33
+
34
+
35
+ class OpenCodeJSONResponse(JSONResponse):
36
+ """Custom JSON response that excludes None values (like OpenCode does)."""
37
+
38
+ def render(self, content: Any) -> bytes:
39
+ return super().render(jsonable_encoder(content, exclude_none=True))
40
+
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import AsyncIterator, Set as AbstractSet
44
+
45
+
46
+ VERSION = "0.1.0"
47
+
48
+
49
+ async def check_pypi_version(package: str = "agentpool") -> str | None:
50
+ """Check PyPI for the latest version of a package.
51
+
52
+ Args:
53
+ package: Package name to check
54
+
55
+ Returns:
56
+ Latest version string, or None if check fails
57
+ """
58
+ import httpx
59
+
60
+ try:
61
+ async with httpx.AsyncClient(timeout=5.0) as client:
62
+ response = await client.get(f"https://pypi.org/pypi/{package}/json")
63
+ if response.status_code == 200: # noqa: PLR2004
64
+ data: dict[str, Any] = response.json()
65
+ info: dict[str, Any] = data.get("info", {})
66
+ version: str | None = info.get("version")
67
+ return version
68
+ except Exception: # noqa: BLE001
69
+ pass
70
+ return None
71
+
72
+
73
+ def compare_versions(current: str, latest: str) -> bool:
74
+ """Check if latest version is newer than current.
75
+
76
+ Args:
77
+ current: Current version string
78
+ latest: Latest version string
79
+
80
+ Returns:
81
+ True if latest is newer than current
82
+ """
83
+ from packaging.version import Version
84
+
85
+ try:
86
+ return Version(latest) > Version(current)
87
+ except Exception: # noqa: BLE001
88
+ return False
89
+
90
+
91
+ def create_app( # noqa: PLR0915
92
+ *,
93
+ pool: AgentPool[Any],
94
+ agent_name: str | None = None,
95
+ working_dir: str | None = None,
96
+ ) -> FastAPI:
97
+ """Create the FastAPI application.
98
+
99
+ Args:
100
+ pool: AgentPool for session persistence and agent access.
101
+ agent_name: Name of the agent to use for handling messages.
102
+ If None, uses the first agent in the pool.
103
+ working_dir: Working directory for file operations. Defaults to cwd.
104
+
105
+ Returns:
106
+ Configured FastAPI application.
107
+
108
+ Raises:
109
+ ValueError: If specified agent_name not found or pool has no agents.
110
+ """
111
+ # Resolve the agent from the pool
112
+ import logfire
113
+
114
+ if agent_name:
115
+ agent = pool.all_agents.get(agent_name)
116
+ if agent is None:
117
+ msg = f"Agent '{agent_name}' not found in pool"
118
+ raise ValueError(msg)
119
+ else:
120
+ # Use first agent as default
121
+ agent = next(iter(pool.all_agents.values()), None)
122
+ if agent is None:
123
+ msg = "Pool has no agents"
124
+ raise ValueError(msg)
125
+
126
+ state = ServerState(
127
+ working_dir=working_dir or str(Path.cwd()),
128
+ pool=pool,
129
+ agent=agent,
130
+ )
131
+
132
+ # Set up todo change callback to broadcast events
133
+ async def on_todo_change(tracker: Any) -> None:
134
+ """Broadcast todo updates to all active sessions."""
135
+ from agentpool_server.opencode_server.models.events import Todo, TodoUpdatedEvent
136
+
137
+ # Convert tracker entries to OpenCode Todo models
138
+ todos = [
139
+ Todo(id=e.id, content=e.content, status=e.status, priority=e.priority)
140
+ for e in tracker.entries
141
+ ]
142
+ # Broadcast to all active sessions
143
+ for session_id in state.sessions:
144
+ event = TodoUpdatedEvent.create(session_id=session_id, todos=todos)
145
+ await state.broadcast_event(event)
146
+
147
+ pool.todos.on_change = on_todo_change
148
+
149
+ # Watchers for VCS and file events
150
+ git_branch_watcher: Any = None
151
+ project_file_watcher: Any = None
152
+
153
+ @asynccontextmanager
154
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
155
+ nonlocal git_branch_watcher, project_file_watcher
156
+ import logging
157
+
158
+ from watchfiles import Change
159
+
160
+ from agentpool.utils.file_watcher import FileWatcher, GitBranchWatcher
161
+ from agentpool_server.opencode_server.models.events import (
162
+ FileWatcherUpdatedEvent,
163
+ VcsBranchUpdatedEvent,
164
+ )
165
+
166
+ log = logging.getLogger(__name__)
167
+
168
+ # --- Git branch watcher ---
169
+ async def on_branch_change(branch: str | None) -> None:
170
+ """Broadcast branch change to all subscribers."""
171
+ log.info("Broadcasting vcs.branch.updated event: %s", branch)
172
+ event = VcsBranchUpdatedEvent.create(branch=branch)
173
+ await state.broadcast_event(event)
174
+
175
+ log.info("Setting up GitBranchWatcher for: %s", state.working_dir)
176
+ git_branch_watcher = GitBranchWatcher(
177
+ repo_path=state.working_dir,
178
+ callback=on_branch_change,
179
+ )
180
+ await git_branch_watcher.start()
181
+ log.info("GitBranchWatcher started, current branch: %s", git_branch_watcher.current_branch)
182
+
183
+ # --- Project file watcher ---
184
+ # Map watchfiles Change types to OpenCode event types
185
+ change_type_map: dict[Change, str] = {
186
+ Change.added: "add",
187
+ Change.modified: "change",
188
+ Change.deleted: "unlink",
189
+ }
190
+
191
+ async def on_file_change(changes: AbstractSet[tuple[Change, str]]) -> None:
192
+ """Broadcast file changes to all subscribers."""
193
+ for change_type, file_path in changes:
194
+ # Skip .git directory changes
195
+ if "/.git/" in file_path or file_path.endswith("/.git"):
196
+ continue
197
+ event_type = change_type_map.get(change_type, "change")
198
+ log.info("Broadcasting file.watcher.updated: %s %s", event_type, file_path)
199
+ event = FileWatcherUpdatedEvent.create(file=file_path, event=event_type) # type: ignore[arg-type]
200
+ await state.broadcast_event(event)
201
+
202
+ log.info("Setting up project FileWatcher for: %s", state.working_dir)
203
+ project_file_watcher = FileWatcher(
204
+ paths=[state.working_dir],
205
+ callback=on_file_change,
206
+ debounce=500, # 500ms debounce to batch rapid changes
207
+ )
208
+ await project_file_watcher.start()
209
+ log.info("Project FileWatcher started")
210
+
211
+ # --- Version update check (triggered when first client connects) ---
212
+ async def check_for_updates() -> None:
213
+ """Check PyPI for updates and notify via toast."""
214
+ from agentpool import __version__ as current_version
215
+ from agentpool_server.opencode_server.models.events import TuiToastShowEvent
216
+
217
+ latest = await check_pypi_version("agentpool")
218
+ if latest and compare_versions(current_version, latest):
219
+ log.info("Update available: %s -> %s", current_version, latest)
220
+ event = TuiToastShowEvent.create(
221
+ title="Update Available",
222
+ message=f"agentpool {latest} is available (current: {current_version})",
223
+ variant="info",
224
+ duration=10000,
225
+ )
226
+ await state.broadcast_event(event)
227
+
228
+ # Register callback to run when first SSE client connects
229
+ state.on_first_subscriber = check_for_updates
230
+
231
+ yield
232
+
233
+ # Shutdown - clean up
234
+ pool.todos.on_change = None
235
+ if git_branch_watcher:
236
+ await git_branch_watcher.stop()
237
+ if project_file_watcher:
238
+ await project_file_watcher.stop()
239
+ # Clean up LSP servers
240
+ if state.lsp_manager is not None:
241
+ await state.lsp_manager.stop_all()
242
+
243
+ app = FastAPI(
244
+ title="OpenCode-Compatible API",
245
+ description="AgentPool server with OpenCode API compatibility",
246
+ version=VERSION,
247
+ lifespan=lifespan,
248
+ default_response_class=OpenCodeJSONResponse,
249
+ )
250
+
251
+ # Add CORS middleware (required for OpenCode TUI)
252
+ app.add_middleware(
253
+ CORSMiddleware,
254
+ allow_origins=["*"],
255
+ allow_credentials=True,
256
+ allow_methods=["*"],
257
+ allow_headers=["*"],
258
+ )
259
+
260
+ # Store state on app for access in routes
261
+ app.state.server_state = state
262
+
263
+ @app.exception_handler(RequestValidationError)
264
+ async def validation_exception_handler(
265
+ request: Request, exc: RequestValidationError
266
+ ) -> JSONResponse:
267
+ body = await request.body()
268
+ print(f"Validation error for {request.url}")
269
+ print(f"Body: {body.decode()}")
270
+ print(f"Errors: {exc.errors()}")
271
+ return JSONResponse(
272
+ status_code=422,
273
+ content={"detail": exc.errors(), "body": body.decode()},
274
+ )
275
+
276
+ # Register routers
277
+ app.include_router(global_router)
278
+ app.include_router(app_router)
279
+ app.include_router(config_router)
280
+ app.include_router(session_router)
281
+ app.include_router(message_router)
282
+ app.include_router(file_router)
283
+ app.include_router(agent_router)
284
+ app.include_router(pty_router)
285
+ app.include_router(tui_router)
286
+ app.include_router(lsp_router)
287
+
288
+ # OpenAPI doc redirect
289
+ @app.get("/doc")
290
+ async def get_doc() -> RedirectResponse:
291
+ """Redirect to OpenAPI docs."""
292
+ return RedirectResponse(url="/docs")
293
+
294
+ # Proxy catch-all for OpenCode's hosted web UI
295
+ # This must be registered LAST so it doesn't catch API routes
296
+ @app.api_route("/{path:path}", methods=["GET", "HEAD", "OPTIONS"])
297
+ async def proxy_web_ui(request: Request, path: str) -> Response:
298
+ """Proxy unmatched GET requests to OpenCode's hosted web UI.
299
+
300
+ This allows users to open http://localhost:4096 in a browser and get
301
+ the full OpenCode web interface, which then makes API calls back to
302
+ this local server for all data operations.
303
+ """
304
+ import httpx
305
+
306
+ # Build target URL
307
+ url = f"https://app.opencode.ai/{path}"
308
+ if request.url.query:
309
+ url += f"?{request.url.query}"
310
+
311
+ async with httpx.AsyncClient(timeout=30.0) as client:
312
+ # Forward the request
313
+ response = await client.request(
314
+ method=request.method,
315
+ url=url,
316
+ headers={"host": "app.opencode.ai"},
317
+ follow_redirects=True,
318
+ )
319
+
320
+ # Filter out hop-by-hop headers that shouldn't be forwarded
321
+ excluded_headers = {
322
+ "content-encoding",
323
+ "content-length",
324
+ "transfer-encoding",
325
+ "connection",
326
+ }
327
+ headers = {
328
+ k: v for k, v in response.headers.items() if k.lower() not in excluded_headers
329
+ }
330
+
331
+ return Response(
332
+ content=response.content,
333
+ status_code=response.status_code,
334
+ headers=headers,
335
+ media_type=response.headers.get("content-type"),
336
+ )
337
+
338
+ logfire.instrument_fastapi(app)
339
+ return app
340
+
341
+
342
+ class OpenCodeServer:
343
+ """OpenCode-compatible server wrapper.
344
+
345
+ Provides a convenient interface for running the server.
346
+ """
347
+
348
+ def __init__(
349
+ self,
350
+ pool: AgentPool[Any],
351
+ *,
352
+ host: str = "127.0.0.1",
353
+ port: int = 4096,
354
+ agent_name: str | None = None,
355
+ working_dir: str | None = None,
356
+ ) -> None:
357
+ """Initialize the server.
358
+
359
+ Args:
360
+ pool: AgentPool for session persistence and agent access.
361
+ host: Host to bind to.
362
+ port: Port to listen on.
363
+ agent_name: Name of the agent to use for handling messages.
364
+ working_dir: Working directory for file operations.
365
+ """
366
+ self.host = host
367
+ self.port = port
368
+ self.pool = pool
369
+ self.agent_name = agent_name
370
+ self.working_dir = working_dir
371
+ self._app: FastAPI | None = None
372
+
373
+ @property
374
+ def app(self) -> FastAPI:
375
+ """Get or create the FastAPI application."""
376
+ if self._app is None:
377
+ self._app = create_app(
378
+ pool=self.pool,
379
+ agent_name=self.agent_name,
380
+ working_dir=self.working_dir,
381
+ )
382
+ return self._app
383
+
384
+ def run(self) -> None:
385
+ """Run the server (blocking)."""
386
+ import uvicorn
387
+
388
+ uvicorn.run(self.app, host=self.host, port=self.port)
389
+
390
+ async def run_async(self) -> None:
391
+ """Run the server asynchronously."""
392
+ import uvicorn
393
+
394
+ config = uvicorn.Config(self.app, host=self.host, port=self.port, ws="websockets-sansio")
395
+ server = uvicorn.Server(config)
396
+ await server.serve()
397
+
398
+
399
+ def run_server(
400
+ pool: AgentPool[Any],
401
+ *,
402
+ host: str = "127.0.0.1",
403
+ port: int = 4096,
404
+ agent_name: str | None = None,
405
+ working_dir: str | None = None,
406
+ ) -> None:
407
+ """Run the OpenCode-compatible server.
408
+
409
+ Args:
410
+ pool: AgentPool for session persistence and agent access.
411
+ host: Host to bind to.
412
+ port: Port to listen on.
413
+ agent_name: Name of the agent to use for handling messages.
414
+ working_dir: Working directory for file operations.
415
+ """
416
+ server = OpenCodeServer(
417
+ pool,
418
+ host=host,
419
+ port=port,
420
+ agent_name=agent_name,
421
+ working_dir=working_dir,
422
+ )
423
+ server.run()
424
+
425
+
426
+ if __name__ == "__main__":
427
+ from agentpool import config_resources
428
+
429
+ pool = AgentPool(config_resources.CLAUDE_CODE_ASSISTANT)
430
+ run_server(pool)
@@ -0,0 +1,121 @@
1
+ """Server state management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Coroutine
6
+ from dataclasses import dataclass, field
7
+ import time
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ import asyncio
13
+
14
+ from agentpool import AgentPool
15
+ from agentpool.agents.base_agent import BaseAgent
16
+ from agentpool.diagnostics.lsp_manager import LSPManager
17
+ from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider
18
+ from agentpool_server.opencode_server.models import (
19
+ Event,
20
+ MessageWithParts,
21
+ Session,
22
+ SessionStatus,
23
+ Todo,
24
+ )
25
+
26
+ # Type alias for async callback
27
+ OnFirstSubscriberCallback = Callable[[], Coroutine[Any, Any, None]]
28
+
29
+
30
+ @dataclass
31
+ class ServerState:
32
+ """Shared state for the OpenCode server.
33
+
34
+ Uses AgentPool for session persistence and storage.
35
+ In-memory state tracks active sessions and runtime data.
36
+ """
37
+
38
+ working_dir: str
39
+ pool: AgentPool[Any]
40
+ agent: BaseAgent[Any, Any]
41
+ start_time: float = field(default_factory=time.time)
42
+
43
+ # Active sessions cache (session_id -> OpenCode Session model)
44
+ # This is a cache of sessions loaded from pool.sessions
45
+ sessions: dict[str, Session] = field(default_factory=dict)
46
+ session_status: dict[str, SessionStatus] = field(default_factory=dict)
47
+
48
+ # Message storage (session_id -> messages)
49
+ # Runtime cache - messages are also persisted via pool.storage
50
+ messages: dict[str, list[MessageWithParts]] = field(default_factory=dict)
51
+
52
+ # Todo storage (session_id -> todos)
53
+ # Uses pool.todos for persistence
54
+ todos: dict[str, list[Todo]] = field(default_factory=dict)
55
+
56
+ # Input providers for permission handling (session_id -> provider)
57
+ input_providers: dict[str, OpenCodeInputProvider] = field(default_factory=dict)
58
+
59
+ # SSE event subscribers
60
+ event_subscribers: list[asyncio.Queue[Event]] = field(default_factory=list)
61
+
62
+ # Callback for first subscriber connection (e.g., for update check)
63
+ on_first_subscriber: OnFirstSubscriberCallback | None = None
64
+ _first_subscriber_triggered: bool = field(default=False, repr=False)
65
+
66
+ # Background tasks (for cleanup on shutdown)
67
+ background_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
68
+
69
+ # LSP manager for language server integration (initialized lazily)
70
+ lsp_manager: LSPManager | None = None
71
+
72
+ def create_background_task(self, coro: Any, *, name: str | None = None) -> asyncio.Task[Any]:
73
+ """Create and track a background task."""
74
+ import asyncio
75
+
76
+ task = asyncio.create_task(coro, name=name)
77
+ self.background_tasks.add(task)
78
+ task.add_done_callback(self.background_tasks.discard)
79
+ return task
80
+
81
+ async def cleanup_tasks(self) -> None:
82
+ """Cancel and wait for all background tasks."""
83
+ for task in self.background_tasks:
84
+ task.cancel()
85
+ if self.background_tasks:
86
+ import asyncio
87
+
88
+ await asyncio.gather(*self.background_tasks, return_exceptions=True)
89
+ self.background_tasks.clear()
90
+
91
+ async def broadcast_event(self, event: Event) -> None:
92
+ """Broadcast an event to all SSE subscribers."""
93
+ print(f"Broadcasting event: {event.type} to {len(self.event_subscribers)} subscribers")
94
+ for queue in self.event_subscribers:
95
+ await queue.put(event)
96
+
97
+ def get_or_create_lsp_manager(self) -> LSPManager:
98
+ """Get or create the LSP manager.
99
+
100
+ Creates the LSP manager lazily using the agent's execution environment.
101
+
102
+ Returns:
103
+ The LSP manager instance.
104
+
105
+ Raises:
106
+ RuntimeError: If the agent doesn't have an execution environment.
107
+ """
108
+ if self.lsp_manager is not None:
109
+ return self.lsp_manager
110
+
111
+ from agentpool.diagnostics.lsp_manager import LSPManager
112
+
113
+ # Get the execution environment from the agent
114
+ env = getattr(self.agent, "env", None)
115
+ if env is None:
116
+ msg = "Agent does not have an execution environment for LSP"
117
+ raise RuntimeError(msg)
118
+
119
+ self.lsp_manager = LSPManager(env=env)
120
+ self.lsp_manager.register_defaults()
121
+ return self.lsp_manager
@@ -0,0 +1,8 @@
1
+ """Time utilities for OpenCode compatibility."""
2
+
3
+ import time
4
+
5
+
6
+ def now_ms() -> int:
7
+ """Return current time in milliseconds as integer (OpenCode format)."""
8
+ return int(time.time() * 1000)