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,1205 @@
1
+ """Session routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ from anyenv.text_sharing.opencode import Message, MessagePart, OpenCodeSharer
10
+ from fastapi import APIRouter, HTTPException
11
+ from pydantic_ai import FileUrl
12
+
13
+ from agentpool.repomap import RepoMap, find_src_files
14
+ from agentpool.sessions.models import SessionData
15
+ from agentpool.utils import identifiers as identifier
16
+ from agentpool_config.session import SessionQuery
17
+ from agentpool_server.opencode_server.command_validation import validate_command
18
+ from agentpool_server.opencode_server.converters import chat_message_to_opencode
19
+ from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
20
+ from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider
21
+ from agentpool_server.opencode_server.models import ( # noqa: TC001
22
+ AssistantMessage,
23
+ CommandRequest,
24
+ MessagePath,
25
+ MessageTime,
26
+ MessageUpdatedEvent,
27
+ MessageWithParts,
28
+ PartUpdatedEvent,
29
+ Session,
30
+ SessionCreatedEvent,
31
+ SessionCreateRequest,
32
+ SessionDeletedEvent,
33
+ SessionForkRequest,
34
+ SessionInitRequest,
35
+ SessionRevert,
36
+ SessionShare,
37
+ SessionStatus,
38
+ SessionStatusEvent,
39
+ SessionUpdatedEvent,
40
+ SessionUpdateRequest,
41
+ ShellRequest,
42
+ StepFinishPart,
43
+ StepStartPart,
44
+ SummarizeRequest,
45
+ TextPart,
46
+ TimeCreatedUpdated,
47
+ Todo,
48
+ Tokens,
49
+ TokensCache,
50
+ )
51
+ from agentpool_server.opencode_server.models.base import OpenCodeBaseModel
52
+ from agentpool_server.opencode_server.models.events import PermissionResolvedEvent
53
+ from agentpool_server.opencode_server.models.parts import StepFinishTokens, TokenCache
54
+ from agentpool_server.opencode_server.time_utils import now_ms
55
+
56
+
57
+ if TYPE_CHECKING:
58
+ from agentpool_server.opencode_server.state import ServerState
59
+
60
+
61
+ # =============================================================================
62
+ # Conversion helpers between OpenCode Session and SessionData
63
+ # =============================================================================
64
+
65
+
66
+ def session_data_to_opencode(data: SessionData) -> Session:
67
+ """Convert SessionData to OpenCode Session model."""
68
+ # Convert datetime to milliseconds timestamp
69
+ created_ms = int(data.created_at.timestamp() * 1000)
70
+ updated_ms = int(data.last_active.timestamp() * 1000)
71
+
72
+ # Extract revert/share from metadata if present
73
+ revert = None
74
+ share = None
75
+ if "revert" in data.metadata:
76
+ revert = SessionRevert(**data.metadata["revert"])
77
+ if "share" in data.metadata:
78
+ share = SessionShare(**data.metadata["share"])
79
+
80
+ return Session(
81
+ id=data.session_id,
82
+ project_id=data.project_id or "default",
83
+ directory=data.cwd or "",
84
+ title=data.title or "New Session",
85
+ version=data.version,
86
+ time=TimeCreatedUpdated(created=created_ms, updated=updated_ms),
87
+ parent_id=data.parent_id,
88
+ revert=revert,
89
+ share=share,
90
+ )
91
+
92
+
93
+ def opencode_to_session_data(
94
+ session: Session,
95
+ *,
96
+ agent_name: str = "default",
97
+ pool_id: str | None = None,
98
+ ) -> SessionData:
99
+ """Convert OpenCode Session to SessionData for persistence."""
100
+ # Convert milliseconds timestamp to datetime
101
+ created_at = datetime.fromtimestamp(session.time.created / 1000, tz=UTC)
102
+ last_active = datetime.fromtimestamp(session.time.updated / 1000, tz=UTC)
103
+ # Store revert/share in metadata
104
+ metadata: dict[str, Any] = {}
105
+ if session.revert:
106
+ metadata["revert"] = session.revert.model_dump()
107
+ if session.share:
108
+ metadata["share"] = session.share.model_dump()
109
+ return SessionData(
110
+ session_id=session.id,
111
+ agent_name=agent_name,
112
+ conversation_id=session.id, # Use session_id as conversation_id
113
+ title=session.title,
114
+ pool_id=pool_id,
115
+ project_id=session.project_id,
116
+ parent_id=session.parent_id,
117
+ version=session.version,
118
+ cwd=session.directory,
119
+ created_at=created_at,
120
+ last_active=last_active,
121
+ metadata=metadata,
122
+ )
123
+
124
+
125
+ async def load_messages_from_storage(state: ServerState, session_id: str) -> list[MessageWithParts]:
126
+ """Load messages from storage and convert to OpenCode format.
127
+
128
+ Args:
129
+ state: Server state with pool reference
130
+ session_id: Session/conversation ID
131
+
132
+ Returns:
133
+ List of OpenCode MessageWithParts
134
+ """
135
+ if state.pool.storage is None:
136
+ return []
137
+
138
+ try:
139
+ query = SessionQuery(name=session_id) # conversation_id = session_id
140
+ chat_messages = await state.pool.storage.filter_messages(query)
141
+ # Convert to OpenCode format
142
+ opencode_messages = []
143
+ working_dir = state.working_dir
144
+ agent_name = state.agent.name
145
+ for chat_msg in chat_messages:
146
+ opencode_msg = chat_message_to_opencode(
147
+ chat_msg,
148
+ session_id=session_id,
149
+ working_dir=working_dir,
150
+ agent_name=agent_name,
151
+ model_id=chat_msg.model_name or "unknown",
152
+ provider_id=chat_msg.provider_name or "agentpool",
153
+ )
154
+ opencode_messages.append(opencode_msg)
155
+
156
+ except Exception: # noqa: BLE001
157
+ # If storage fails, return empty list
158
+ return []
159
+ else:
160
+ return opencode_messages
161
+
162
+
163
+ async def get_or_load_session(state: ServerState, session_id: str) -> Session | None:
164
+ """Get session from cache or load from storage.
165
+
166
+ Returns None if session not found in either location.
167
+ Also loads messages from storage if not already cached.
168
+ """
169
+ # Check in-memory cache first
170
+ if session_id in state.sessions:
171
+ return state.sessions[session_id]
172
+
173
+ # Try to load from storage
174
+ data = await state.pool.sessions.store.load(session_id)
175
+ if data is not None:
176
+ session = session_data_to_opencode(data)
177
+ # Cache it
178
+ state.sessions[session_id] = session
179
+ # Initialize runtime state
180
+ if session_id not in state.session_status:
181
+ state.session_status[session_id] = SessionStatus(type="idle")
182
+ # Load messages from storage if not cached
183
+ if session_id not in state.messages:
184
+ state.messages[session_id] = await load_messages_from_storage(state, session_id)
185
+ return session
186
+
187
+ return None
188
+
189
+
190
+ router = APIRouter(prefix="/session", tags=["session"])
191
+
192
+
193
+ @router.get("")
194
+ async def list_sessions(state: StateDep) -> list[Session]:
195
+ """List all sessions from storage.
196
+
197
+ Returns all persisted sessions, not just active ones.
198
+ """
199
+ sessions: list[Session] = []
200
+ # Load all session IDs from storage
201
+ session_ids = await state.pool.sessions.store.list_sessions()
202
+ for session_id in session_ids:
203
+ # Use get_or_load to populate cache and get Session model
204
+ session = await get_or_load_session(state, session_id)
205
+ if session is not None:
206
+ sessions.append(session)
207
+
208
+ return sessions
209
+
210
+
211
+ @router.post("")
212
+ async def create_session(state: StateDep, request: SessionCreateRequest | None = None) -> Session:
213
+ """Create a new session and persist to storage."""
214
+ now = now_ms()
215
+ session_id = identifier.ascending("session")
216
+ session = Session(
217
+ id=session_id,
218
+ project_id="default", # TODO: Get from config/request
219
+ directory=state.working_dir,
220
+ title=request.title if request and request.title else "New Session",
221
+ version="1",
222
+ time=TimeCreatedUpdated(created=now, updated=now),
223
+ parent_id=request.parent_id if request else None,
224
+ )
225
+
226
+ # Persist to storage
227
+ id_ = state.pool.manifest.config_file_path
228
+ session_data = opencode_to_session_data(session, agent_name=state.agent.name, pool_id=id_)
229
+ await state.pool.sessions.store.save(session_data)
230
+ # Cache in memory
231
+ state.sessions[session_id] = session
232
+ state.messages[session_id] = []
233
+ state.session_status[session_id] = SessionStatus(type="idle")
234
+ state.todos[session_id] = []
235
+ # Create input provider for this session
236
+ input_provider = OpenCodeInputProvider(state, session_id)
237
+ state.input_providers[session_id] = input_provider
238
+ # Set input provider on agent
239
+ state.agent._input_provider = input_provider
240
+ await state.broadcast_event(SessionCreatedEvent.create(session))
241
+ return session
242
+
243
+
244
+ @router.get("/status")
245
+ async def get_session_status(state: StateDep) -> dict[str, SessionStatus]:
246
+ """Get status for all sessions.
247
+
248
+ Returns only non-idle sessions. If all sessions are idle, returns empty dict.
249
+ """
250
+ return {sid: status for sid, status in state.session_status.items() if status.type != "idle"}
251
+
252
+
253
+ @router.get("/{session_id}")
254
+ async def get_session(session_id: str, state: StateDep) -> Session:
255
+ """Get session details.
256
+
257
+ Loads from storage if not in memory cache.
258
+ """
259
+ session = await get_or_load_session(state, session_id)
260
+ if session is None:
261
+ raise HTTPException(status_code=404, detail="Session not found")
262
+ return session
263
+
264
+
265
+ @router.patch("/{session_id}")
266
+ async def update_session(
267
+ session_id: str,
268
+ request: SessionUpdateRequest,
269
+ state: StateDep,
270
+ ) -> Session:
271
+ """Update session properties and persist changes."""
272
+ session = await get_or_load_session(state, session_id)
273
+ if session is None:
274
+ raise HTTPException(status_code=404, detail="Session not found")
275
+
276
+ if request.title is not None:
277
+ time_ = TimeCreatedUpdated(created=session.time.created, updated=now_ms())
278
+ session = session.model_copy(update={"title": request.title, "time": time_})
279
+ state.sessions[session_id] = session # Update cache
280
+ id_ = state.pool.manifest.config_file_path
281
+ session_data = opencode_to_session_data(session, agent_name=state.agent.name, pool_id=id_)
282
+ await state.pool.sessions.store.save(session_data)
283
+ await state.broadcast_event(SessionUpdatedEvent.create(session))
284
+ return session
285
+
286
+
287
+ @router.delete("/{session_id}")
288
+ async def delete_session(session_id: str, state: StateDep) -> bool:
289
+ """Delete a session from both cache and storage."""
290
+ # Check if session exists (in cache or storage)
291
+ session = await get_or_load_session(state, session_id)
292
+ if session is None:
293
+ raise HTTPException(status_code=404, detail="Session not found")
294
+
295
+ # Cancel any pending permissions and clean up input provider
296
+ input_provider = state.input_providers.pop(session_id, None)
297
+ if input_provider is not None:
298
+ input_provider.cancel_all_pending()
299
+
300
+ # Remove from cache
301
+ state.sessions.pop(session_id, None)
302
+ state.messages.pop(session_id, None)
303
+ state.session_status.pop(session_id, None)
304
+ state.todos.pop(session_id, None)
305
+ # Delete from storage
306
+ await state.pool.sessions.store.delete(session_id)
307
+ await state.broadcast_event(SessionDeletedEvent.create(session_id))
308
+
309
+ return True
310
+
311
+
312
+ @router.post("/{session_id}/abort")
313
+ async def abort_session(session_id: str, state: StateDep) -> bool:
314
+ """Abort a running session."""
315
+ session = await get_or_load_session(state, session_id)
316
+ if session is None:
317
+ raise HTTPException(status_code=404, detail="Session not found")
318
+ # TODO: Actually abort running operations
319
+ state.session_status[session_id] = SessionStatus(type="idle")
320
+ return True
321
+
322
+
323
+ @router.post("/{session_id}/fork")
324
+ async def fork_session( # noqa: D417
325
+ session_id: str,
326
+ state: StateDep,
327
+ request: SessionForkRequest | None = None,
328
+ directory: str | None = None,
329
+ ) -> Session:
330
+ """Fork a session, optionally at a specific message.
331
+
332
+ Creates a new session with:
333
+ - parent_id pointing to the original session
334
+ - Copies all messages (or up to message_id if specified)
335
+ - Independent conversation history from that point forward
336
+
337
+ Args:
338
+ session_id: The session to fork from
339
+ request: Optional fork parameters (message_id to fork from)
340
+ directory: Optional directory for the forked session
341
+
342
+ Returns:
343
+ The newly created forked session
344
+ """
345
+ # Get the original session
346
+ original_session = await get_or_load_session(state, session_id)
347
+ if original_session is None:
348
+ raise HTTPException(status_code=404, detail="Session not found")
349
+
350
+ # Get messages from the original session
351
+ original_messages = state.messages.get(session_id, [])
352
+ # Filter messages if message_id is specified
353
+ messages_to_copy: list[MessageWithParts] = []
354
+ if request and request.message_id:
355
+ # Copy messages up to and including the specified message_id
356
+ for msg in original_messages:
357
+ messages_to_copy.append(msg)
358
+ if msg.info.id == request.message_id:
359
+ break
360
+ else:
361
+ # message_id not found in messages
362
+ detail = f"Message {request.message_id} not found in session"
363
+ raise HTTPException(status_code=404, detail=detail)
364
+ else:
365
+ # Copy all messages
366
+ messages_to_copy = list(original_messages)
367
+
368
+ # Create the new forked session
369
+ now = now_ms()
370
+ new_session_id = identifier.ascending("session")
371
+ # Use provided directory or inherit from original session
372
+ fork_directory = directory if directory else original_session.directory
373
+ forked_session = Session(
374
+ id=new_session_id,
375
+ project_id=original_session.project_id,
376
+ directory=fork_directory,
377
+ title=f"{original_session.title} (fork)",
378
+ version="1",
379
+ time=TimeCreatedUpdated(created=now, updated=now),
380
+ parent_id=session_id, # Link to original session
381
+ )
382
+
383
+ # Persist the forked session to storage
384
+ session_data = opencode_to_session_data(
385
+ forked_session,
386
+ agent_name=state.agent.name,
387
+ pool_id=state.pool.manifest.config_file_path,
388
+ )
389
+ await state.pool.sessions.store.save(session_data)
390
+ # Cache in memory
391
+ state.sessions[new_session_id] = forked_session
392
+ state.session_status[new_session_id] = SessionStatus(type="idle")
393
+ state.todos[new_session_id] = []
394
+ # Copy messages to the new session (with updated session_id references)
395
+ copied_messages: list[MessageWithParts] = []
396
+ for msg_with_parts in messages_to_copy:
397
+ # Create new message info with updated session_id
398
+ new_info = msg_with_parts.info.model_copy(update={"session_id": new_session_id})
399
+ # Copy parts with updated session_id
400
+ new_parts = [
401
+ part.model_copy(update={"session_id": new_session_id}) for part in msg_with_parts.parts
402
+ ]
403
+ copied_messages.append(MessageWithParts(info=new_info, parts=new_parts))
404
+
405
+ state.messages[new_session_id] = copied_messages
406
+ input_provider = OpenCodeInputProvider(state, new_session_id)
407
+ state.input_providers[new_session_id] = input_provider
408
+ # Broadcast session created event
409
+ await state.broadcast_event(SessionCreatedEvent.create(forked_session))
410
+ return forked_session
411
+
412
+
413
+ @router.post("/{session_id}/init")
414
+ async def init_session( # noqa: D417
415
+ session_id: str,
416
+ state: StateDep,
417
+ request: SessionInitRequest | None = None,
418
+ ) -> bool:
419
+ """Initialize a session by analyzing the codebase and creating AGENTS.md.
420
+
421
+ Generates a repository map, reads README if present, and runs the agent
422
+ with a prompt to create an AGENTS.md file with project-specific context.
423
+
424
+ Args:
425
+ session_id: The session to initialize
426
+ request: Optional model/provider override for the init task
427
+
428
+ Returns:
429
+ True when the init task has been started (runs async)
430
+ """
431
+ session = await get_or_load_session(state, session_id)
432
+ if session is None:
433
+ raise HTTPException(status_code=404, detail="Session not found")
434
+
435
+ # Get the agent and filesystem
436
+ agent = state.agent
437
+ fs = agent.env.get_fs()
438
+ working_dir = state.working_dir
439
+ try:
440
+ all_files = await find_src_files(fs, working_dir)
441
+ repo_map = RepoMap(fs=fs, root_path=working_dir, max_tokens=4000)
442
+ repomap_content = await repo_map.get_map(all_files) or "No repository map generated."
443
+ except Exception as e: # noqa: BLE001
444
+ repomap_content = f"Error generating repository map: {e}"
445
+
446
+ # Try to read README.md
447
+ readme_content = ""
448
+ for readme_name in ["README.md", "readme.md", "README", "readme.txt"]:
449
+ try:
450
+ readme_path = f"{working_dir}/{readme_name}".replace("//", "/")
451
+ content = await fs._cat_file(readme_path)
452
+ readme_content = content.decode("utf-8") if isinstance(content, bytes) else content
453
+ break
454
+ except Exception: # noqa: BLE001
455
+ continue
456
+
457
+ # Build the init prompt
458
+ prompt_parts = [
459
+ "Please analyze this codebase and create an AGENTS.md file in the project root.",
460
+ "",
461
+ "<repository-structure>",
462
+ repomap_content,
463
+ "</repository-structure>",
464
+ ]
465
+ if readme_content:
466
+ prompt_parts.extend(["", "<readme>", readme_content, "</readme>"])
467
+ prompt_parts.extend([
468
+ "",
469
+ "Include:",
470
+ "1. Build/lint/test commands - especially for running a single test",
471
+ "2. Code style guidelines (imports, formatting, types, naming conventions, error handling)",
472
+ "",
473
+ "The file will be given to AI coding agents working in this repository. "
474
+ "Keep it around 150 lines.",
475
+ "",
476
+ "If there are existing rules (.cursor/rules/, .cursorrules, "
477
+ ".github/copilot-instructions.md), incorporate them.",
478
+ ])
479
+
480
+ init_prompt = "\n".join(prompt_parts)
481
+
482
+ # Handle model selection if requested
483
+ original_model: str | None = None
484
+ if request and request.model_id and request.provider_id:
485
+ requested_model = f"{request.provider_id}:{request.model_id}"
486
+ try:
487
+ available_models = await agent.get_available_models()
488
+ if available_models:
489
+ valid_ids = [m.id_override if m.id_override else m.id for m in available_models]
490
+ if requested_model in valid_ids:
491
+ # Store original model to restore later
492
+ original_model = agent.model_name
493
+ await agent.set_model(requested_model)
494
+ except Exception: # noqa: BLE001
495
+ # Agent doesn't support model selection, ignore
496
+ pass
497
+
498
+ # Run the agent in the background
499
+ async def run_init() -> None:
500
+ try:
501
+ await agent.run(init_prompt)
502
+ finally:
503
+ # Restore original model if we changed it
504
+ if original_model is not None:
505
+ with contextlib.suppress(Exception):
506
+ await agent.set_model(original_model)
507
+
508
+ state.create_background_task(run_init(), name=f"init_{session_id}")
509
+
510
+ return True
511
+
512
+
513
+ @router.get("/{session_id}/todo")
514
+ async def get_session_todos(session_id: str, state: StateDep) -> list[Todo]:
515
+ """Get todos for a session.
516
+
517
+ Returns todos from the agent pool's TodoTracker.
518
+ """
519
+ session = await get_or_load_session(state, session_id)
520
+ if session is None:
521
+ raise HTTPException(status_code=404, detail="Session not found")
522
+
523
+ # Get todos from pool's TodoTracker
524
+ tracker = state.pool.todos
525
+ return [Todo(id=e.id, content=e.content, status=e.status) for e in tracker.entries]
526
+
527
+
528
+ @router.get("/{session_id}/diff")
529
+ async def get_session_diff(
530
+ session_id: str,
531
+ state: StateDep,
532
+ message_id: str | None = None,
533
+ ) -> list[dict[str, Any]]:
534
+ """Get file diffs for a session.
535
+
536
+ Returns a list of file changes with unified diffs.
537
+ Optionally filter to changes since a specific message.
538
+ """
539
+ session = await get_or_load_session(state, session_id)
540
+ if session is None:
541
+ raise HTTPException(status_code=404, detail="Session not found")
542
+
543
+ file_ops = state.pool.file_ops
544
+ if not file_ops.changes:
545
+ return []
546
+ # Optionally filter by message_id
547
+ changes = file_ops.get_changes_since_message(message_id) if message_id else file_ops.changes
548
+ # Format as list of diffs
549
+ return [
550
+ {
551
+ "path": change.path,
552
+ "operation": change.operation,
553
+ "diff": change.to_unified_diff(),
554
+ "timestamp": change.timestamp,
555
+ "agent_name": change.agent_name,
556
+ "message_id": change.message_id,
557
+ }
558
+ for change in changes
559
+ ]
560
+
561
+
562
+ @router.post("/{session_id}/shell")
563
+ async def run_shell_command(
564
+ session_id: str,
565
+ request: ShellRequest,
566
+ state: StateDep,
567
+ ) -> MessageWithParts:
568
+ """Run a shell command directly."""
569
+ session = await get_or_load_session(state, session_id)
570
+ if session is None:
571
+ raise HTTPException(status_code=404, detail="Session not found")
572
+
573
+ # Validate command for security issues
574
+ validate_command(request.command, state.working_dir)
575
+ now = now_ms()
576
+ # Create assistant message for the shell output
577
+ assistant_msg_id = identifier.ascending("message")
578
+ assistant_message = AssistantMessage(
579
+ id=assistant_msg_id,
580
+ session_id=session_id,
581
+ parent_id="", # Shell commands don't have a parent user message
582
+ model_id=request.model.model_id if request.model else "shell",
583
+ provider_id=request.model.provider_id if request.model else "local",
584
+ mode="shell",
585
+ agent=request.agent,
586
+ path=MessagePath(cwd=state.working_dir, root=state.working_dir),
587
+ time=MessageTime(created=now, completed=None),
588
+ tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
589
+ cost=0.0,
590
+ )
591
+
592
+ # Initialize message with empty parts
593
+ assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
594
+ state.messages[session_id].append(assistant_msg_with_parts)
595
+ # Broadcast message created
596
+ await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
597
+ # Mark session as busy
598
+ state.session_status[session_id] = SessionStatus(type="busy")
599
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
600
+ # Add step-start part
601
+ step_start = StepStartPart(
602
+ id=identifier.ascending("part"),
603
+ message_id=assistant_msg_id,
604
+ session_id=session_id,
605
+ )
606
+ assistant_msg_with_parts.parts.append(step_start)
607
+ await state.broadcast_event(PartUpdatedEvent.create(step_start))
608
+ # Execute the command
609
+ output_text = ""
610
+ success = False
611
+ try:
612
+ result = await state.agent.env.execute_command(request.command)
613
+ success = result.success
614
+ if success:
615
+ output_text = str(result.result) if result.result else ""
616
+ else:
617
+ output_text = f"Error: {result.error}" if result.error else "Command failed"
618
+ except Exception as e: # noqa: BLE001
619
+ output_text = f"Error executing command: {e}"
620
+
621
+ response_time = now_ms()
622
+ # Create text part with output
623
+ text_part = TextPart(
624
+ id=identifier.ascending("part"),
625
+ message_id=assistant_msg_id,
626
+ session_id=session_id,
627
+ text=f"$ {request.command}\n{output_text}",
628
+ )
629
+ assistant_msg_with_parts.parts.append(text_part)
630
+ await state.broadcast_event(PartUpdatedEvent.create(text_part))
631
+ step_finish = StepFinishPart(
632
+ id=identifier.ascending("part"),
633
+ message_id=assistant_msg_id,
634
+ session_id=session_id,
635
+ tokens=StepFinishTokens(cache=TokenCache(read=0, write=0)),
636
+ )
637
+ assistant_msg_with_parts.parts.append(step_finish)
638
+ await state.broadcast_event(PartUpdatedEvent.create(step_finish))
639
+ # Update message with completion time
640
+ time_ = MessageTime(created=now, completed=response_time)
641
+ updated_assistant = assistant_message.model_copy(update={"time": time_})
642
+ assistant_msg_with_parts.info = updated_assistant
643
+ await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
644
+ # Mark session as idle
645
+ state.session_status[session_id] = SessionStatus(type="idle")
646
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
647
+ return assistant_msg_with_parts
648
+
649
+
650
+ class PermissionResponse(OpenCodeBaseModel):
651
+ """Request body for responding to a permission request."""
652
+
653
+ response: Literal["once", "always", "reject"]
654
+
655
+
656
+ @router.get("/{session_id}/permissions")
657
+ async def get_pending_permissions(session_id: str, state: StateDep) -> list[dict[str, Any]]:
658
+ """Get all pending permission requests for a session.
659
+
660
+ Returns a list of pending permissions awaiting user response.
661
+ """
662
+ session = await get_or_load_session(state, session_id)
663
+ if session is None:
664
+ raise HTTPException(status_code=404, detail="Session not found")
665
+
666
+ # Get the input provider for this session
667
+ input_provider = state.input_providers.get(session_id)
668
+ if input_provider is None:
669
+ return []
670
+
671
+ return input_provider.get_pending_permissions()
672
+
673
+
674
+ @router.post("/{session_id}/permissions/{permission_id}")
675
+ async def respond_to_permission(
676
+ session_id: str,
677
+ permission_id: str,
678
+ request: PermissionResponse,
679
+ state: StateDep,
680
+ ) -> bool:
681
+ """Respond to a pending permission request.
682
+
683
+ The response can be:
684
+ - "once": Allow this tool execution once
685
+ - "always": Always allow this tool (remembered for session)
686
+ - "reject": Reject this tool execution
687
+ """
688
+ session = await get_or_load_session(state, session_id)
689
+ if session is None:
690
+ raise HTTPException(status_code=404, detail="Session not found")
691
+
692
+ # Get the input provider for this session
693
+ input_provider = state.input_providers.get(session_id)
694
+ if input_provider is None:
695
+ raise HTTPException(status_code=404, detail="No input provider for session")
696
+
697
+ # Resolve the permission
698
+ resolved = input_provider.resolve_permission(permission_id, request.response)
699
+ if not resolved:
700
+ raise HTTPException(status_code=404, detail="Permission not found or already resolved")
701
+
702
+ await state.broadcast_event(
703
+ PermissionResolvedEvent.create(
704
+ session_id=session_id,
705
+ request_id=permission_id,
706
+ reply=request.response,
707
+ )
708
+ )
709
+
710
+ return True
711
+
712
+
713
+ # OpenCode-style continuation prompt for summarization
714
+ SUMMARIZE_PROMPT = """Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.""" # noqa: E501
715
+
716
+
717
+ @router.post("/{session_id}/summarize")
718
+ async def summarize_session( # noqa: PLR0915
719
+ session_id: str,
720
+ state: StateDep,
721
+ request: SummarizeRequest | None = None,
722
+ ) -> MessageWithParts:
723
+ """Summarize the session conversation.
724
+
725
+ First runs the compaction pipeline to condense older messages,
726
+ then streams an LLM-generated summary/continuation prompt to the user.
727
+ The summary message is marked with summary=true for UI display.
728
+ """
729
+ from pydantic_ai.messages import (
730
+ PartDeltaEvent,
731
+ PartStartEvent,
732
+ TextPart as PydanticTextPart,
733
+ TextPartDelta,
734
+ )
735
+
736
+ from agentpool.agents.events import StreamCompleteEvent
737
+
738
+ session = await get_or_load_session(state, session_id)
739
+ if session is None:
740
+ raise HTTPException(status_code=404, detail="Session not found")
741
+ messages = state.messages.get(session_id, [])
742
+ if not messages:
743
+ raise HTTPException(status_code=400, detail="No messages to summarize")
744
+
745
+ # Determine model to use
746
+ model_id = request.model_id if request and request.model_id else "default"
747
+ provider_id = request.provider_id if request and request.provider_id else "agentpool"
748
+
749
+ now = now_ms()
750
+ # Create assistant message for the summary (marked with summary=true)
751
+ assistant_msg_id = identifier.ascending("message")
752
+ assistant_message = AssistantMessage(
753
+ id=assistant_msg_id,
754
+ session_id=session_id,
755
+ parent_id="",
756
+ model_id=model_id,
757
+ provider_id=provider_id,
758
+ mode="summarize",
759
+ agent="summarizer",
760
+ path=MessagePath(cwd=state.working_dir, root=state.working_dir),
761
+ time=MessageTime(created=now, completed=None),
762
+ tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
763
+ cost=0.0,
764
+ summary=True, # Mark as summary message
765
+ )
766
+
767
+ assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
768
+ state.messages[session_id].append(assistant_msg_with_parts)
769
+ # Broadcast message created
770
+ await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
771
+ # Mark session as busy
772
+ state.session_status[session_id] = SessionStatus(type="busy")
773
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
774
+ # Add step-start part
775
+ step_start = StepStartPart(
776
+ id=identifier.ascending("part"),
777
+ message_id=assistant_msg_id,
778
+ session_id=session_id,
779
+ )
780
+ assistant_msg_with_parts.parts.append(step_start)
781
+ await state.broadcast_event(PartUpdatedEvent.create(step_start))
782
+
783
+ # Step 1: Stream LLM summary generation FIRST (while we have full history)
784
+ # The LLM sees the complete conversation and generates a continuation prompt.
785
+ response_text = ""
786
+ input_tokens = 0
787
+ output_tokens = 0
788
+ text_part: TextPart | None = None
789
+
790
+ try:
791
+ agent = state.agent
792
+ # Stream events from the agent with the summarization prompt
793
+ # This runs with FULL history - the summary is based on complete context
794
+ async for event in agent.run_stream(SUMMARIZE_PROMPT):
795
+ match event:
796
+ # Text streaming start
797
+ case PartStartEvent(part=PydanticTextPart(content=delta)):
798
+ response_text = delta
799
+ text_part = TextPart(
800
+ id=identifier.ascending("part"),
801
+ message_id=assistant_msg_id,
802
+ session_id=session_id,
803
+ text=delta,
804
+ )
805
+ assistant_msg_with_parts.parts.append(text_part)
806
+ await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
807
+
808
+ # Text streaming delta
809
+ case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta:
810
+ response_text += delta
811
+ if text_part is not None:
812
+ text_part = TextPart(
813
+ id=text_part.id,
814
+ message_id=assistant_msg_id,
815
+ session_id=session_id,
816
+ text=response_text,
817
+ )
818
+ # Update in parts list
819
+ for i, p in enumerate(assistant_msg_with_parts.parts):
820
+ if isinstance(p, TextPart) and p.id == text_part.id:
821
+ assistant_msg_with_parts.parts[i] = text_part
822
+ break
823
+ await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta))
824
+
825
+ # Stream complete - extract token usage
826
+ case StreamCompleteEvent(message=msg) if msg and msg.usage:
827
+ input_tokens = msg.usage.input_tokens or 0
828
+ output_tokens = msg.usage.output_tokens or 0
829
+
830
+ except Exception as e: # noqa: BLE001
831
+ response_text = f"Error generating summary: {e}"
832
+
833
+ response_time = now_ms()
834
+
835
+ # Create/update text part with final response
836
+ if text_part is None:
837
+ text_part = TextPart(
838
+ id=identifier.ascending("part"),
839
+ message_id=assistant_msg_id,
840
+ session_id=session_id,
841
+ text=response_text,
842
+ )
843
+ assistant_msg_with_parts.parts.append(text_part)
844
+ await state.broadcast_event(PartUpdatedEvent.create(text_part))
845
+
846
+ # Step 2: Run compaction pipeline AFTER summary is generated
847
+ # The summary was generated with full context. Now we compact the history.
848
+ # Final state will be: [compacted history] + [summary message]
849
+ # The compacted history becomes the cached prefix for future LLM calls.
850
+ try:
851
+ from agentpool.messaging.compaction import compact_conversation, summarizing_context
852
+
853
+ # Get the compaction pipeline from the agent pool configuration
854
+ pipeline = None
855
+ if state.agent.agent_pool is not None:
856
+ pipeline = state.agent.agent_pool.compaction_pipeline
857
+
858
+ if pipeline is None:
859
+ # Fall back to a default summarizing pipeline
860
+ pipeline = summarizing_context()
861
+
862
+ # Apply the compaction pipeline (modifies agent.conversation in place)
863
+ await compact_conversation(pipeline, state.agent.conversation)
864
+
865
+ # Persist compacted messages to storage, replacing the old ones
866
+ if state.pool.storage is not None:
867
+ compacted_history = state.agent.conversation.get_history()
868
+ await state.pool.storage.replace_conversation_messages(
869
+ session_id,
870
+ compacted_history,
871
+ )
872
+
873
+ # Update in-memory OpenCode messages list with compacted versions
874
+ # Keep only the summary message we just created
875
+ state.messages[session_id] = [assistant_msg_with_parts]
876
+
877
+ except Exception: # noqa: BLE001
878
+ # Compaction failure is not fatal - we still have the summary
879
+ pass
880
+
881
+ # Add step-finish part
882
+ step_finish = StepFinishPart(
883
+ id=identifier.ascending("part"),
884
+ message_id=assistant_msg_id,
885
+ session_id=session_id,
886
+ tokens=StepFinishTokens(
887
+ cache=TokenCache(read=0, write=0),
888
+ input=input_tokens,
889
+ output=output_tokens,
890
+ reasoning=0,
891
+ ),
892
+ )
893
+ assistant_msg_with_parts.parts.append(step_finish)
894
+ await state.broadcast_event(PartUpdatedEvent.create(step_finish))
895
+ # Update message with completion time and tokens
896
+ updated_assistant = assistant_message.model_copy(
897
+ update={
898
+ "time": MessageTime(created=now, completed=response_time),
899
+ "tokens": Tokens(
900
+ cache=TokensCache(read=0, write=0),
901
+ input=input_tokens,
902
+ output=output_tokens,
903
+ reasoning=0,
904
+ ),
905
+ }
906
+ )
907
+ assistant_msg_with_parts.info = updated_assistant
908
+ await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
909
+ # Mark session as idle
910
+ state.session_status[session_id] = SessionStatus(type="idle")
911
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
912
+ return assistant_msg_with_parts
913
+
914
+
915
+ @router.post("/{session_id}/share")
916
+ async def share_session(
917
+ session_id: str,
918
+ state: StateDep,
919
+ num_messages: int | None = None,
920
+ ) -> Session:
921
+ """Share session conversation history via OpenCode's sharing service.
922
+
923
+ Uses the OpenCode share API to create a shareable link with the full
924
+ conversation including messages and parts.
925
+
926
+ Returns the updated session with the share URL.
927
+ """
928
+ session = await get_or_load_session(state, session_id)
929
+ if session is None:
930
+ raise HTTPException(status_code=404, detail="Session not found")
931
+ messages = state.messages.get(session_id, [])
932
+
933
+ if not messages:
934
+ raise HTTPException(status_code=400, detail="No messages to share")
935
+
936
+ # Apply message limit if specified
937
+ if num_messages is not None and num_messages > 0:
938
+ messages = messages[-num_messages:]
939
+ # Convert our messages to OpenCode Message format
940
+ opencode_messages: list[Message] = []
941
+ for msg_with_parts in messages:
942
+ info = msg_with_parts.info
943
+ # Map role to OpenCode sharing roles
944
+ role = info.role
945
+ if role == "model": # type: ignore[comparison-overlap]
946
+ mapped_role: Literal["user", "assistant", "system"] = "assistant"
947
+ elif role in ("user", "assistant", "system"):
948
+ mapped_role = role
949
+ else:
950
+ mapped_role = "user"
951
+
952
+ # Extract text parts
953
+ parts = [
954
+ MessagePart(type="text", text=part.text)
955
+ for part in msg_with_parts.parts
956
+ if isinstance(part, TextPart) and part.text
957
+ ]
958
+ if parts:
959
+ opencode_messages.append(Message(role=mapped_role, parts=parts))
960
+ if not opencode_messages:
961
+ raise HTTPException(status_code=400, detail="No content to share")
962
+
963
+ # Share via OpenCode API
964
+ async with OpenCodeSharer() as sharer:
965
+ result = await sharer.share_conversation(opencode_messages, title=session.title)
966
+ share_url = result.url
967
+ # Store the share URL in the session
968
+ share_info = SessionShare(url=share_url)
969
+ updated_session = session.model_copy(update={"share": share_info})
970
+ state.sessions[session_id] = updated_session
971
+ # Broadcast session update
972
+ await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
973
+ return updated_session
974
+
975
+
976
+ class RevertRequest(OpenCodeBaseModel):
977
+ """Request body for reverting a message."""
978
+
979
+ message_id: str
980
+ part_id: str | None = None
981
+
982
+
983
+ @router.post("/{session_id}/revert")
984
+ async def revert_session(session_id: str, request: RevertRequest, state: StateDep) -> Session:
985
+ """Revert file changes from a specific message.
986
+
987
+ Restores files to their state before the specified message's changes.
988
+ """
989
+ session = await get_or_load_session(state, session_id)
990
+ if session is None:
991
+ raise HTTPException(status_code=404, detail="Session not found")
992
+ file_ops = state.pool.file_ops
993
+ if not file_ops.changes:
994
+ raise HTTPException(status_code=400, detail="No file changes to revert")
995
+ # Get revert operations for changes since this message
996
+ revert_ops = file_ops.get_revert_operations(since_message_id=request.message_id)
997
+
998
+ if not revert_ops:
999
+ detail = f"No changes found for message {request.message_id}"
1000
+ raise HTTPException(status_code=404, detail=detail)
1001
+ # Get filesystem from the agent's environment
1002
+ fs = state.agent.env.get_fs()
1003
+ # Apply reverts using the filesystem
1004
+ # TODO: Currently write operations only track "existed vs created", not full old content.
1005
+ # Files that existed before a write will be restored as empty, not their original content.
1006
+ reverted_paths = []
1007
+ for path, content in revert_ops:
1008
+ try:
1009
+ if content is None:
1010
+ # File was created (old_text=None), delete it
1011
+ await fs._rm_file(path)
1012
+ else:
1013
+ # Restore original content
1014
+ content_bytes = content.encode("utf-8")
1015
+ await fs._pipe_file(path, content_bytes)
1016
+ reverted_paths.append(path)
1017
+ except Exception as e:
1018
+ raise HTTPException(status_code=500, detail=f"Failed to revert {path}: {e}") from e
1019
+
1020
+ # Remove the reverted changes from the tracker
1021
+ file_ops.remove_changes_since_message(request.message_id)
1022
+ # Update session with revert info
1023
+ session = state.sessions[session_id]
1024
+ # TODO: include the diff?
1025
+ revert_info = SessionRevert(message_id=request.message_id, part_id=request.part_id)
1026
+ updated_session = session.model_copy(update={"revert": revert_info})
1027
+ state.sessions[session_id] = updated_session
1028
+ # Broadcast session update
1029
+ await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
1030
+ return updated_session
1031
+
1032
+
1033
+ @router.post("/{session_id}/unrevert")
1034
+ async def unrevert_session(session_id: str, state: StateDep) -> Session:
1035
+ """Restore all reverted file changes.
1036
+
1037
+ Re-applies the changes that were previously reverted.
1038
+ """
1039
+ session = await get_or_load_session(state, session_id)
1040
+ if session is None:
1041
+ raise HTTPException(status_code=404, detail="Session not found")
1042
+ file_ops = state.pool.file_ops
1043
+ if not file_ops.reverted_changes:
1044
+ raise HTTPException(status_code=400, detail="No reverted changes to restore")
1045
+ # Get unrevert operations
1046
+ unrevert_ops = file_ops.get_unrevert_operations()
1047
+ # Get filesystem from the agent's environment
1048
+ fs = state.agent.env.get_fs()
1049
+ # Apply unrevert - write back the new_content
1050
+ for path, content in unrevert_ops:
1051
+ try:
1052
+ if content is None:
1053
+ # File was deleted in the original change, delete it again
1054
+ await fs._rm_file(path)
1055
+ else:
1056
+ # Restore the changed content
1057
+ content_bytes = content.encode("utf-8")
1058
+ await fs._pipe_file(path, content_bytes)
1059
+ except Exception as e:
1060
+ detail = f"Failed to unrevert {path}: {e}"
1061
+ raise HTTPException(status_code=500, detail=detail) from e
1062
+
1063
+ # Restore the changes to the tracker
1064
+ file_ops.restore_reverted_changes()
1065
+ # Clear revert info from session
1066
+ updated_session = session.model_copy(update={"revert": None})
1067
+ state.sessions[session_id] = updated_session
1068
+ # Broadcast session update
1069
+ await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
1070
+ return updated_session
1071
+
1072
+
1073
+ @router.delete("/{session_id}/share")
1074
+ async def unshare_session(session_id: str, state: StateDep) -> bool:
1075
+ """Remove share link from a session.
1076
+
1077
+ Note: This only removes the link from the session metadata.
1078
+ The shared content may still exist on the provider's servers.
1079
+ """
1080
+ session = await get_or_load_session(state, session_id)
1081
+ if session is None:
1082
+ raise HTTPException(status_code=404, detail="Session not found")
1083
+ if session.share is None:
1084
+ raise HTTPException(status_code=400, detail="Session is not shared")
1085
+ # Remove share info from session
1086
+ updated_session = session.model_copy(update={"share": None})
1087
+ state.sessions[session_id] = updated_session
1088
+ # Broadcast session update
1089
+ await state.broadcast_event(SessionUpdatedEvent.create(updated_session))
1090
+ return True
1091
+
1092
+
1093
+ @router.post("/{session_id}/command")
1094
+ async def execute_command( # noqa: PLR0915
1095
+ session_id: str,
1096
+ request: CommandRequest,
1097
+ state: StateDep,
1098
+ ) -> MessageWithParts:
1099
+ """Execute a slash command (MCP prompt).
1100
+
1101
+ Commands are mapped to MCP prompts. The command name is used to find
1102
+ the matching prompt, and arguments are parsed and passed to it.
1103
+ """
1104
+ session = await get_or_load_session(state, session_id)
1105
+ if session is None:
1106
+ raise HTTPException(status_code=404, detail="Session not found")
1107
+ prompts = await state.agent.tools.list_prompts()
1108
+ # Find matching prompt by name
1109
+ prompt = next((p for p in prompts if p.name == request.command), None)
1110
+ if prompt is None:
1111
+ detail = f"Command not found: {request.command}"
1112
+ raise HTTPException(status_code=404, detail=detail)
1113
+
1114
+ # Parse arguments - OpenCode uses $1, $2 style, MCP uses named arguments
1115
+ # For simplicity, we'll pass the raw arguments string to the first argument
1116
+ # or parse space-separated args into a dict
1117
+ arguments: dict[str, str] = {}
1118
+ if request.arguments and prompt.arguments:
1119
+ # Split arguments and map to prompt argument names
1120
+ arg_values = request.arguments.split()
1121
+ for i, arg_def in enumerate(prompt.arguments):
1122
+ if i < len(arg_values):
1123
+ arguments[arg_def["name"]] = arg_values[i]
1124
+
1125
+ now = now_ms()
1126
+ # Create assistant message
1127
+ assistant_msg_id = identifier.ascending("message")
1128
+ assistant_message = AssistantMessage(
1129
+ id=assistant_msg_id,
1130
+ session_id=session_id,
1131
+ parent_id="",
1132
+ model_id=request.model or "default",
1133
+ provider_id="mcp",
1134
+ mode="command",
1135
+ agent=request.agent or "default",
1136
+ path=MessagePath(cwd=state.working_dir, root=state.working_dir),
1137
+ time=MessageTime(created=now, completed=None),
1138
+ tokens=Tokens(cache=TokensCache(read=0, write=0), input=0, output=0, reasoning=0),
1139
+ cost=0.0,
1140
+ )
1141
+ assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[])
1142
+ state.messages[session_id].append(assistant_msg_with_parts)
1143
+ await state.broadcast_event(MessageUpdatedEvent.create(assistant_message))
1144
+ # Mark session as busy
1145
+ state.session_status[session_id] = SessionStatus(type="busy")
1146
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy")))
1147
+ # Add step-start part
1148
+ part_id = identifier.ascending("part")
1149
+ step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id)
1150
+ assistant_msg_with_parts.parts.append(step_start)
1151
+ await state.broadcast_event(PartUpdatedEvent.create(step_start))
1152
+
1153
+ # Get prompt content and execute through the agent
1154
+ try:
1155
+ prompt_parts = await prompt.get_components(arguments)
1156
+ # Extract text content from parts
1157
+ prompt_texts = []
1158
+ for part in prompt_parts:
1159
+ if hasattr(part, "content"):
1160
+ content = part.content
1161
+ if isinstance(content, str):
1162
+ prompt_texts.append(content)
1163
+ elif isinstance(content, list):
1164
+ # Handle Sequence[UserContent]
1165
+ for item in content:
1166
+ if isinstance(item, FileUrl):
1167
+ prompt_texts.append(item.url)
1168
+ elif isinstance(item, str):
1169
+ prompt_texts.append(item)
1170
+ prompt_text = "\n".join(prompt_texts)
1171
+ # Run the expanded prompt through the agent
1172
+ result = await state.agent.run(prompt_text)
1173
+ output_text = str(result.data)
1174
+
1175
+ except Exception as e: # noqa: BLE001
1176
+ output_text = f"Error executing command: {e}"
1177
+
1178
+ response_time = now_ms()
1179
+ # Create text part with output
1180
+ text_part = TextPart(
1181
+ id=identifier.ascending("part"),
1182
+ message_id=assistant_msg_id,
1183
+ session_id=session_id,
1184
+ text=output_text,
1185
+ )
1186
+ assistant_msg_with_parts.parts.append(text_part)
1187
+ await state.broadcast_event(PartUpdatedEvent.create(text_part))
1188
+ step_finish = StepFinishPart(
1189
+ id=identifier.ascending("part"),
1190
+ message_id=assistant_msg_id,
1191
+ session_id=session_id,
1192
+ tokens=StepFinishTokens(cache=TokenCache()),
1193
+ cost=0.0,
1194
+ )
1195
+ assistant_msg_with_parts.parts.append(step_finish)
1196
+ await state.broadcast_event(PartUpdatedEvent.create(step_finish))
1197
+ # Update message with completion time
1198
+ time_ = MessageTime(created=now, completed=response_time)
1199
+ updated_assistant = assistant_message.model_copy(update={"time": time_})
1200
+ assistant_msg_with_parts.info = updated_assistant
1201
+ await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant))
1202
+ # Mark session as idle
1203
+ state.session_status[session_id] = SessionStatus(type="idle")
1204
+ await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle")))
1205
+ return assistant_msg_with_parts