crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,1581 @@
1
+ """CrabCode ACP Agent implementation.
2
+
3
+ Implements the ACP Agent protocol by translating between ACP JSON-RPC
4
+ and CrabCode's internal Gateway REST API + EventBus.
5
+
6
+ Architecture mirrors OpenCode's agent.ts:
7
+ - ACP client ↔ AgentSideConnection ↔ CrabCodeACPAgent ↔ httpx ↔ Gateway REST API
8
+ - EventBus SSE → _event_listener → session_update() → ACP client
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ import uuid
16
+ from typing import Any
17
+
18
+ import acp
19
+ import acp.schema as S
20
+ from acp.exceptions import RequestError
21
+ from acp.schema import (
22
+ AgentCapabilities,
23
+ AllowedOutcome,
24
+ AuthenticateResponse,
25
+ ContentToolCallContent,
26
+ FileEditToolCallContent,
27
+ ForkSessionResponse,
28
+ HttpMcpServer,
29
+ Implementation,
30
+ InitializeResponse,
31
+ ListSessionsResponse,
32
+ LoadSessionResponse,
33
+ McpCapabilities,
34
+ McpServerStdio,
35
+ ModelInfo,
36
+ NewSessionResponse,
37
+ PermissionOption,
38
+ PromptCapabilities,
39
+ PromptResponse,
40
+ ResumeSessionResponse,
41
+ SseMcpServer,
42
+ SessionCapabilities,
43
+ SessionConfigOptionSelect,
44
+ SessionConfigSelectOption,
45
+ SessionInfo,
46
+ SessionListCapabilities,
47
+ SessionMode,
48
+ SessionModeState,
49
+ SessionModelState,
50
+ SessionResumeCapabilities,
51
+ SessionForkCapabilities,
52
+ SetSessionConfigOptionResponse,
53
+ SetSessionModeResponse,
54
+ SetSessionModelResponse,
55
+ TextContent,
56
+ ToolCallProgress,
57
+ ToolCallStart,
58
+ Usage,
59
+ UsageUpdate,
60
+ )
61
+
62
+ from crabcode_core import VERSION
63
+ from crabcode_core.logging_utils import get_logger
64
+ from crabcode_gateway.acp.session import ACPSessionManager
65
+ from crabcode_gateway.acp.types import ACPConfig, ACPSessionState, ModelSelection, to_locations, to_tool_kind
66
+
67
+ logger = get_logger(__name__)
68
+
69
+ # ── Permission options ─────────────────────────────────────────
70
+
71
+ _PERMISSION_OPTIONS = [
72
+ PermissionOption(option_id="once", kind="allow_once", name="Allow once"),
73
+ PermissionOption(option_id="always", kind="allow_always", name="Always allow"),
74
+ PermissionOption(option_id="reject", kind="reject_once", name="Reject"),
75
+ ]
76
+
77
+ _LISTENER_READY_TIMEOUT = 10.0
78
+
79
+
80
+ class CrabCodeACPAgent:
81
+ """ACP Agent that bridges ACP protocol to CrabCode's Gateway API."""
82
+
83
+ def __init__(self, config: ACPConfig) -> None:
84
+ self._config = config
85
+ self._session_mgr = ACPSessionManager(config)
86
+ self._connection: Any = None # set by on_connect
87
+ self._event_task: asyncio.Task | None = None
88
+ self._listener_generation = 0
89
+ self._listener_ready = asyncio.Event()
90
+ self._listener_error: str | None = None
91
+ self._interaction_tasks: dict[
92
+ asyncio.Task[Any],
93
+ tuple[int, str, str],
94
+ ] = {}
95
+ self._closed = False
96
+ # Tool-use ids are only unique within a conversation in some ACP
97
+ # clients. Include the session id so two sessions cannot suppress
98
+ # each other's notifications.
99
+ self._tool_starts: set[tuple[str, str]] = set()
100
+ self._turn_events: dict[str, asyncio.Event] = {}
101
+ self._turn_operations: dict[str, str] = {}
102
+ self._turn_releases: dict[tuple[str, str], asyncio.Event] = {}
103
+ self._turn_send_settled: dict[str, tuple[str, asyncio.Event]] = {}
104
+ self._turn_send_accepted: dict[str, str] = {}
105
+ self._turn_cancel_requests: dict[str, str] = {}
106
+ self._turn_cancellations: dict[str, str] = {}
107
+ self._turn_usage: dict[str, Usage | None] = {}
108
+ self._turn_errors: dict[str, str] = {}
109
+ self._turn_reasons: dict[str, str] = {}
110
+
111
+ # ── Connection lifecycle ────────────────────────────────────
112
+
113
+ def on_connect(self, connection: Any) -> None:
114
+ """Called by AgentSideConnection after wiring."""
115
+ self._fail_active_prompts("ACP event connection replaced")
116
+ self._closed = False
117
+ self._connection = connection
118
+ self._start_event_listener()
119
+
120
+ def _start_event_listener(self) -> None:
121
+ """Start a new listener generation and fence its predecessor."""
122
+ previous = self._event_task
123
+ previous_ready = self._listener_ready
124
+ previous_ready.set()
125
+ self._listener_generation += 1
126
+ generation = self._listener_generation
127
+ ready = asyncio.Event()
128
+ self._listener_ready = ready
129
+ self._listener_error = None
130
+ if previous is not None and not previous.done():
131
+ previous.cancel()
132
+ self._event_task = asyncio.create_task(
133
+ self._event_listener(generation, ready)
134
+ )
135
+ self._event_task.add_done_callback(self._consume_task_result)
136
+
137
+ def _fail_active_prompts(self, message: str) -> None:
138
+ for session_id, waiter in list(self._turn_events.items()):
139
+ if session_id not in self._turn_operations or waiter.is_set():
140
+ continue
141
+ self._turn_errors.setdefault(session_id, message)
142
+ waiter.set()
143
+
144
+ async def _ensure_event_listener_ready(self) -> None:
145
+ """Join a ready listener, restarting a failed/ended generation."""
146
+ while True:
147
+ if self._closed:
148
+ raise RequestError(code=-32603, message="ACP agent is closed")
149
+ task = self._event_task
150
+ if task is None or task.done() or self._listener_error is not None:
151
+ self._start_event_listener()
152
+ generation = self._listener_generation
153
+ ready = self._listener_ready
154
+ try:
155
+ await asyncio.wait_for(
156
+ ready.wait(),
157
+ timeout=_LISTENER_READY_TIMEOUT,
158
+ )
159
+ except asyncio.TimeoutError as exc:
160
+ if generation == self._listener_generation:
161
+ self._listener_error = "Timed out waiting for Gateway event stream"
162
+ if self._event_task is not None:
163
+ self._event_task.cancel()
164
+ raise RequestError(
165
+ code=-32001,
166
+ message="Timed out waiting for Gateway event stream",
167
+ ) from exc
168
+ if generation != self._listener_generation:
169
+ continue
170
+ if self._listener_error:
171
+ raise RequestError(code=-32603, message=self._listener_error)
172
+ return
173
+
174
+ async def _wait_for_listener_or_turn_stop(
175
+ self,
176
+ session_id: str,
177
+ operation_id: str,
178
+ turn_event: asyncio.Event,
179
+ ) -> bool:
180
+ """Wait for SSE readiness, returning false for a pre-send cancel."""
181
+ listener_ready = asyncio.create_task(self._ensure_event_listener_ready())
182
+ turn_stopped = asyncio.create_task(turn_event.wait())
183
+ try:
184
+ await asyncio.wait(
185
+ {listener_ready, turn_stopped},
186
+ return_when=asyncio.FIRST_COMPLETED,
187
+ )
188
+ if self._turn_cancellations.get(session_id) == operation_id:
189
+ return False
190
+ if turn_event.is_set():
191
+ message = self._turn_errors.get(
192
+ session_id,
193
+ "ACP operation stopped before Gateway admission",
194
+ )
195
+ raise RequestError(code=-32603, message=message)
196
+ await listener_ready
197
+ return True
198
+ finally:
199
+ for task in (listener_ready, turn_stopped):
200
+ if not task.done():
201
+ task.cancel()
202
+ for task in (listener_ready, turn_stopped):
203
+ try:
204
+ await task
205
+ except asyncio.CancelledError:
206
+ pass
207
+ except Exception:
208
+ pass
209
+
210
+ @staticmethod
211
+ def _consume_task_result(task: asyncio.Task[Any]) -> None:
212
+ try:
213
+ task.result()
214
+ except asyncio.CancelledError:
215
+ pass
216
+ except Exception:
217
+ logger.debug("ACP event listener task failed", exc_info=True)
218
+
219
+ def _start_interaction_task(
220
+ self,
221
+ generation: int,
222
+ session_id: str,
223
+ operation_id: str,
224
+ payload: dict[str, Any],
225
+ ) -> None:
226
+ task = asyncio.create_task(
227
+ self._run_interaction(
228
+ generation,
229
+ session_id,
230
+ operation_id,
231
+ payload,
232
+ )
233
+ )
234
+ self._interaction_tasks[task] = (generation, session_id, operation_id)
235
+ task.add_done_callback(self._interaction_task_done)
236
+
237
+ def _interaction_task_done(self, task: asyncio.Task[Any]) -> None:
238
+ self._interaction_tasks.pop(task, None)
239
+ self._consume_task_result(task)
240
+
241
+ async def _cancel_interaction_tasks(self, generation: int | None = None) -> None:
242
+ tasks = [
243
+ task
244
+ for task, (task_generation, _session_id, _operation_id)
245
+ in self._interaction_tasks.items()
246
+ if generation is None or task_generation == generation
247
+ ]
248
+ for task in tasks:
249
+ task.cancel()
250
+ if tasks:
251
+ await asyncio.gather(*tasks, return_exceptions=True)
252
+
253
+ async def _cancel_operation_interactions(
254
+ self,
255
+ session_id: str,
256
+ operation_id: str,
257
+ ) -> None:
258
+ tasks = [
259
+ task
260
+ for task, (_generation, task_session, task_operation) in list(
261
+ self._interaction_tasks.items()
262
+ )
263
+ if task_session == session_id and task_operation == operation_id
264
+ ]
265
+ for task in tasks:
266
+ task.cancel()
267
+ if tasks:
268
+ await asyncio.gather(*tasks, return_exceptions=True)
269
+
270
+ def _interaction_is_current(
271
+ self,
272
+ generation: int,
273
+ session_id: str,
274
+ operation_id: str,
275
+ ) -> bool:
276
+ return (
277
+ not self._closed
278
+ and generation == self._listener_generation
279
+ and self._turn_operations.get(session_id) == operation_id
280
+ and (session_id, operation_id) not in self._turn_releases
281
+ )
282
+
283
+ async def _run_interaction(
284
+ self,
285
+ generation: int,
286
+ session_id: str,
287
+ operation_id: str,
288
+ payload: dict[str, Any],
289
+ ) -> None:
290
+ if not self._interaction_is_current(generation, session_id, operation_id):
291
+ return
292
+ event_type = payload.get("type")
293
+ try:
294
+ if event_type == "permission_request":
295
+ await self._handle_permission_request(
296
+ session_id,
297
+ payload,
298
+ generation=generation,
299
+ operation_id=operation_id,
300
+ )
301
+ elif event_type == "choice_request":
302
+ await self._handle_choice_request(
303
+ session_id,
304
+ payload,
305
+ generation=generation,
306
+ operation_id=operation_id,
307
+ )
308
+ except asyncio.CancelledError:
309
+ raise
310
+ except Exception:
311
+ logger.exception("acp_interaction_failed")
312
+ if not self._interaction_is_current(
313
+ generation,
314
+ session_id,
315
+ operation_id,
316
+ ):
317
+ return
318
+ if event_type == "permission_request":
319
+ delivered = await self._reply_permission(
320
+ str(payload.get("tool_use_id") or ""),
321
+ session_id,
322
+ "reject",
323
+ agent_id=payload.get("agent_id"),
324
+ )
325
+ if not delivered:
326
+ await self._fail_interaction_reply(
327
+ session_id,
328
+ operation_id,
329
+ "Failed to deliver permission response to Gateway",
330
+ )
331
+ elif event_type == "choice_request":
332
+ delivered = await self._reply_choice(
333
+ str(payload.get("tool_use_id") or ""),
334
+ session_id,
335
+ selected=[],
336
+ cancelled=True,
337
+ agent_id=payload.get("agent_id"),
338
+ )
339
+ if not delivered:
340
+ await self._fail_interaction_reply(
341
+ session_id,
342
+ operation_id,
343
+ "Failed to deliver choice response to Gateway",
344
+ )
345
+
346
+ async def _event_listener(
347
+ self,
348
+ generation: int,
349
+ ready: asyncio.Event,
350
+ ) -> None:
351
+ """Subscribe to Gateway EventBus SSE and translate CoreEvents to ACP session_updates."""
352
+ client = self._session_mgr.client
353
+ failure: str | None = None
354
+ try:
355
+ async with client.stream("GET", "/event") as resp:
356
+ resp.raise_for_status()
357
+ async for line in resp.aiter_lines():
358
+ if not line or not line.startswith("data:"):
359
+ continue
360
+ payload_str = line[len("data:"):].strip()
361
+ if not payload_str:
362
+ continue
363
+ try:
364
+ import json
365
+ payload = json.loads(payload_str)
366
+ except json.JSONDecodeError:
367
+ continue
368
+ if payload.get("type") == "server.connected":
369
+ if generation == self._listener_generation:
370
+ ready.set()
371
+ continue
372
+ if generation != self._listener_generation:
373
+ return
374
+ try:
375
+ await self._handle_event_payload(
376
+ payload,
377
+ generation=generation,
378
+ )
379
+ except Exception:
380
+ logger.exception("acp_event_handler_error")
381
+ failure = "Gateway event stream ended"
382
+ except asyncio.CancelledError:
383
+ return
384
+ except Exception as exc:
385
+ failure = f"Gateway event stream failed: {exc}"
386
+ logger.exception("acp_event_listener_stopped")
387
+ finally:
388
+ if failure and generation == self._listener_generation and not self._closed:
389
+ self._listener_error = failure
390
+ ready.set()
391
+ self._fail_active_prompts(failure)
392
+ await self._cancel_interaction_tasks(generation)
393
+
394
+ async def _handle_event_payload(
395
+ self,
396
+ payload: dict[str, Any],
397
+ *,
398
+ generation: int | None = None,
399
+ ) -> None:
400
+ """Translate a Gateway EventBus payload into ACP session_update(s)."""
401
+ event_type = payload.get("type", "")
402
+ session_id = payload.get("session_id", "")
403
+ state = self._session_mgr.try_get(session_id)
404
+ if not state:
405
+ return
406
+
407
+ active_operation = self._turn_operations.get(session_id)
408
+ if (
409
+ active_operation is None
410
+ or payload.get("operation_scope") != "foreground"
411
+ or payload.get("operation_id") != active_operation
412
+ or (session_id, active_operation) in self._turn_releases
413
+ ):
414
+ return
415
+
416
+ if event_type in {"permission_request", "choice_request"}:
417
+ if generation is None:
418
+ if event_type == "permission_request":
419
+ await self._handle_permission_request(session_id, payload)
420
+ else:
421
+ await self._handle_choice_request(session_id, payload)
422
+ else:
423
+ self._start_interaction_task(
424
+ generation,
425
+ session_id,
426
+ active_operation,
427
+ payload,
428
+ )
429
+ elif event_type == "tool_use":
430
+ await self._handle_tool_use(session_id, payload)
431
+ elif event_type == "tool_result":
432
+ await self._handle_tool_result(session_id, payload)
433
+ elif event_type == "stream_text":
434
+ await self._push_agent_message_chunk(session_id, payload.get("text", ""))
435
+ elif event_type == "thinking":
436
+ await self._push_agent_thought_chunk(session_id, payload.get("text", ""))
437
+ elif event_type == "turn_complete":
438
+ usage = _usage_from_payload(payload.get("usage"))
439
+ self._turn_usage[session_id] = usage
440
+ self._turn_reasons.setdefault(
441
+ session_id,
442
+ str(payload.get("reason") or "end_turn"),
443
+ )
444
+ waiter = self._turn_events.get(session_id)
445
+ if waiter is not None:
446
+ waiter.set()
447
+ elif event_type == "error":
448
+ if self._turn_cancellations.get(session_id) == active_operation:
449
+ return
450
+ # Managed-agent failures are lifecycle events for that background
451
+ # run, not failures of the active top-level ACP prompt. The agent
452
+ # state/callback channels report them separately. Callback and
453
+ # monitor continuation failures predate agent attribution but are
454
+ # likewise emitted outside the active foreground turn.
455
+ if (
456
+ payload.get("agent_id") is not None
457
+ or payload.get("error_type") in {"agent_callback", "monitor_callback"}
458
+ ):
459
+ return
460
+ # A failed query may not emit turn_complete. Wake the ACP
461
+ # prompt waiter and surface the error instead of hanging for the
462
+ # full timeout.
463
+ self._turn_errors[session_id] = str(payload.get("message") or "Query failed")
464
+ waiter = self._turn_events.get(session_id)
465
+ if waiter is not None:
466
+ waiter.set()
467
+
468
+ async def _handle_permission_request(
469
+ self,
470
+ session_id: str,
471
+ payload: dict[str, Any],
472
+ *,
473
+ generation: int | None = None,
474
+ operation_id: str | None = None,
475
+ ) -> None:
476
+ """Forward a CrabCode permission request to the ACP client."""
477
+ tool_name = payload.get("tool_name", "")
478
+ tool_use_id = payload.get("tool_use_id", "")
479
+ tool_input = payload.get("tool_input", {})
480
+ agent_id = payload.get("agent_id")
481
+
482
+ tool_kind = to_tool_kind(tool_name)
483
+ locations = to_locations(tool_name, tool_input)
484
+
485
+ tool_call = S.ToolCallUpdate(
486
+ tool_call_id=tool_use_id,
487
+ status="pending",
488
+ title=tool_name,
489
+ kind=tool_kind,
490
+ raw_input=tool_input,
491
+ locations=[S.ToolCallLocation(**loc) for loc in locations],
492
+ )
493
+
494
+ try:
495
+ result = await self._connection.request_permission(
496
+ options=_PERMISSION_OPTIONS,
497
+ session_id=session_id,
498
+ tool_call=tool_call,
499
+ )
500
+ except Exception:
501
+ logger.exception("acp_request_permission_failed")
502
+ if self._interaction_reply_allowed(
503
+ generation,
504
+ session_id,
505
+ operation_id,
506
+ ):
507
+ delivered = await self._reply_permission(
508
+ tool_use_id,
509
+ session_id,
510
+ "reject",
511
+ agent_id=agent_id,
512
+ )
513
+ if not delivered:
514
+ await self._fail_interaction_reply(
515
+ session_id,
516
+ operation_id,
517
+ "Failed to deliver permission response to Gateway",
518
+ )
519
+ return
520
+
521
+ if not self._interaction_reply_allowed(generation, session_id, operation_id):
522
+ return
523
+ outcome = result.outcome if result else None
524
+ if not outcome or not isinstance(outcome, AllowedOutcome) or outcome.outcome != "selected":
525
+ delivered = await self._reply_permission(
526
+ tool_use_id,
527
+ session_id,
528
+ "reject",
529
+ agent_id=agent_id,
530
+ )
531
+ if not delivered:
532
+ await self._fail_interaction_reply(
533
+ session_id,
534
+ operation_id,
535
+ "Failed to deliver permission response to Gateway",
536
+ )
537
+ return
538
+
539
+ option_id = outcome.option_id if hasattr(outcome, "option_id") else "reject"
540
+ delivered = await self._reply_permission(
541
+ tool_use_id,
542
+ session_id,
543
+ option_id,
544
+ agent_id=agent_id,
545
+ )
546
+ if not delivered:
547
+ await self._fail_interaction_reply(
548
+ session_id,
549
+ operation_id,
550
+ "Failed to deliver permission response to Gateway",
551
+ )
552
+
553
+ def _interaction_reply_allowed(
554
+ self,
555
+ generation: int | None,
556
+ session_id: str,
557
+ operation_id: str | None,
558
+ ) -> bool:
559
+ if generation is None or operation_id is None:
560
+ return True
561
+ return self._interaction_is_current(generation, session_id, operation_id)
562
+
563
+ async def _reply_permission(
564
+ self,
565
+ tool_use_id: str,
566
+ session_id: str,
567
+ reply: str,
568
+ *,
569
+ agent_id: str | None = None,
570
+ ) -> bool:
571
+ """Send a permission response back to the Gateway."""
572
+ allowed = reply in ("once", "always")
573
+ always = reply == "always"
574
+ try:
575
+ response = await self._session_mgr.client.post(
576
+ "/permission/respond",
577
+ json={
578
+ "tool_use_id": tool_use_id,
579
+ "allowed": allowed,
580
+ "always_allow": always,
581
+ "session_id": session_id,
582
+ "agent_id": agent_id,
583
+ },
584
+ )
585
+ response.raise_for_status()
586
+ return True
587
+ except Exception:
588
+ logger.exception("acp_reply_permission_failed")
589
+ return False
590
+
591
+ async def _handle_choice_request(
592
+ self,
593
+ session_id: str,
594
+ payload: dict[str, Any],
595
+ *,
596
+ generation: int | None = None,
597
+ operation_id: str | None = None,
598
+ ) -> None:
599
+ tool_use_id = str(payload.get("tool_use_id") or "")
600
+ question = str(payload.get("question") or "Choose an option")
601
+ options = [str(option) for option in payload.get("options") or []]
602
+ agent_id = payload.get("agent_id")
603
+
604
+ if payload.get("multiple") or not options:
605
+ if self._interaction_reply_allowed(generation, session_id, operation_id):
606
+ delivered = await self._reply_choice(
607
+ tool_use_id,
608
+ session_id,
609
+ selected=[],
610
+ cancelled=True,
611
+ agent_id=agent_id,
612
+ )
613
+ if not delivered:
614
+ await self._fail_interaction_reply(
615
+ session_id,
616
+ operation_id,
617
+ "Failed to deliver choice response to Gateway",
618
+ )
619
+ return
620
+
621
+ option_ids = {f"choice-{index}": option for index, option in enumerate(options)}
622
+ permission_options = [
623
+ PermissionOption(
624
+ option_id=option_id,
625
+ kind="allow_once",
626
+ name=option,
627
+ )
628
+ for option_id, option in option_ids.items()
629
+ ]
630
+ tool_call = S.ToolCallUpdate(
631
+ tool_call_id=tool_use_id,
632
+ status="pending",
633
+ title=question,
634
+ kind="other",
635
+ raw_input={"question": question, "options": options},
636
+ )
637
+ try:
638
+ result = await self._connection.request_permission(
639
+ options=permission_options,
640
+ session_id=session_id,
641
+ tool_call=tool_call,
642
+ )
643
+ except Exception:
644
+ logger.exception("acp_choice_request_failed")
645
+ result = None
646
+
647
+ if not self._interaction_reply_allowed(generation, session_id, operation_id):
648
+ return
649
+ outcome = result.outcome if result else None
650
+ selected = (
651
+ option_ids.get(outcome.option_id)
652
+ if isinstance(outcome, AllowedOutcome) and outcome.outcome == "selected"
653
+ else None
654
+ )
655
+ delivered = await self._reply_choice(
656
+ tool_use_id,
657
+ session_id,
658
+ selected=[selected] if selected is not None else [],
659
+ cancelled=selected is None,
660
+ agent_id=agent_id,
661
+ )
662
+ if not delivered:
663
+ await self._fail_interaction_reply(
664
+ session_id,
665
+ operation_id,
666
+ "Failed to deliver choice response to Gateway",
667
+ )
668
+
669
+ async def _reply_choice(
670
+ self,
671
+ tool_use_id: str,
672
+ session_id: str,
673
+ *,
674
+ selected: list[str],
675
+ cancelled: bool,
676
+ agent_id: str | None = None,
677
+ ) -> bool:
678
+ try:
679
+ response = await self._session_mgr.client.post(
680
+ "/choice/respond",
681
+ json={
682
+ "tool_use_id": tool_use_id,
683
+ "selected": selected,
684
+ "cancelled": cancelled,
685
+ "session_id": session_id,
686
+ "agent_id": agent_id,
687
+ },
688
+ )
689
+ response.raise_for_status()
690
+ return True
691
+ except Exception:
692
+ logger.exception("acp_reply_choice_failed")
693
+ return False
694
+
695
+ async def _fail_interaction_reply(
696
+ self,
697
+ session_id: str,
698
+ operation_id: str | None,
699
+ message: str,
700
+ ) -> None:
701
+ """Stop an operation whose interactive response could not be routed."""
702
+ if operation_id is None or self._turn_operations.get(session_id) != operation_id:
703
+ return
704
+ if not await self._interrupt_operation(session_id, operation_id):
705
+ return
706
+ if self._turn_operations.get(session_id) != operation_id:
707
+ return
708
+ waiter = self._turn_events.get(session_id)
709
+ if waiter is None or waiter.is_set():
710
+ return
711
+ self._turn_errors[session_id] = message
712
+ waiter.set()
713
+
714
+ async def _handle_tool_use(self, session_id: str, payload: dict[str, Any]) -> None:
715
+ """Tool call started — push tool_call start notification."""
716
+ tool_name = payload.get("tool_name", "")
717
+ tool_use_id = payload.get("tool_use_id", "")
718
+ tool_input = payload.get("tool_input", {})
719
+
720
+ tool_key = (session_id, tool_use_id)
721
+ if tool_key in self._tool_starts:
722
+ return
723
+ self._tool_starts.add(tool_key)
724
+
725
+ tool_kind = to_tool_kind(tool_name)
726
+ locations = to_locations(tool_name, tool_input)
727
+
728
+ update = ToolCallStart(
729
+ session_update="tool_call",
730
+ tool_call_id=tool_use_id,
731
+ title=tool_name,
732
+ kind=tool_kind,
733
+ status="pending",
734
+ locations=[S.ToolCallLocation(**loc) for loc in locations],
735
+ raw_input=tool_input,
736
+ )
737
+ await self._connection.session_update(session_id=session_id, update=update)
738
+
739
+ async def _handle_tool_result(self, session_id: str, payload: dict[str, Any]) -> None:
740
+ """Tool call completed — push tool_call_update."""
741
+ tool_name = payload.get("tool_name", "")
742
+ tool_use_id = payload.get("tool_use_id", "")
743
+ result_text = payload.get("result", "")
744
+ is_error = payload.get("is_error", False)
745
+ tool_input = payload.get("tool_input", {})
746
+
747
+ self._tool_starts.discard((session_id, tool_use_id))
748
+ tool_kind = to_tool_kind(tool_name)
749
+ locations = to_locations(tool_name, tool_input)
750
+
751
+ content: list[ContentToolCallContent | FileEditToolCallContent] = [
752
+ ContentToolCallContent(type="content", content=TextContent(type="text", text=result_text)),
753
+ ]
754
+
755
+ # Add diff for edit tools
756
+ if tool_kind == "edit":
757
+ file_path = tool_input.get("filePath") or tool_input.get("file_path") or ""
758
+ old_text = tool_input.get("oldString") or tool_input.get("old_string") or ""
759
+ new_text = tool_input.get("newString") or tool_input.get("new_string") or tool_input.get("content") or ""
760
+ content.append(
761
+ FileEditToolCallContent(type="diff", path=file_path, old_text=old_text, new_text=new_text)
762
+ )
763
+
764
+ status = "failed" if is_error else "completed"
765
+
766
+ update = ToolCallProgress(
767
+ session_update="tool_call_update",
768
+ tool_call_id=tool_use_id,
769
+ title=tool_name,
770
+ kind=tool_kind,
771
+ status=status,
772
+ content=content,
773
+ locations=[S.ToolCallLocation(**loc) for loc in locations],
774
+ raw_input=tool_input,
775
+ raw_output={"output": result_text} if not is_error else {"error": result_text},
776
+ )
777
+ await self._connection.session_update(session_id=session_id, update=update)
778
+
779
+ async def _push_agent_message_chunk(self, session_id: str, text: str) -> None:
780
+ update = acp.update_agent_message_text(text)
781
+ await self._connection.session_update(session_id=session_id, update=update)
782
+
783
+ async def _push_agent_thought_chunk(self, session_id: str, text: str) -> None:
784
+ update = acp.update_agent_thought_text(text)
785
+ await self._connection.session_update(session_id=session_id, update=update)
786
+
787
+ # ── ACP Agent interface ─────────────────────────────────────
788
+
789
+ async def initialize(
790
+ self,
791
+ protocol_version: int,
792
+ client_capabilities: S.ClientCapabilities | None = None,
793
+ client_info: S.Implementation | None = None,
794
+ **kwargs: Any,
795
+ ) -> InitializeResponse:
796
+ logger.info("acp_initialize", extra={"protocol_version": protocol_version})
797
+
798
+ return InitializeResponse(
799
+ protocol_version=acp.PROTOCOL_VERSION,
800
+ agent_capabilities=AgentCapabilities(
801
+ load_session=True,
802
+ mcp_capabilities=McpCapabilities(http=True, sse=True),
803
+ prompt_capabilities=PromptCapabilities(embedded_context=True, image=True),
804
+ session_capabilities=SessionCapabilities(
805
+ fork=SessionForkCapabilities(),
806
+ list=SessionListCapabilities(),
807
+ resume=SessionResumeCapabilities(),
808
+ ),
809
+ ),
810
+ # Gateway authentication is handled by the transport (Basic Auth
811
+ # / bearer middleware). Do not advertise an ACP login method that
812
+ # the CLI does not implement.
813
+ auth_methods=[],
814
+ agent_info=Implementation(name="CrabCode", version=VERSION),
815
+ )
816
+
817
+ async def new_session(
818
+ self,
819
+ cwd: str,
820
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
821
+ **kwargs: Any,
822
+ ) -> NewSessionResponse:
823
+ if self._closed:
824
+ raise RequestError(code=-32603, message="ACP agent is closed")
825
+ model = self._config.default_model
826
+ try:
827
+ state = await self._session_mgr.create(cwd, mcp_servers or [], model)
828
+ except Exception as e:
829
+ raise RequestError(code=-32603, message=str(e))
830
+
831
+ models_state = await self._build_models_state(state)
832
+ modes_state = await self._build_modes_state(state)
833
+ config_options = _build_config_options(models_state, modes_state)
834
+
835
+ return NewSessionResponse(
836
+ session_id=state.id,
837
+ models=models_state,
838
+ modes=modes_state,
839
+ config_options=config_options,
840
+ )
841
+
842
+ async def load_session(
843
+ self,
844
+ cwd: str,
845
+ session_id: str,
846
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
847
+ **kwargs: Any,
848
+ ) -> LoadSessionResponse | None:
849
+ if self._closed:
850
+ raise RequestError(code=-32603, message="ACP agent is closed")
851
+ model = self._config.default_model
852
+ try:
853
+ state = await self._session_mgr.load(session_id, cwd, mcp_servers or [], model)
854
+ except Exception as e:
855
+ raise RequestError(code=-32603, message=str(e))
856
+
857
+ models_state = await self._build_models_state(state)
858
+ modes_state = await self._build_modes_state(state)
859
+ config_options = _build_config_options(models_state, modes_state)
860
+
861
+ # Replay messages for loaded session
862
+ await self._replay_messages(state)
863
+
864
+ # Push usage update
865
+ await self._send_usage_update(state)
866
+
867
+ return LoadSessionResponse(
868
+ models=models_state,
869
+ modes=modes_state,
870
+ config_options=config_options,
871
+ )
872
+
873
+ async def list_sessions(
874
+ self,
875
+ cursor: str | None = None,
876
+ cwd: str | None = None,
877
+ **kwargs: Any,
878
+ ) -> ListSessionsResponse:
879
+ try:
880
+ params: dict[str, Any] = {}
881
+ if cwd:
882
+ params["cwd"] = cwd
883
+ resp = await self._session_mgr.client.get("/session/list", params=params)
884
+ resp.raise_for_status()
885
+ data = resp.json()
886
+ except Exception as e:
887
+ raise RequestError(code=-32603, message=str(e))
888
+
889
+ entries: list[SessionInfo] = []
890
+ for s in data:
891
+ entries.append(SessionInfo(
892
+ session_id=s.get("session_id", ""),
893
+ cwd=s.get("cwd", cwd or ""),
894
+ title=s.get("title"),
895
+ updated_at=s.get("updated_at"),
896
+ ))
897
+
898
+ return ListSessionsResponse(sessions=entries)
899
+
900
+ async def fork_session(
901
+ self,
902
+ cwd: str,
903
+ session_id: str,
904
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
905
+ **kwargs: Any,
906
+ ) -> ForkSessionResponse:
907
+ if self._closed:
908
+ raise RequestError(code=-32603, message="ACP agent is closed")
909
+ try:
910
+ # ACP currently identifies the source session, not a message. Use
911
+ # its latest completed assistant reply; Desktop exposes the more
912
+ # precise message-level fork endpoint directly.
913
+ messages_resp = await self._session_mgr.client.get(
914
+ "/session/messages",
915
+ params={"session_id": session_id},
916
+ )
917
+ messages_resp.raise_for_status()
918
+ messages = messages_resp.json()
919
+ requested_uuid = kwargs.get("message_uuid") or kwargs.get("messageUuid")
920
+ assistant_uuid = str(requested_uuid) if requested_uuid else next(
921
+ (
922
+ str(message.get("uuid"))
923
+ for message in reversed(messages)
924
+ if isinstance(message, dict)
925
+ and message.get("role") == "assistant"
926
+ and message.get("uuid")
927
+ ),
928
+ None,
929
+ )
930
+ if assistant_uuid is None:
931
+ raise RequestError(code=-32602, message="Session has no assistant reply to fork")
932
+ response = await self._session_mgr.client.post(
933
+ "/session/fork",
934
+ json={"session_id": session_id, "message_uuid": assistant_uuid},
935
+ )
936
+ response.raise_for_status()
937
+ data = response.json()
938
+ forked_id = str(data["session_id"])
939
+ state = ACPSessionState(
940
+ id=forked_id,
941
+ cwd=str(data.get("cwd") or cwd),
942
+ mcp_servers=mcp_servers or [],
943
+ created_at=time.time(),
944
+ model=self._config.default_model,
945
+ )
946
+ self._session_mgr._sessions[forked_id] = state
947
+ models_state = await self._build_models_state(state)
948
+ modes_state = await self._build_modes_state(state)
949
+ return ForkSessionResponse(
950
+ session_id=forked_id,
951
+ models=models_state,
952
+ modes=modes_state,
953
+ config_options=_build_config_options(models_state, modes_state),
954
+ )
955
+ except RequestError:
956
+ raise
957
+ except Exception as e:
958
+ raise RequestError(code=-32603, message=str(e))
959
+
960
+ async def resume_session(
961
+ self,
962
+ cwd: str,
963
+ session_id: str,
964
+ mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
965
+ **kwargs: Any,
966
+ ) -> ResumeSessionResponse:
967
+ if self._closed:
968
+ raise RequestError(code=-32603, message="ACP agent is closed")
969
+ model = self._config.default_model
970
+ try:
971
+ state = await self._session_mgr.load(session_id, cwd, mcp_servers or [], model)
972
+ except Exception as e:
973
+ raise RequestError(code=-32603, message=str(e))
974
+
975
+ models_state = await self._build_models_state(state)
976
+ modes_state = await self._build_modes_state(state)
977
+ config_options = _build_config_options(models_state, modes_state)
978
+
979
+ await self._send_usage_update(state)
980
+ await self._replay_messages(state)
981
+
982
+ return ResumeSessionResponse(
983
+ models=models_state,
984
+ modes=modes_state,
985
+ config_options=config_options,
986
+ )
987
+
988
+ async def prompt(
989
+ self,
990
+ prompt: list,
991
+ session_id: str,
992
+ message_id: str | None = None,
993
+ **kwargs: Any,
994
+ ) -> PromptResponse:
995
+ if self._closed:
996
+ raise RequestError(code=-32603, message="ACP agent is closed")
997
+ self._session_mgr.get(session_id) # validate session exists
998
+
999
+ # Convert ACP prompt parts to simple text
1000
+ text_parts: list[str] = []
1001
+ for part in prompt:
1002
+ if hasattr(part, "text"):
1003
+ text_parts.append(part.text)
1004
+ elif hasattr(part, "uri"):
1005
+ text_parts.append(f"[file: {part.uri}]")
1006
+ text = "\n".join(text_parts)
1007
+
1008
+ # Install the waiter before sending. The gateway can complete a very
1009
+ # short turn before the HTTP request returns, and that event must not
1010
+ # be lost between the response and waiter setup.
1011
+ if session_id in self._turn_operations:
1012
+ raise RequestError(code=-32000, message=f"Session {session_id} already has a prompt in progress")
1013
+ operation_id = uuid.uuid4().hex
1014
+ turn_event = asyncio.Event()
1015
+ self._turn_events[session_id] = turn_event
1016
+ self._turn_operations[session_id] = operation_id
1017
+ self._turn_send_settled.pop(session_id, None)
1018
+ self._turn_send_accepted.pop(session_id, None)
1019
+ self._turn_cancel_requests.pop(session_id, None)
1020
+ self._turn_cancellations.pop(session_id, None)
1021
+ self._turn_usage.pop(session_id, None)
1022
+ self._turn_errors.pop(session_id, None)
1023
+ self._turn_reasons.pop(session_id, None)
1024
+
1025
+ # Do not launch the foreground operation until the global SSE listener
1026
+ # has consumed its connected handshake. Otherwise a fast operation can
1027
+ # publish its terminal event before this ACP process is subscribed.
1028
+ send_attempted = False
1029
+ try:
1030
+ listener_ready = await self._wait_for_listener_or_turn_stop(
1031
+ session_id,
1032
+ operation_id,
1033
+ turn_event,
1034
+ )
1035
+ if not listener_ready:
1036
+ await self._release_turn_ownership(
1037
+ session_id,
1038
+ operation_id,
1039
+ turn_event,
1040
+ )
1041
+ self._discard_turn_result(session_id)
1042
+ return PromptResponse(
1043
+ stop_reason="cancelled",
1044
+ usage=None,
1045
+ user_message_id=message_id,
1046
+ )
1047
+ send_attempted = True
1048
+ send_settled = asyncio.Event()
1049
+ self._turn_send_settled[session_id] = (operation_id, send_settled)
1050
+ try:
1051
+ resp = await self._session_mgr.client.post(
1052
+ "/session/send",
1053
+ json={
1054
+ "text": text,
1055
+ "session_id": session_id,
1056
+ "operation_id": operation_id,
1057
+ },
1058
+ )
1059
+ resp.raise_for_status()
1060
+ finally:
1061
+ send_settled.set()
1062
+ self._turn_send_accepted[session_id] = operation_id
1063
+ if self._turn_cancel_requests.get(session_id) == operation_id:
1064
+ # cancel() may have raced the POST while the operation did not
1065
+ # yet exist server-side. Re-issue after successful admission.
1066
+ interrupted = await self._interrupt_operation(
1067
+ session_id,
1068
+ operation_id,
1069
+ )
1070
+ if interrupted:
1071
+ self._confirm_turn_cancel(session_id, operation_id)
1072
+ elif self._turn_cancel_requests.get(session_id) == operation_id:
1073
+ self._turn_cancel_requests.pop(session_id, None)
1074
+ except asyncio.CancelledError:
1075
+ await self._release_turn_ownership(
1076
+ session_id,
1077
+ operation_id,
1078
+ turn_event,
1079
+ )
1080
+ self._discard_turn_result(session_id)
1081
+ if send_attempted:
1082
+ await self._interrupt_after_task_cancellation(
1083
+ session_id,
1084
+ operation_id,
1085
+ )
1086
+ raise
1087
+ except Exception as e:
1088
+ await self._release_turn_ownership(
1089
+ session_id,
1090
+ operation_id,
1091
+ turn_event,
1092
+ )
1093
+ self._discard_turn_result(session_id)
1094
+ if send_attempted:
1095
+ await self._interrupt_operation(session_id, operation_id)
1096
+ if isinstance(e, RequestError):
1097
+ raise
1098
+ raise RequestError(code=-32603, message=str(e))
1099
+
1100
+ try:
1101
+ usage = await self._wait_turn_complete(
1102
+ session_id,
1103
+ operation_id=operation_id,
1104
+ event=turn_event,
1105
+ )
1106
+ except asyncio.CancelledError:
1107
+ self._discard_turn_result(session_id)
1108
+ await self._interrupt_after_task_cancellation(session_id, operation_id)
1109
+ raise
1110
+ except RequestError:
1111
+ # A timeout must not leave the Gateway query running indefinitely.
1112
+ self._discard_turn_result(session_id)
1113
+ if not self._closed:
1114
+ await self._interrupt_operation(session_id, operation_id)
1115
+ raise
1116
+
1117
+ reason = self._turn_reasons.pop(session_id, "end_turn")
1118
+ stop_reason = {
1119
+ "interrupted": "cancelled",
1120
+ "cancelled": "cancelled",
1121
+ "max_tokens": "max_tokens",
1122
+ "max_turns": "max_turn_requests",
1123
+ }.get(reason, "end_turn")
1124
+
1125
+ return PromptResponse(
1126
+ stop_reason=stop_reason,
1127
+ usage=usage,
1128
+ user_message_id=message_id,
1129
+ )
1130
+
1131
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
1132
+ self._session_mgr.get(session_id) # validate session exists
1133
+ operation_id = self._turn_operations.get(session_id)
1134
+ if operation_id is None:
1135
+ return
1136
+ send_state = self._turn_send_settled.get(session_id)
1137
+ send_started = send_state is not None and send_state[0] == operation_id
1138
+ if not send_started:
1139
+ self._confirm_turn_cancel(session_id, operation_id)
1140
+ return
1141
+
1142
+ self._turn_cancel_requests[session_id] = operation_id
1143
+ if self._turn_send_accepted.get(session_id) != operation_id:
1144
+ return
1145
+ interrupted = await self._interrupt_operation(session_id, operation_id)
1146
+ if self._turn_operations.get(session_id) != operation_id:
1147
+ return
1148
+ if interrupted:
1149
+ self._confirm_turn_cancel(session_id, operation_id)
1150
+ elif self._turn_cancel_requests.get(session_id) == operation_id:
1151
+ self._turn_cancel_requests.pop(session_id, None)
1152
+
1153
+ def _confirm_turn_cancel(self, session_id: str, operation_id: str) -> None:
1154
+ if self._turn_operations.get(session_id) != operation_id:
1155
+ return
1156
+ waiter = self._turn_events.get(session_id)
1157
+ if waiter is None or waiter.is_set():
1158
+ self._turn_cancel_requests.pop(session_id, None)
1159
+ return
1160
+ self._turn_cancel_requests.pop(session_id, None)
1161
+ self._turn_cancellations[session_id] = operation_id
1162
+ self._turn_reasons[session_id] = "cancelled"
1163
+ waiter.set()
1164
+
1165
+ async def _interrupt_operation(
1166
+ self,
1167
+ session_id: str,
1168
+ operation_id: str,
1169
+ ) -> bool:
1170
+ """Best-effort interrupt for one owned Gateway operation."""
1171
+ try:
1172
+ response = await self._session_mgr.client.post(
1173
+ "/session/interrupt",
1174
+ json={
1175
+ "session_id": session_id,
1176
+ "operation_id": operation_id,
1177
+ },
1178
+ )
1179
+ response.raise_for_status()
1180
+ return True
1181
+ except Exception:
1182
+ logger.debug("acp_operation_interrupt_failed", exc_info=True)
1183
+ return False
1184
+
1185
+ async def _interrupt_after_task_cancellation(
1186
+ self,
1187
+ session_id: str,
1188
+ operation_id: str,
1189
+ ) -> None:
1190
+ """Finish a targeted interrupt despite repeated caller cancellation."""
1191
+ cleanup = asyncio.create_task(
1192
+ self._interrupt_operation(session_id, operation_id)
1193
+ )
1194
+ while True:
1195
+ try:
1196
+ await asyncio.shield(cleanup)
1197
+ return
1198
+ except asyncio.CancelledError:
1199
+ if cleanup.done():
1200
+ try:
1201
+ cleanup.result()
1202
+ except asyncio.CancelledError:
1203
+ pass
1204
+ return
1205
+
1206
+ async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None:
1207
+ self._session_mgr.get(session_id) # validate session exists
1208
+ try:
1209
+ resp = await self._session_mgr.client.post(
1210
+ "/config/switch-model",
1211
+ json={"name": model_id, "session_id": session_id},
1212
+ )
1213
+ resp.raise_for_status()
1214
+ except Exception as exc:
1215
+ raise RequestError(code=-32603, message=str(exc)) from exc
1216
+ parts = model_id.split("/", 1)
1217
+ self._session_mgr.set_model(
1218
+ session_id,
1219
+ ModelSelection(
1220
+ provider_id=parts[0] if len(parts) == 2 else "",
1221
+ model_id=parts[1] if len(parts) == 2 else model_id,
1222
+ ),
1223
+ )
1224
+ return SetSessionModelResponse()
1225
+
1226
+ async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None:
1227
+ self._session_mgr.get(session_id)
1228
+ try:
1229
+ resp = await self._session_mgr.client.post(
1230
+ "/config/switch-mode",
1231
+ json={"mode": mode_id, "session_id": session_id},
1232
+ )
1233
+ resp.raise_for_status()
1234
+ except Exception as exc:
1235
+ raise RequestError(code=-32603, message=str(exc)) from exc
1236
+ self._session_mgr.set_mode(session_id, mode_id)
1237
+ return SetSessionModeResponse()
1238
+
1239
+ async def set_config_option(
1240
+ self, config_id: str, session_id: str, value: str | bool, **kwargs: Any
1241
+ ) -> SetSessionConfigOptionResponse | None:
1242
+ state = self._session_mgr.get(session_id)
1243
+ if config_id == "model" and isinstance(value, str):
1244
+ await self.set_session_model(value, session_id)
1245
+ elif config_id == "mode" and isinstance(value, str):
1246
+ await self.set_session_mode(value, session_id)
1247
+ else:
1248
+ raise RequestError(code=-32602, message=f"Unknown config option: {config_id}")
1249
+
1250
+ models_state = await self._build_models_state(state)
1251
+ modes_state = await self._build_modes_state(state)
1252
+ return SetSessionConfigOptionResponse(
1253
+ config_options=_build_config_options(models_state, modes_state),
1254
+ )
1255
+
1256
+ async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
1257
+ raise RequestError(code=-32601, message="Authentication not implemented")
1258
+
1259
+ # ── Helpers ─────────────────────────────────────────────────
1260
+
1261
+ async def _build_models_state(self, state: ACPSessionState) -> SessionModelState | None:
1262
+ """Build ACP SessionModelState from available Gateway config."""
1263
+ try:
1264
+ resp = await self._session_mgr.client.get(
1265
+ "/config/models",
1266
+ params={"session_id": state.id},
1267
+ )
1268
+ resp.raise_for_status()
1269
+ data = resp.json()
1270
+ if isinstance(data, list):
1271
+ models = data
1272
+ elif isinstance(data, dict):
1273
+ models = data.get("models", data.get("items", []))
1274
+ else:
1275
+ models = []
1276
+ if not isinstance(models, list):
1277
+ models = []
1278
+ available = [
1279
+ ModelInfo(
1280
+ model_id=str(
1281
+ m.get("modelId")
1282
+ or m.get("model_id")
1283
+ or m.get("id")
1284
+ or m.get("name")
1285
+ or ""
1286
+ ),
1287
+ name=str(m.get("name") or m.get("id") or m.get("modelId") or ""),
1288
+ description=m.get("description"),
1289
+ )
1290
+ for m in models
1291
+ if isinstance(m, dict)
1292
+ ]
1293
+ current = ""
1294
+ if state.model:
1295
+ current = f"{state.model.provider_id}/{state.model.model_id}"
1296
+ elif available:
1297
+ current = available[0].model_id
1298
+ return SessionModelState(current_model_id=current, available_models=available)
1299
+ except Exception:
1300
+ logger.exception("acp_build_models_failed")
1301
+ return None
1302
+
1303
+ async def _build_modes_state(self, state: ACPSessionState) -> SessionModeState | None:
1304
+ """Build ACP SessionModeState."""
1305
+ modes = [
1306
+ SessionMode(id="agent", name="Agent", description="Autonomous coding agent"),
1307
+ SessionMode(id="plan", name="Plan", description="Plan before executing"),
1308
+ ]
1309
+ current = state.mode_id or "agent"
1310
+ return SessionModeState(current_mode_id=current, available_modes=modes)
1311
+
1312
+ async def _replay_messages(self, state: ACPSessionState) -> None:
1313
+ """Replay session history as ACP updates (for load_session)."""
1314
+ try:
1315
+ resp = await self._session_mgr.client.get(
1316
+ "/session/messages",
1317
+ params={"session_id": state.id},
1318
+ )
1319
+ if resp.status_code != 200:
1320
+ return
1321
+ data = resp.json()
1322
+ if not isinstance(data, list):
1323
+ return
1324
+ for msg in data:
1325
+ role = msg.get("role", "")
1326
+ content = msg.get("content", "")
1327
+ if isinstance(content, list):
1328
+ content = "\n".join(
1329
+ str(item.get("text", ""))
1330
+ for item in content
1331
+ if isinstance(item, dict) and item.get("text")
1332
+ )
1333
+ if isinstance(content, str) and content:
1334
+ if role == "assistant":
1335
+ await self._push_agent_message_chunk(state.id, content)
1336
+ elif role == "user":
1337
+ update = acp.update_user_message_text(content)
1338
+ await self._connection.session_update(session_id=state.id, update=update)
1339
+ except Exception:
1340
+ logger.exception("acp_replay_failed")
1341
+
1342
+ async def _send_usage_update(self, state: ACPSessionState) -> None:
1343
+ """Push a usage_update notification to the ACP client."""
1344
+ try:
1345
+ resp = await self._session_mgr.client.get(
1346
+ "/session/status",
1347
+ params={"session_id": state.id},
1348
+ )
1349
+ if resp.status_code != 200:
1350
+ return
1351
+ payload = resp.json()
1352
+ if not isinstance(payload, dict):
1353
+ return
1354
+ size = max(0, int(payload.get("context_window_tokens") or 0))
1355
+ used = max(0, int(payload.get("context_used_tokens") or 0))
1356
+ if not size and not used:
1357
+ return
1358
+ update = UsageUpdate(session_update="usage_update", size=size, used=used)
1359
+ if self._connection is not None:
1360
+ await self._connection.session_update(session_id=state.id, update=update)
1361
+ except Exception:
1362
+ logger.debug("acp_usage_update_failed", exc_info=True)
1363
+
1364
+ async def _wait_turn_complete(
1365
+ self,
1366
+ session_id: str,
1367
+ timeout: float = 120.0,
1368
+ *,
1369
+ operation_id: str | None = None,
1370
+ event: asyncio.Event | None = None,
1371
+ ) -> Usage | None:
1372
+ """Wait for the agent turn to complete via EventBus SSE.
1373
+
1374
+ The event listener resolves a per-session event when the gateway
1375
+ publishes ``turn_complete`` (or an error). A timeout is reported to
1376
+ the ACP client rather than falsely acknowledging an unfinished turn.
1377
+ """
1378
+ waiter = event or self._turn_events.get(session_id)
1379
+ if waiter is None:
1380
+ raise RequestError(code=-32602, message=f"Session not found: {session_id}")
1381
+ try:
1382
+ await asyncio.wait_for(waiter.wait(), timeout=timeout)
1383
+ except asyncio.TimeoutError as exc:
1384
+ raise RequestError(
1385
+ code=-32001,
1386
+ message=f"Timed out waiting for session {session_id} to complete",
1387
+ ) from exc
1388
+ finally:
1389
+ if operation_id is not None:
1390
+ await self._release_turn_ownership(
1391
+ session_id,
1392
+ operation_id,
1393
+ waiter,
1394
+ )
1395
+ elif self._turn_events.get(session_id) is waiter:
1396
+ self._turn_events.pop(session_id, None)
1397
+
1398
+ error = self._turn_errors.pop(session_id, None)
1399
+ if error:
1400
+ raise RequestError(code=-32603, message=error)
1401
+ return self._turn_usage.pop(session_id, None)
1402
+
1403
+ async def _release_turn_ownership(
1404
+ self,
1405
+ session_id: str,
1406
+ operation_id: str,
1407
+ waiter: asyncio.Event,
1408
+ ) -> None:
1409
+ if (
1410
+ self._turn_operations.get(session_id) != operation_id
1411
+ or self._turn_events.get(session_id) is not waiter
1412
+ ):
1413
+ return
1414
+
1415
+ key = (session_id, operation_id)
1416
+ release_done = self._turn_releases.get(key)
1417
+ if release_done is not None:
1418
+ await release_done.wait()
1419
+ return
1420
+
1421
+ release_done = asyncio.Event()
1422
+ self._turn_releases[key] = release_done
1423
+ try:
1424
+ await self._cancel_operation_interactions(session_id, operation_id)
1425
+ finally:
1426
+ if (
1427
+ self._turn_operations.get(session_id) == operation_id
1428
+ and self._turn_events.get(session_id) is waiter
1429
+ ):
1430
+ self._turn_operations.pop(session_id, None)
1431
+ self._turn_events.pop(session_id, None)
1432
+ send_state = self._turn_send_settled.get(session_id)
1433
+ if send_state is not None and send_state[0] == operation_id:
1434
+ self._turn_send_settled.pop(session_id, None)
1435
+ if self._turn_send_accepted.get(session_id) == operation_id:
1436
+ self._turn_send_accepted.pop(session_id, None)
1437
+ if self._turn_cancel_requests.get(session_id) == operation_id:
1438
+ self._turn_cancel_requests.pop(session_id, None)
1439
+ if self._turn_cancellations.get(session_id) == operation_id:
1440
+ self._turn_cancellations.pop(session_id, None)
1441
+ if self._turn_releases.get(key) is release_done:
1442
+ self._turn_releases.pop(key, None)
1443
+ release_done.set()
1444
+
1445
+ def _discard_turn_result(self, session_id: str) -> None:
1446
+ self._turn_usage.pop(session_id, None)
1447
+ self._turn_errors.pop(session_id, None)
1448
+ self._turn_reasons.pop(session_id, None)
1449
+
1450
+ async def close(self) -> None:
1451
+ """Clean up resources."""
1452
+ admitted_operations: list[
1453
+ tuple[str, str, asyncio.Event | None, asyncio.Event | None]
1454
+ ] = []
1455
+ for session_id, operation_id in self._turn_operations.items():
1456
+ send_state = self._turn_send_settled.get(session_id)
1457
+ send_settled = (
1458
+ send_state[1]
1459
+ if send_state is not None and send_state[0] == operation_id
1460
+ else None
1461
+ )
1462
+ if (
1463
+ self._turn_send_accepted.get(session_id) == operation_id
1464
+ or send_settled is not None
1465
+ ):
1466
+ admitted_operations.append(
1467
+ (
1468
+ session_id,
1469
+ operation_id,
1470
+ self._turn_events.get(session_id),
1471
+ send_settled,
1472
+ )
1473
+ )
1474
+ self._closed = True
1475
+ self._listener_generation += 1
1476
+ self._listener_ready.set()
1477
+ self._listener_error = "ACP agent closed"
1478
+ self._fail_active_prompts("ACP agent closed")
1479
+ for session_id, operation_id, waiter, send_settled in admitted_operations:
1480
+ if send_settled is not None:
1481
+ await send_settled.wait()
1482
+ await self._interrupt_operation(session_id, operation_id)
1483
+ if waiter is not None:
1484
+ await self._release_turn_ownership(
1485
+ session_id,
1486
+ operation_id,
1487
+ waiter,
1488
+ )
1489
+ if self._event_task:
1490
+ self._event_task.cancel()
1491
+ try:
1492
+ await self._event_task
1493
+ except asyncio.CancelledError:
1494
+ pass
1495
+ self._event_task = None
1496
+ await self._cancel_interaction_tasks()
1497
+ self._turn_usage.clear()
1498
+ self._turn_reasons.clear()
1499
+ await self._session_mgr.aclose()
1500
+
1501
+
1502
+ # ── Module-level helpers ───────────────────────────────────────
1503
+
1504
+
1505
+ def _build_config_options(
1506
+ models_state: SessionModelState | None,
1507
+ modes_state: SessionModeState | None,
1508
+ ) -> list[SessionConfigOptionSelect]:
1509
+ """Build ACP config option descriptors for the client."""
1510
+ options: list[SessionConfigOptionSelect] = []
1511
+
1512
+ if models_state:
1513
+ options.append(SessionConfigOptionSelect(
1514
+ id="model",
1515
+ name="Model",
1516
+ category="model",
1517
+ type="select",
1518
+ current_value=models_state.current_model_id,
1519
+ options=[
1520
+ SessionConfigSelectOption(value=m.model_id, name=m.name)
1521
+ for m in models_state.available_models
1522
+ ],
1523
+ ))
1524
+
1525
+ if modes_state:
1526
+ options.append(SessionConfigOptionSelect(
1527
+ id="mode",
1528
+ name="Session Mode",
1529
+ category="mode",
1530
+ type="select",
1531
+ current_value=modes_state.current_mode_id,
1532
+ options=[
1533
+ SessionConfigSelectOption(value=m.id, name=m.name, description=m.description)
1534
+ for m in modes_state.available_modes
1535
+ ],
1536
+ ))
1537
+
1538
+ return options
1539
+
1540
+
1541
+ def _usage_from_payload(raw: Any) -> Usage | None:
1542
+ """Translate a gateway usage mapping to ACP's required token fields."""
1543
+ if not isinstance(raw, dict):
1544
+ return None
1545
+
1546
+ def _number(*keys: str) -> int:
1547
+ for key in keys:
1548
+ value = raw.get(key)
1549
+ if isinstance(value, (int, float)) and value >= 0:
1550
+ return int(value)
1551
+ return 0
1552
+
1553
+ input_tokens = _number(
1554
+ "total_input_tokens",
1555
+ "input_tokens",
1556
+ "inputTokens",
1557
+ "prompt_tokens",
1558
+ )
1559
+ output_tokens = _number("output_tokens", "outputTokens", "completion_tokens")
1560
+ thought_tokens = _number("thought_tokens", "thoughtTokens", "reasoning_tokens")
1561
+ cached_read_tokens = _number("cache_read_tokens", "cachedReadTokens")
1562
+ cached_write_tokens = _number("cache_write_tokens", "cachedWriteTokens")
1563
+ total_tokens = _number("total_tokens", "totalTokens")
1564
+ if total_tokens == 0:
1565
+ total_tokens = input_tokens + output_tokens + thought_tokens
1566
+ if not (
1567
+ input_tokens
1568
+ or output_tokens
1569
+ or total_tokens
1570
+ or cached_read_tokens
1571
+ or cached_write_tokens
1572
+ ):
1573
+ return None
1574
+ return Usage(
1575
+ input_tokens=input_tokens,
1576
+ output_tokens=output_tokens,
1577
+ thought_tokens=thought_tokens or None,
1578
+ cached_read_tokens=cached_read_tokens or None,
1579
+ cached_write_tokens=cached_write_tokens or None,
1580
+ total_tokens=total_tokens,
1581
+ )