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,25 @@
1
+ """Route handlers for the OpenCode API."""
2
+
3
+ from agentpool_server.opencode_server.routes.global_routes import router as global_router
4
+ from agentpool_server.opencode_server.routes.app_routes import router as app_router
5
+ from agentpool_server.opencode_server.routes.config_routes import router as config_router
6
+ from agentpool_server.opencode_server.routes.session_routes import router as session_router
7
+ from agentpool_server.opencode_server.routes.message_routes import router as message_router
8
+ from agentpool_server.opencode_server.routes.file_routes import router as file_router
9
+ from agentpool_server.opencode_server.routes.agent_routes import router as agent_router
10
+ from agentpool_server.opencode_server.routes.pty_routes import router as pty_router
11
+ from agentpool_server.opencode_server.routes.tui_routes import router as tui_router
12
+ from agentpool_server.opencode_server.routes.lsp_routes import router as lsp_router
13
+
14
+ __all__ = [
15
+ "agent_router",
16
+ "app_router",
17
+ "config_router",
18
+ "file_router",
19
+ "global_router",
20
+ "lsp_router",
21
+ "message_router",
22
+ "pty_router",
23
+ "session_router",
24
+ "tui_router",
25
+ ]
@@ -0,0 +1,442 @@
1
+ """Agent, command, MCP, LSP, formatter, and logging routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ import httpx
9
+ from llmling_models.auth.anthropic_auth import (
10
+ AnthropicTokenStore,
11
+ build_authorization_url,
12
+ exchange_code_for_token,
13
+ generate_pkce,
14
+ )
15
+ from pydantic import BaseModel, HttpUrl
16
+
17
+ from agentpool.mcp_server.manager import MCPManager
18
+ from agentpool.resource_providers import AggregatingResourceProvider
19
+ from agentpool_config.mcp_server import (
20
+ SSEMCPServerConfig,
21
+ StdioMCPServerConfig,
22
+ StreamableHTTPMCPServerConfig,
23
+ )
24
+ from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
25
+ from agentpool_server.opencode_server.models import ( # noqa: TC001
26
+ Agent,
27
+ Command,
28
+ LogRequest,
29
+ MCPStatus,
30
+ )
31
+
32
+
33
+ router = APIRouter(tags=["agent"])
34
+
35
+
36
+ @router.get("/agent")
37
+ async def list_agents(state: StateDep) -> list[Agent]:
38
+ """List available agents from the AgentPool.
39
+
40
+ Returns all agents with their configurations, suitable for the agent
41
+ switcher UI. Agents are marked as primary (visible in switcher) or
42
+ subagent (hidden, used internally).
43
+ """
44
+ if state.agent.agent_pool is None:
45
+ return [
46
+ Agent(
47
+ name="default",
48
+ description="Default AgentPool agent",
49
+ mode="primary",
50
+ default=True,
51
+ )
52
+ ]
53
+
54
+ pool = state.agent.agent_pool
55
+ agents: list[Agent] = []
56
+ first_agent_name = next(iter(pool.all_agents.keys()), None)
57
+
58
+ for name, agent in pool.all_agents.items():
59
+ # Get description from agent
60
+ agents.append(
61
+ Agent(
62
+ name=name,
63
+ description=agent.description or f"Agent: {name}",
64
+ # model=AgentModel(model_id=agent.model_name or "unknown", provider_id=""),
65
+ mode="primary", # All agents visible for now; add hidden config later
66
+ default=(name == first_agent_name), # First agent is default
67
+ )
68
+ )
69
+
70
+ return (
71
+ agents
72
+ if agents
73
+ else [Agent(name="default", description="Default agent", mode="primary", default=True)]
74
+ )
75
+
76
+
77
+ @router.get("/command")
78
+ async def list_commands(state: StateDep) -> list[Command]:
79
+ """List available slash commands.
80
+
81
+ Commands are derived from MCP prompts available to the agent.
82
+ """
83
+ try:
84
+ prompts = await state.agent.tools.list_prompts()
85
+ return [Command(name=p.name, description=p.description or "") for p in prompts]
86
+ except Exception: # noqa: BLE001
87
+ return []
88
+
89
+
90
+ @router.get("/mcp")
91
+ async def get_mcp_status(state: StateDep) -> dict[str, MCPStatus]:
92
+ """Get MCP server status.
93
+
94
+ Returns status for each connected MCP server.
95
+ """
96
+ # Use agent's get_mcp_server_info method which handles different agent types
97
+ server_info = state.agent.get_mcp_server_info()
98
+
99
+ # Convert MCPServerStatus dataclass to MCPStatus response model
100
+ return {
101
+ name: MCPStatus(
102
+ name=status.name,
103
+ status=status.status,
104
+ error=status.error,
105
+ )
106
+ for name, status in server_info.items()
107
+ }
108
+
109
+
110
+ class AddMCPServerRequest(BaseModel):
111
+ """Request to add an MCP server dynamically."""
112
+
113
+ command: str | None = None
114
+ """Command to run (for stdio servers)."""
115
+
116
+ args: list[str] | None = None
117
+ """Arguments for the command."""
118
+
119
+ url: str | None = None
120
+ """URL for HTTP/SSE servers."""
121
+
122
+ env: dict[str, str] | None = None
123
+ """Environment variables for the server."""
124
+
125
+
126
+ @router.post("/mcp")
127
+ async def add_mcp_server(request: AddMCPServerRequest, state: StateDep) -> MCPStatus:
128
+ """Add an MCP server dynamically.
129
+
130
+ Supports stdio servers (command + args) or HTTP/SSE servers (url).
131
+ """
132
+ # Build the config based on request
133
+ # Note: client_id is auto-generated from command/url, custom names not supported
134
+ config: SSEMCPServerConfig | StdioMCPServerConfig | StreamableHTTPMCPServerConfig
135
+ if request.url:
136
+ # HTTP-based server
137
+ if request.url.endswith("/sse"):
138
+ config = SSEMCPServerConfig(url=HttpUrl(request.url))
139
+ else:
140
+ config = StreamableHTTPMCPServerConfig(url=HttpUrl(request.url))
141
+ elif request.command: # Stdio server
142
+ args = request.args or []
143
+ config = StdioMCPServerConfig(command=request.command, args=args, env=request.env)
144
+ else:
145
+ detail = "Must provide either 'command' (for stdio) or 'url' (for HTTP/SSE)"
146
+ raise HTTPException(status_code=400, detail=detail)
147
+
148
+ # Find the MCPManager and add the server
149
+ manager: MCPManager | None = None
150
+ for provider in state.agent.tools.external_providers:
151
+ if isinstance(provider, MCPManager):
152
+ manager = provider
153
+ break
154
+ if isinstance(provider, AggregatingResourceProvider):
155
+ for nested in provider.providers:
156
+ if isinstance(nested, MCPManager):
157
+ manager = nested
158
+ break
159
+
160
+ if manager is None:
161
+ raise HTTPException(status_code=400, detail="No MCP manager available")
162
+
163
+ try:
164
+ await manager.setup_server(config, add_to_config=True)
165
+ return MCPStatus(name=config.client_id, status="connected")
166
+ except Exception as e:
167
+ raise HTTPException(status_code=500, detail=f"Failed to add MCP server: {e}") from e
168
+
169
+
170
+ @router.post("/log")
171
+ async def log(request: LogRequest, state: StateDep) -> bool:
172
+ """Write a log entry.
173
+
174
+ TODO: Integrate with proper logging.
175
+ """
176
+ _ = state # unused for now
177
+ print(f"[{request.level}] {request.service}: {request.message}")
178
+ return True
179
+
180
+
181
+ @router.get("/experimental/tool/ids")
182
+ async def list_tool_ids(state: StateDep) -> list[str]:
183
+ """List all available tool IDs.
184
+
185
+ Returns a list of tool names that are available to the agent.
186
+ OpenCode expects: Array<string>
187
+ """
188
+ try:
189
+ tools = await state.agent.tools.get_tools()
190
+ return [tool.name for tool in tools]
191
+ except Exception: # noqa: BLE001
192
+ return []
193
+
194
+
195
+ class ToolListItem(BaseModel):
196
+ """Tool info matching OpenCode SDK ToolListItem type."""
197
+
198
+ id: str
199
+ description: str
200
+ parameters: dict[str, Any]
201
+
202
+
203
+ @router.get("/experimental/tool")
204
+ async def list_tools_with_schemas( # noqa: D417
205
+ state: StateDep,
206
+ provider: str | None = None,
207
+ model: str | None = None,
208
+ ) -> list[ToolListItem]:
209
+ """List tools with their JSON schemas.
210
+
211
+ Args:
212
+ provider: Optional provider filter (not used currently)
213
+ model: Optional model filter (not used currently)
214
+
215
+ Returns list of tools matching OpenCode's ToolListItem format:
216
+ - id: string
217
+ - description: string
218
+ - parameters: unknown (JSON schema)
219
+ """
220
+ _ = provider, model # Currently unused, for future filtering
221
+
222
+ try:
223
+ tools = await state.agent.tools.get_tools()
224
+ result = []
225
+ for tool in tools:
226
+ # Extract parameters schema from the OpenAI function schema
227
+ schema = tool.schema
228
+ params = schema.get("function", {}).get("parameters", {})
229
+ item = ToolListItem(id=tool.name, description=tool.description or "", parameters=params)
230
+ result.append(item)
231
+ except Exception: # noqa: BLE001
232
+ return []
233
+ else:
234
+ return result
235
+
236
+
237
+ @router.get("/lsp")
238
+ async def get_lsp_status(state: StateDep) -> list[dict[str, Any]]:
239
+ """Get LSP server status.
240
+
241
+ Returns status of all running LSP servers.
242
+ """
243
+ try:
244
+ lsp_manager = state.get_or_create_lsp_manager()
245
+ servers = []
246
+ for server_id, server_state in lsp_manager._servers.items():
247
+ # OpenCode TUI expects "connected" or "error" for status colors
248
+ status = "connected" if server_state.initialized else "error"
249
+ servers.append({
250
+ "id": server_id,
251
+ "name": server_id,
252
+ "status": status,
253
+ "language": server_state.language,
254
+ "root": server_state.root_uri, # TUI uses "root" not "rootUri"
255
+ })
256
+ except Exception: # noqa: BLE001
257
+ return []
258
+ else:
259
+ return servers
260
+
261
+
262
+ @router.get("/formatter")
263
+ async def get_formatter_status(state: StateDep) -> list[dict[str, Any]]:
264
+ """Get formatter status.
265
+
266
+ Returns empty list - formatters not supported yet.
267
+ """
268
+ _ = state
269
+ return []
270
+
271
+
272
+ @router.get("/provider/auth")
273
+ async def get_provider_auth(state: StateDep) -> dict[str, list[dict[str, Any]]]:
274
+ """Get provider authentication methods.
275
+
276
+ Returns available OAuth providers with their auth methods.
277
+ """
278
+ _ = state
279
+ return {
280
+ "anthropic": [
281
+ {
282
+ "type": "oauth",
283
+ "label": "Connect Claude Max/Pro",
284
+ "method": "code",
285
+ }
286
+ ],
287
+ "copilot": [
288
+ {
289
+ "type": "oauth",
290
+ "label": "Connect GitHub Copilot",
291
+ "method": "device_code",
292
+ }
293
+ ],
294
+ }
295
+
296
+
297
+ # Store for active OAuth flows (in production, use Redis or similar)
298
+ _oauth_flows: dict[str, dict[str, Any]] = {}
299
+
300
+
301
+ @router.post("/provider/{provider_id}/oauth/authorize")
302
+ async def oauth_authorize(provider_id: str, state: StateDep) -> dict[str, Any]:
303
+ """Start OAuth authorization flow for a provider.
304
+
305
+ Returns URL and instructions for the user to complete authorization.
306
+ """
307
+ _ = state
308
+
309
+ if provider_id == "anthropic":
310
+ verifier, challenge = generate_pkce()
311
+ auth_url = build_authorization_url(verifier, challenge)
312
+
313
+ # Store verifier for callback
314
+ _oauth_flows[f"anthropic:{verifier}"] = {"verifier": verifier}
315
+
316
+ return {
317
+ "url": auth_url,
318
+ "instructions": "Sign in with your Anthropic account and copy the authorization code",
319
+ "method": "code",
320
+ "state": verifier,
321
+ }
322
+
323
+ if provider_id == "copilot":
324
+ async with httpx.AsyncClient(timeout=30.0) as client:
325
+ resp = await client.post(
326
+ "https://github.com/login/device/code",
327
+ headers={
328
+ "accept": "application/json",
329
+ "editor-version": "Neovim/0.6.1",
330
+ "editor-plugin-version": "copilot.vim/1.16.0",
331
+ "content-type": "application/json",
332
+ "user-agent": "GithubCopilot/1.155.0",
333
+ },
334
+ json={"client_id": "Iv1.b507a08c87ecfe98", "scope": "read:user"},
335
+ )
336
+ resp.raise_for_status()
337
+ data = resp.json()
338
+
339
+ device_code = data["device_code"]
340
+ user_code = data["user_code"]
341
+ verification_uri = data["verification_uri"]
342
+
343
+ # Store device_code for callback
344
+ _oauth_flows[f"copilot:{device_code}"] = {"device_code": device_code}
345
+
346
+ return {
347
+ "url": verification_uri,
348
+ "instructions": f"Enter code: {user_code}",
349
+ "method": "device_code",
350
+ "user_code": user_code,
351
+ "device_code": device_code,
352
+ }
353
+
354
+ raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_id}")
355
+
356
+
357
+ @router.post("/provider/{provider_id}/oauth/callback")
358
+ async def oauth_callback(
359
+ provider_id: str,
360
+ state: StateDep,
361
+ code: str | None = None,
362
+ device_code: str | None = None,
363
+ verifier: str | None = None,
364
+ ) -> dict[str, Any]:
365
+ """Handle OAuth callback/code exchange.
366
+
367
+ For Anthropic: exchanges authorization code for tokens.
368
+ For Copilot: polls for token using device code.
369
+ """
370
+ _ = state
371
+
372
+ if provider_id == "anthropic":
373
+ if not code or not verifier:
374
+ raise HTTPException(
375
+ status_code=400, detail="Missing code or verifier for Anthropic OAuth"
376
+ )
377
+
378
+ try:
379
+ token = exchange_code_for_token(code, verifier)
380
+ # Save token
381
+ store = AnthropicTokenStore()
382
+ store.save(token)
383
+
384
+ # Clean up flow state
385
+ _oauth_flows.pop(f"anthropic:{verifier}", None)
386
+ except Exception as e:
387
+ raise HTTPException(status_code=400, detail=str(e)) from e
388
+ else:
389
+ return {
390
+ "type": "success",
391
+ "access": token.access_token,
392
+ "refresh": token.refresh_token,
393
+ "expires": token.expires_at,
394
+ }
395
+
396
+ if provider_id == "copilot":
397
+ if not device_code:
398
+ raise HTTPException(
399
+ status_code=400,
400
+ detail="Missing device_code for Copilot OAuth",
401
+ )
402
+
403
+ async with httpx.AsyncClient(timeout=30.0) as client:
404
+ resp = await client.post(
405
+ "https://github.com/login/oauth/access_token",
406
+ headers={
407
+ "accept": "application/json",
408
+ "editor-version": "Neovim/0.6.1",
409
+ "editor-plugin-version": "copilot.vim/1.16.0",
410
+ "content-type": "application/json",
411
+ "user-agent": "GithubCopilot/1.155.0",
412
+ },
413
+ json={
414
+ "client_id": "Iv1.b507a08c87ecfe98",
415
+ "device_code": device_code,
416
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
417
+ },
418
+ )
419
+ data = resp.json()
420
+
421
+ if "error" in data:
422
+ if data["error"] == "authorization_pending":
423
+ return {"type": "pending", "message": "Waiting for user authorization"}
424
+ raise HTTPException(
425
+ status_code=400,
426
+ detail=data.get("error_description", data["error"]),
427
+ )
428
+
429
+ access_token = data.get("access_token")
430
+ if access_token:
431
+ # Clean up flow state
432
+ _oauth_flows.pop(f"copilot:{device_code}", None)
433
+
434
+ return {
435
+ "type": "success",
436
+ "access": access_token,
437
+ "refresh": data.get("refresh_token"),
438
+ "expires": None, # Copilot tokens don't expire the same way
439
+ }
440
+
441
+ return {"type": "pending", "message": "No token received yet"}
442
+ raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_id}")
@@ -0,0 +1,139 @@
1
+ """App, project, path, and VCS routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import subprocess
7
+ from typing import TYPE_CHECKING
8
+
9
+ from fastapi import APIRouter
10
+
11
+ from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
12
+ from agentpool_server.opencode_server.models import (
13
+ App,
14
+ AppTimeInfo,
15
+ PathInfo,
16
+ Project,
17
+ ProjectTime,
18
+ VcsInfo,
19
+ )
20
+ from agentpool_storage.project_store import ProjectStore
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from agentpool.sessions.models import ProjectData
25
+
26
+
27
+ router = APIRouter(tags=["app"])
28
+
29
+
30
+ @router.get("/app")
31
+ async def get_app(state: StateDep) -> App:
32
+ """Get app information."""
33
+ working_path = Path(state.working_dir)
34
+ return App(
35
+ git=(working_path / ".git").is_dir(),
36
+ hostname="localhost",
37
+ path=PathInfo(
38
+ config="",
39
+ cwd=state.working_dir,
40
+ data="",
41
+ root=state.working_dir,
42
+ state="",
43
+ ),
44
+ time=AppTimeInfo(initialized=state.start_time),
45
+ )
46
+
47
+
48
+ def _project_data_to_response(data: ProjectData) -> Project:
49
+ """Convert ProjectData to OpenCode Project response."""
50
+ working_path = Path(data.worktree)
51
+ vcs_dir: str | None = None
52
+ if data.vcs == "git":
53
+ vcs_dir = str(working_path / ".git")
54
+ elif data.vcs == "hg":
55
+ vcs_dir = str(working_path / ".hg")
56
+
57
+ return Project(
58
+ id=data.project_id,
59
+ worktree=data.worktree,
60
+ vcs_dir=vcs_dir,
61
+ vcs=data.vcs,
62
+ time=ProjectTime(created=int(data.created_at.timestamp() * 1000)),
63
+ )
64
+
65
+
66
+ async def _get_current_project(state: StateDep) -> ProjectData:
67
+ """Get or create the current project from storage."""
68
+ storage = state.pool.storage
69
+ project_store = ProjectStore(storage)
70
+ return await project_store.get_or_create(state.working_dir)
71
+
72
+
73
+ @router.get("/project")
74
+ async def list_projects(state: StateDep) -> list[Project]:
75
+ """List all projects."""
76
+ storage = state.pool.storage
77
+ project_store = ProjectStore(storage)
78
+ projects = await project_store.list_recent(limit=50)
79
+ return [_project_data_to_response(p) for p in projects]
80
+
81
+
82
+ @router.get("/project/current")
83
+ async def get_project_current(state: StateDep) -> Project:
84
+ """Get current project."""
85
+ project = await _get_current_project(state)
86
+ return _project_data_to_response(project)
87
+
88
+
89
+ @router.get("/path")
90
+ async def get_path(state: StateDep) -> PathInfo:
91
+ """Get current path info."""
92
+ return PathInfo(
93
+ config="",
94
+ cwd=state.working_dir,
95
+ data="",
96
+ root=state.working_dir,
97
+ state="",
98
+ )
99
+
100
+
101
+ @router.get("/vcs")
102
+ async def get_vcs(state: StateDep) -> VcsInfo:
103
+ """Get VCS info.
104
+
105
+ TODO: For remote/ACP support, these git commands should run through
106
+ state.env.execute_command() instead of subprocess.run() so they
107
+ execute on the client side where the repository lives.
108
+ """
109
+ git_dir = Path(state.working_dir) / ".git"
110
+ if not git_dir.is_dir():
111
+ return VcsInfo(branch=None, dirty=False, commit=None)
112
+
113
+ try:
114
+ branch = subprocess.run(
115
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
116
+ cwd=state.working_dir,
117
+ capture_output=True,
118
+ text=True,
119
+ check=True,
120
+ ).stdout.strip()
121
+ commit = subprocess.run(
122
+ ["git", "rev-parse", "HEAD"],
123
+ cwd=state.working_dir,
124
+ capture_output=True,
125
+ text=True,
126
+ check=True,
127
+ ).stdout.strip()
128
+ dirty = bool(
129
+ subprocess.run(
130
+ ["git", "status", "--porcelain"],
131
+ cwd=state.working_dir,
132
+ capture_output=True,
133
+ text=True,
134
+ check=True,
135
+ ).stdout.strip()
136
+ )
137
+ return VcsInfo(branch=branch, dirty=dirty, commit=commit)
138
+ except subprocess.CalledProcessError:
139
+ return VcsInfo(branch=None, dirty=False, commit=None)