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,765 @@
1
+ """gRPC server implementation for CrabCode Gateway.
2
+
3
+ Uses the generated stubs checked in alongside ``crabcode.proto``. Keeping
4
+ the generated modules in the package makes the gRPC endpoint available in
5
+ normal installations without requiring ``grpcio-tools`` at runtime.
6
+
7
+ In production, run::
8
+
9
+ python -m grpc_tools.protoc \\
10
+ -I packages/gateway/crabcode_gateway/grpc/proto \\
11
+ --python_out=packages/gateway/crabcode_gateway/grpc \\
12
+ --grpc_python_out=packages/gateway/crabcode_gateway/grpc \\
13
+ packages/gateway/crabcode_gateway/grpc/proto/crabcode.proto
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import binascii
20
+ import hmac
21
+ import json
22
+ from concurrent import futures
23
+ from typing import Any
24
+
25
+ import grpc
26
+
27
+ from crabcode_core import VERSION
28
+ from crabcode_core.logging_utils import get_logger
29
+ from crabcode_gateway.adapter import ProtocolAdapter
30
+ from crabcode_gateway.auth import decode_jwt, verify_password
31
+ from crabcode_gateway.schemas import core_event_to_payload
32
+ from crabcode_gateway.task_registry import (
33
+ SessionOperationRejected,
34
+ run_session_operation,
35
+ session_lease,
36
+ )
37
+
38
+ logger = get_logger(__name__)
39
+
40
+ _PROTO_INT64_MIN = -(1 << 63)
41
+ _PROTO_INT64_MAX = (1 << 63) - 1
42
+
43
+
44
+ async def _call_sync(fn: Any) -> Any:
45
+ """Adapt a synchronous CoreSession accessor to the owned-task helper."""
46
+ return fn()
47
+
48
+
49
+ def _session_from_app(
50
+ app_state: Any,
51
+ session_id: str | None = None,
52
+ *,
53
+ agent_id: str | None = None,
54
+ agent_ids: list[str] | None = None,
55
+ ) -> Any | None:
56
+ """Resolve a session without silently crossing an explicit boundary.
57
+
58
+ Agent RPCs may omit ``session_id`` for backwards compatibility. In that
59
+ case an agent selector is resolved by ownership; requests that explicitly
60
+ name a session must stay in that session and are never redirected to the
61
+ process default.
62
+ """
63
+ sessions: dict = getattr(app_state, "sessions", {})
64
+ if getattr(app_state, "gateway_closing", False):
65
+ return None
66
+ closing = getattr(app_state, "closing_sessions", set())
67
+ # A missing selector may use the legacy default; an explicitly supplied
68
+ # value is authoritative, including an empty/invalid value.
69
+ # Protobuf scalar strings use ``""`` for an omitted optional selector;
70
+ # normalize that wire representation before applying ownership rules.
71
+ requested = session_id if session_id not in (None, "") else None
72
+ selected = sessions.get(requested) if requested else None
73
+
74
+ ids = [item for item in (agent_ids or []) if item]
75
+ if agent_id:
76
+ ids.append(agent_id)
77
+
78
+ def owns(candidate: Any) -> bool:
79
+ if candidate is None:
80
+ return False
81
+ if getattr(candidate, "session_id", None) in closing:
82
+ return False
83
+ if not ids:
84
+ return True
85
+ try:
86
+ return all(candidate.get_agent(item) is not None for item in ids)
87
+ except Exception:
88
+ return False
89
+
90
+ if requested:
91
+ return selected if owns(selected) else None
92
+
93
+ if ids:
94
+ for candidate in sessions.values():
95
+ if owns(candidate):
96
+ return candidate
97
+ return None
98
+
99
+ default_id = getattr(app_state, "default_session_id", None)
100
+ if default_id and default_id in sessions:
101
+ return sessions[default_id]
102
+ if sessions:
103
+ return next(iter(sessions.values()))
104
+ return None
105
+
106
+
107
+ def _event_to_proto(event: Any) -> dict:
108
+ """Convert a CoreEvent to a gRPC-friendly dict."""
109
+ payload = core_event_to_payload(event)
110
+ return {
111
+ "type": payload.type,
112
+ "payload_json": payload.model_dump_json(),
113
+ }
114
+
115
+
116
+ def _snapshot_to_proto(snapshot: Any) -> dict[str, Any]:
117
+ """Map the complete observable agent lifecycle to the gRPC shape."""
118
+ usage: dict[str, int] = {}
119
+ for key, value in (snapshot.usage or {}).items():
120
+ try:
121
+ numeric = int(value)
122
+ except (TypeError, ValueError, OverflowError):
123
+ # The protobuf contract uses int64 values. A malformed provider
124
+ # usage field should not make an otherwise valid status unreadable.
125
+ logger.debug("Ignoring non-integer usage value for %s: %r", key, value)
126
+ continue
127
+ if not _PROTO_INT64_MIN <= numeric <= _PROTO_INT64_MAX:
128
+ logger.debug("Ignoring out-of-range usage value for %s: %r", key, value)
129
+ continue
130
+ usage[str(key)] = numeric
131
+ payload: dict[str, Any] = {
132
+ "agent_id": snapshot.agent_id,
133
+ "session_id": snapshot.session_id,
134
+ "parent_agent_id": snapshot.parent_agent_id or "",
135
+ "title": snapshot.title,
136
+ "subagent_type": snapshot.subagent_type,
137
+ "status": snapshot.status,
138
+ "model": snapshot.model,
139
+ "created_at": snapshot.created_at,
140
+ "usage": usage,
141
+ "final_result": snapshot.final_result,
142
+ "error": snapshot.error,
143
+ "callback_enabled": snapshot.callback_enabled,
144
+ "callback_state": snapshot.callback_state,
145
+ "callback_epoch": snapshot.callback_epoch,
146
+ }
147
+ if snapshot.finished_at:
148
+ payload["finished_at"] = snapshot.finished_at
149
+ if snapshot.transcript_path:
150
+ payload["transcript_path"] = snapshot.transcript_path
151
+ if snapshot.callback_message_id:
152
+ payload["callback_message_id"] = snapshot.callback_message_id
153
+ return payload
154
+
155
+
156
+ class _CrabCodeServicer:
157
+ """Hand-written servicer that delegates to CoreSession.
158
+
159
+ The generated protobuf service wrapper below adapts wire messages to this
160
+ dictionary-based implementation, keeping the core session API protocol
161
+ agnostic.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ app_state: Any,
167
+ *,
168
+ username: str = "crabcode",
169
+ password: str | None = None,
170
+ password_hash: str | None = None,
171
+ security_mode: str | None = None,
172
+ jwt_secret: str | None = None,
173
+ ) -> None:
174
+ self._app_state = app_state
175
+ self._event_bus = getattr(app_state, "event_bus", None)
176
+ self._auth_username = username
177
+ self._auth_password = password
178
+ self._auth_password_hash = password_hash
179
+ self._security_mode = security_mode or (
180
+ "password" if password or password_hash else "none"
181
+ )
182
+ self._jwt_secret = jwt_secret
183
+
184
+ async def _require_auth(self, context: Any) -> None:
185
+ """Apply the same credentials as HTTP/WS when gRPC is protected."""
186
+ if self._security_mode == "none":
187
+ return
188
+ metadata = {}
189
+ try:
190
+ metadata = {
191
+ str(key).lower(): str(value)
192
+ for key, value in context.invocation_metadata()
193
+ }
194
+ except Exception:
195
+ metadata = {}
196
+ header = metadata.get("authorization", "")
197
+ scheme, separator, credentials = header.partition(" ")
198
+ authorized = False
199
+ if separator and scheme.lower() == "bearer":
200
+ authorized = bool(self._jwt_secret and decode_jwt(credentials, self._jwt_secret))
201
+ if not authorized and self._security_mode in ("password", "mixed"):
202
+ authorized = bool(
203
+ (self._auth_password and hmac.compare_digest(credentials, self._auth_password))
204
+ or (self._auth_password_hash and verify_password(credentials, self._auth_password_hash))
205
+ )
206
+ elif separator and scheme.lower() == "basic" and self._security_mode in ("password", "mixed"):
207
+ try:
208
+ decoded = base64.b64decode(credentials, validate=True).decode("utf-8")
209
+ user, supplied = decoded.split(":", 1)
210
+ authorized = hmac.compare_digest(user, self._auth_username) and bool(
211
+ (self._auth_password and hmac.compare_digest(supplied, self._auth_password))
212
+ or (self._auth_password_hash and verify_password(supplied, self._auth_password_hash))
213
+ )
214
+ except (ValueError, UnicodeDecodeError, binascii.Error):
215
+ authorized = False
216
+ if not authorized:
217
+ await context.abort(
218
+ code=grpc.StatusCode.UNAUTHENTICATED,
219
+ details="Unauthenticated",
220
+ )
221
+
222
+ async def _owned(self, session: Any, operation: Any, context: Any) -> Any:
223
+ """Run a unary RPC under the session task/lifecycle fence."""
224
+ try:
225
+ return await run_session_operation(self._app_state, session, operation)
226
+ except SessionOperationRejected as exc:
227
+ await context.abort(
228
+ code=grpc.StatusCode.UNAVAILABLE,
229
+ details=str(exc),
230
+ )
231
+
232
+ # ── Unary RPCs ──────────────────────────────────────────────
233
+
234
+ async def SpawnAgent(self, request: dict, context: Any) -> dict:
235
+ await self._require_auth(context)
236
+ session = _session_from_app(self._app_state, request.get("session_id"))
237
+ if not session:
238
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
239
+ agent_id = await self._owned(
240
+ session,
241
+ lambda: session.spawn_agent(
242
+ prompt=request.get("prompt", ""),
243
+ subagent_type=request.get("subagent_type", "generalPurpose"),
244
+ name=request.get("name"),
245
+ model_profile=request.get("model_profile"),
246
+ callback=bool(request.get("callback", False)),
247
+ ),
248
+ context,
249
+ )
250
+ return {"agent_id": agent_id}
251
+
252
+ async def GetAgent(self, request: dict, context: Any) -> dict:
253
+ await self._require_auth(context)
254
+ session = _session_from_app(
255
+ self._app_state,
256
+ request.get("session_id"),
257
+ agent_id=request.get("agent_id"),
258
+ )
259
+ if not session:
260
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
261
+ snapshot = await self._owned(
262
+ session,
263
+ lambda: _call_sync(lambda: session.get_agent(request.get("agent_id", ""))),
264
+ context,
265
+ )
266
+ if not snapshot:
267
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="Agent not found")
268
+ return _snapshot_to_proto(snapshot)
269
+
270
+ async def ListAgents(self, request: dict, context: Any) -> dict:
271
+ await self._require_auth(context)
272
+ session = _session_from_app(self._app_state, request.get("session_id"))
273
+ if not session:
274
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
275
+ agents = await self._owned(
276
+ session,
277
+ lambda: _call_sync(lambda: [_snapshot_to_proto(snapshot) for snapshot in session.list_agents()]),
278
+ context,
279
+ )
280
+ return {"agents": agents}
281
+
282
+ async def CancelAgent(self, request: dict, context: Any) -> dict:
283
+ await self._require_auth(context)
284
+ session = _session_from_app(
285
+ self._app_state,
286
+ request.get("session_id"),
287
+ agent_id=request.get("agent_id"),
288
+ )
289
+ if not session:
290
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
291
+ ok = await self._owned(
292
+ session,
293
+ lambda: session.cancel_agent(request.get("agent_id", "")),
294
+ context,
295
+ )
296
+ return {"ok": ok}
297
+
298
+ async def WaitAgent(self, request: dict, context: Any) -> dict:
299
+ await self._require_auth(context)
300
+ agent_ids = [item for item in request.get("agent_ids", []) if item]
301
+ if not agent_ids:
302
+ await context.abort(
303
+ code=grpc.StatusCode.INVALID_ARGUMENT,
304
+ details="At least one agent id is required",
305
+ )
306
+ session = _session_from_app(
307
+ self._app_state,
308
+ request.get("session_id"),
309
+ agent_ids=agent_ids,
310
+ )
311
+ if not session:
312
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
313
+ timeout_ms = request.get("timeout_ms")
314
+ if len(agent_ids) == 1:
315
+ snapshot = await self._owned(
316
+ session,
317
+ lambda: session.wait_agent(agent_ids[0], timeout_ms=timeout_ms),
318
+ context,
319
+ )
320
+ else:
321
+ snapshot = await self._owned(
322
+ session,
323
+ lambda: session.wait_agent(agent_ids, timeout_ms=timeout_ms),
324
+ context,
325
+ )
326
+ if not snapshot:
327
+ await context.abort(code=grpc.StatusCode.DEADLINE_EXCEEDED, details="Wait timed out")
328
+ return _snapshot_to_proto(snapshot)
329
+
330
+ async def RespondPermission(self, request: dict, context: Any) -> dict:
331
+ from crabcode_core.types.event import PermissionResponseEvent
332
+ await self._require_auth(context)
333
+ session = _session_from_app(
334
+ self._app_state,
335
+ request.get("session_id"),
336
+ agent_id=request.get("agent_id"),
337
+ )
338
+ if not session:
339
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
340
+ event = PermissionResponseEvent(
341
+ tool_use_id=request.get("tool_use_id", ""),
342
+ allowed=request.get("allowed", False),
343
+ always_allow=request.get("always_allow", False),
344
+ agent_id=request.get("agent_id"),
345
+ )
346
+ await self._owned(
347
+ session,
348
+ lambda: session.respond_permission(event),
349
+ context,
350
+ )
351
+ return {}
352
+
353
+ async def RespondChoice(self, request: dict, context: Any) -> dict:
354
+ from crabcode_core.types.event import ChoiceResponseEvent
355
+ await self._require_auth(context)
356
+ session = _session_from_app(
357
+ self._app_state,
358
+ request.get("session_id"),
359
+ agent_id=request.get("agent_id"),
360
+ )
361
+ if not session:
362
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
363
+ event = ChoiceResponseEvent(
364
+ tool_use_id=request.get("tool_use_id", ""),
365
+ selected=request.get("selected", []),
366
+ cancelled=request.get("cancelled", False),
367
+ agent_id=request.get("agent_id"),
368
+ )
369
+ await self._owned(
370
+ session,
371
+ lambda: session.respond_choice(event),
372
+ context,
373
+ )
374
+ return {}
375
+
376
+ async def ListModels(self, request: dict, context: Any) -> dict:
377
+ await self._require_auth(context)
378
+ session = _session_from_app(self._app_state, request.get("session_id"))
379
+ if not session:
380
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
381
+ models = await self._owned(
382
+ session,
383
+ lambda: _call_sync(lambda: list(session.list_models().items())),
384
+ context,
385
+ )
386
+ result_models = []
387
+ for name, desc in models:
388
+ result_models.append({"name": name, "description": desc})
389
+ return {"models": result_models}
390
+
391
+ async def SwitchModel(self, request: dict, context: Any) -> dict:
392
+ await self._require_auth(context)
393
+ session = _session_from_app(self._app_state, request.get("session_id"))
394
+ if not session:
395
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
396
+ ok = await self._owned(
397
+ session,
398
+ lambda: _call_sync(lambda: session.switch_model(request.get("name", ""))),
399
+ context,
400
+ )
401
+ if not ok:
402
+ await context.abort(code=grpc.StatusCode.INVALID_ARGUMENT, details="Model not found")
403
+ return {}
404
+
405
+ async def SwitchMode(self, request: dict, context: Any) -> dict:
406
+ await self._require_auth(context)
407
+ session = _session_from_app(self._app_state, request.get("session_id"))
408
+ if not session:
409
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
410
+ ok = await self._owned(
411
+ session,
412
+ lambda: _call_sync(lambda: session.switch_mode(request.get("mode", "agent"))),
413
+ context,
414
+ )
415
+ if not ok:
416
+ await context.abort(code=grpc.StatusCode.INVALID_ARGUMENT, details="Invalid mode")
417
+ return {}
418
+
419
+ async def HealthCheck(self, request: dict, context: Any) -> dict:
420
+ # Keep health probes public, matching the HTTP /health endpoint.
421
+ return {"status": "ok", "version": VERSION}
422
+
423
+ # ── Server-streaming RPCs ────────────────────────────────────
424
+
425
+ async def SendMessage(self, request: dict, context: Any) -> Any:
426
+ """Stream CoreEvents as the query loop runs."""
427
+ await self._require_auth(context)
428
+ from crabcode_gateway.session_registry import get_session_lock
429
+
430
+ async with get_session_lock(self._app_state):
431
+ session = _session_from_app(self._app_state, request.get("session_id"))
432
+ if not session:
433
+ await context.abort(code=grpc.StatusCode.NOT_FOUND, details="No active session")
434
+
435
+ text = request.get("text", "")
436
+ max_turns = request.get("max_turns", 0)
437
+
438
+ try:
439
+ async with session_lease(self._app_state, session):
440
+ async for event in session.send_message(text, max_turns=max_turns):
441
+ yield _event_to_proto(event)
442
+ except SessionOperationRejected as exc:
443
+ await context.abort(code=grpc.StatusCode.UNAVAILABLE, details=str(exc))
444
+
445
+ async def SubscribeEvents(self, request: dict, context: Any) -> Any:
446
+ """Subscribe to the event bus SSE stream via gRPC."""
447
+ await self._require_auth(context)
448
+ if not self._event_bus:
449
+ await context.abort(code=grpc.StatusCode.INTERNAL, details="Event bus not available")
450
+ return
451
+
452
+ session_id = request.get("session_id") or None
453
+ from crabcode_gateway.session_registry import get_session_lock
454
+
455
+ subscriber = None
456
+ async with get_session_lock(self._app_state):
457
+ closing = getattr(self._app_state, "gateway_closing", False)
458
+ selected = (
459
+ _session_from_app(self._app_state, session_id)
460
+ if session_id is not None
461
+ else None
462
+ )
463
+ known = session_id is None or selected is not None
464
+ session_closing = bool(
465
+ session_id is not None
466
+ and session_id in getattr(self._app_state, "closing_sessions", set())
467
+ )
468
+ if not closing and known and not session_closing:
469
+ # Async generators are lazy: constructing ``sse_stream`` alone
470
+ # does not register its subscriber until iteration begins.
471
+ # Create it under the same lock used by gateway stop so
472
+ # close_all() cannot run before this queue becomes visible.
473
+ subscriber = self._event_bus.subscribe(session_id)
474
+ if closing:
475
+ await context.abort(
476
+ code=grpc.StatusCode.UNAVAILABLE,
477
+ details="Gateway is shutting down",
478
+ )
479
+ if not known:
480
+ await context.abort(
481
+ code=grpc.StatusCode.NOT_FOUND,
482
+ details="Session not found",
483
+ )
484
+ if session_closing:
485
+ await context.abort(
486
+ code=grpc.StatusCode.UNAVAILABLE,
487
+ details="Session is shutting down",
488
+ )
489
+ assert subscriber is not None
490
+ try:
491
+ if session_id is None:
492
+ # A global subscription has no session lease; it is bounded by
493
+ # the transport itself and is not tied to one CoreSession.
494
+ stream = self._event_bus.sse_stream(
495
+ session_id,
496
+ subscriber=subscriber,
497
+ )
498
+ async for data in stream:
499
+ try:
500
+ parsed = json.loads(data)
501
+ yield {"type": parsed.get("type", "unknown"), "payload_json": data}
502
+ except json.JSONDecodeError:
503
+ yield {"type": "unknown", "payload_json": data}
504
+ else:
505
+ async with session_lease(self._app_state, selected):
506
+ async for data in self._event_bus.sse_stream(
507
+ session_id,
508
+ subscriber=subscriber,
509
+ ):
510
+ try:
511
+ parsed = json.loads(data)
512
+ yield {"type": parsed.get("type", "unknown"), "payload_json": data}
513
+ except json.JSONDecodeError:
514
+ yield {"type": "unknown", "payload_json": data}
515
+ except SessionOperationRejected as exc:
516
+ await context.abort(code=grpc.StatusCode.UNAVAILABLE, details=str(exc))
517
+ finally:
518
+ # ``sse_stream`` normally removes it in its own finally block;
519
+ # this idempotent call also covers rejection before iteration.
520
+ self._event_bus.unsubscribe(subscriber)
521
+
522
+
523
+ class GrpcAdapter(ProtocolAdapter):
524
+ """gRPC protocol adapter.
525
+
526
+ Starts a grpcio async server on the configured port.
527
+ """
528
+
529
+ def __init__(
530
+ self,
531
+ app_state: Any,
532
+ password: str | None = None,
533
+ password_hash: str | None = None,
534
+ security_mode: str | None = None,
535
+ jwt_secret: str | None = None,
536
+ ) -> None:
537
+ self._app_state = app_state
538
+ self._password = password
539
+ self._password_hash = password_hash
540
+ self._security_mode = security_mode or (
541
+ "password" if password or password_hash else "none"
542
+ )
543
+ self._jwt_secret = jwt_secret
544
+ self._server: Any = None
545
+ self._running = False
546
+ self._bound_port: int | None = None
547
+
548
+ @property
549
+ def is_running(self) -> bool:
550
+ return self._running
551
+
552
+ @property
553
+ def bound_port(self) -> int | None:
554
+ """Return the port selected by gRPC (useful when binding port 0)."""
555
+ return self._bound_port
556
+
557
+ async def start(self, host: str, port: int) -> None:
558
+ """Start the gRPC server using the bundled generated protobuf stubs."""
559
+ if self._running or self._server is not None:
560
+ raise RuntimeError("gRPC server is already started")
561
+ try:
562
+ from grpc import aio as grpc_aio
563
+ except ImportError:
564
+ logger.warning("grpcio not installed, skipping gRPC server")
565
+ return
566
+
567
+ servicer = _CrabCodeServicer(
568
+ self._app_state,
569
+ username=getattr(self._app_state, "gateway_username", "crabcode"),
570
+ password=(
571
+ self._password
572
+ if self._password is not None
573
+ else getattr(self._app_state, "gateway_password", None)
574
+ ),
575
+ password_hash=self._password_hash,
576
+ security_mode=self._security_mode,
577
+ jwt_secret=self._jwt_secret,
578
+ )
579
+
580
+ self._server = grpc_aio.server(futures.ThreadPoolExecutor(max_workers=4))
581
+
582
+ # Register the generated stubs shipped with this package.
583
+ try:
584
+ from crabcode_gateway.grpc import crabcode_pb2_grpc # noqa: F401
585
+
586
+ crabcode_pb2_grpc.add_CrabCodeServiceServicer_to_server(
587
+ _GeneratedStubServicer(servicer), self._server
588
+ )
589
+ logger.info("Using generated gRPC stubs")
590
+ except ImportError as exc:
591
+ # Stubs are shipped in this package. Keep the error explicit if a
592
+ # broken/partial installation omits them instead of silently
593
+ # reporting a running-but-unusable gRPC endpoint.
594
+ await self._cleanup_failed_start()
595
+ raise RuntimeError(
596
+ "Bundled gRPC protobuf stubs are unavailable; reinstall crabcode-gateway"
597
+ ) from exc
598
+
599
+ try:
600
+ from grpc_health.v1 import health, health_pb2, health_pb2_grpc
601
+ except ImportError as exc:
602
+ await self._cleanup_failed_start()
603
+ raise RuntimeError(
604
+ "grpcio-health-checking is required for the gRPC gateway"
605
+ ) from exc
606
+
607
+ health_servicer = health.HealthServicer()
608
+ health_pb2_grpc.add_HealthServicer_to_server(health_servicer, self._server)
609
+ health_servicer.set(
610
+ "", health_pb2.HealthCheckResponse.SERVING
611
+ )
612
+
613
+ try:
614
+ self._bound_port = self._server.add_insecure_port(f"{host}:{port}")
615
+ except Exception:
616
+ await self._cleanup_failed_start()
617
+ raise
618
+ if not self._bound_port:
619
+ await self._cleanup_failed_start()
620
+ raise OSError(f"Unable to bind gRPC listener on {host}:{port}")
621
+ try:
622
+ await self._server.start()
623
+ except Exception:
624
+ await self._cleanup_failed_start()
625
+ raise
626
+ self._running = True
627
+ logger.info("gRPC server listening on %s:%d", host, self._bound_port)
628
+
629
+ async def _cleanup_failed_start(self) -> None:
630
+ server = self._server
631
+ self._server = None
632
+ self._bound_port = None
633
+ self._running = False
634
+ if server is not None:
635
+ await server.stop(grace=0)
636
+
637
+ async def stop(self) -> None:
638
+ server = self._server
639
+ self._server = None
640
+ self._running = False
641
+ self._bound_port = None
642
+ if server:
643
+ await server.stop(grace=5)
644
+ logger.info("gRPC server stopped")
645
+
646
+
647
+ class _GeneratedStubServicer:
648
+ """Wrapper that delegates to _CrabCodeServicer using generated stub interfaces."""
649
+
650
+ def __init__(self, servicer: _CrabCodeServicer) -> None:
651
+ self._servicer = servicer
652
+
653
+ async def SendMessage(self, request, context):
654
+ req_dict = {
655
+ "text": request.text,
656
+ "max_turns": request.max_turns,
657
+ "session_id": request.session_id,
658
+ }
659
+ async for event in self._servicer.SendMessage(req_dict, context):
660
+ yield self._proto_event(event)
661
+
662
+ async def SubscribeEvents(self, request, context):
663
+ req_dict = {"session_id": request.session_id}
664
+ async for event in self._servicer.SubscribeEvents(req_dict, context):
665
+ yield self._proto_event(event)
666
+
667
+ async def SpawnAgent(self, request, context):
668
+ req_dict = {
669
+ "prompt": request.prompt,
670
+ "subagent_type": request.subagent_type or "generalPurpose",
671
+ "callback": request.callback,
672
+ "session_id": request.session_id,
673
+ }
674
+ if request.HasField("name"):
675
+ req_dict["name"] = request.name
676
+ if request.HasField("model_profile"):
677
+ req_dict["model_profile"] = request.model_profile
678
+ result = await self._servicer.SpawnAgent(req_dict, context)
679
+ from crabcode_gateway.grpc import crabcode_pb2 as _pb2
680
+ return _pb2.SpawnAgentResponse(**result)
681
+
682
+ async def GetAgent(self, request, context):
683
+ result = await self._servicer.GetAgent({
684
+ "agent_id": request.agent_id,
685
+ "session_id": request.session_id,
686
+ }, context)
687
+ from crabcode_gateway.grpc import crabcode_pb2
688
+ return crabcode_pb2.AgentSnapshotProto(**result)
689
+
690
+ async def ListAgents(self, request, context):
691
+ result = await self._servicer.ListAgents({"session_id": request.session_id}, context)
692
+ from crabcode_gateway.grpc import crabcode_pb2
693
+ return crabcode_pb2.ListAgentsResponse(**result)
694
+
695
+ async def CancelAgent(self, request, context):
696
+ result = await self._servicer.CancelAgent({
697
+ "agent_id": request.agent_id,
698
+ "session_id": request.session_id,
699
+ }, context)
700
+ from crabcode_gateway.grpc import crabcode_pb2
701
+ return crabcode_pb2.CancelAgentResponse(**result)
702
+
703
+ async def WaitAgent(self, request, context):
704
+ req_dict = {
705
+ "agent_ids": list(request.agent_ids),
706
+ "session_id": request.session_id,
707
+ }
708
+ if request.HasField("timeout_ms"):
709
+ req_dict["timeout_ms"] = request.timeout_ms
710
+ result = await self._servicer.WaitAgent(req_dict, context)
711
+ from crabcode_gateway.grpc import crabcode_pb2
712
+ return crabcode_pb2.AgentSnapshotProto(**result)
713
+
714
+ async def RespondPermission(self, request, context):
715
+ await self._servicer.RespondPermission({
716
+ "tool_use_id": request.tool_use_id,
717
+ "allowed": request.allowed,
718
+ "always_allow": request.always_allow,
719
+ "agent_id": request.agent_id if request.HasField("agent_id") else None,
720
+ "session_id": request.session_id,
721
+ }, context)
722
+ from crabcode_gateway.grpc import crabcode_pb2
723
+ return crabcode_pb2.Empty()
724
+
725
+ async def RespondChoice(self, request, context):
726
+ await self._servicer.RespondChoice({
727
+ "tool_use_id": request.tool_use_id,
728
+ "selected": list(request.selected),
729
+ "cancelled": request.cancelled,
730
+ "agent_id": request.agent_id if request.HasField("agent_id") else None,
731
+ "session_id": request.session_id,
732
+ }, context)
733
+ from crabcode_gateway.grpc import crabcode_pb2
734
+ return crabcode_pb2.Empty()
735
+
736
+ async def ListModels(self, request, context):
737
+ result = await self._servicer.ListModels({"session_id": request.session_id}, context)
738
+ from crabcode_gateway.grpc import crabcode_pb2
739
+ return crabcode_pb2.ListModelsResponse(**result)
740
+
741
+ async def SwitchModel(self, request, context):
742
+ await self._servicer.SwitchModel({
743
+ "name": request.name,
744
+ "session_id": request.session_id,
745
+ }, context)
746
+ from crabcode_gateway.grpc import crabcode_pb2
747
+ return crabcode_pb2.Empty()
748
+
749
+ async def SwitchMode(self, request, context):
750
+ await self._servicer.SwitchMode({
751
+ "mode": request.mode,
752
+ "session_id": request.session_id,
753
+ }, context)
754
+ from crabcode_gateway.grpc import crabcode_pb2
755
+ return crabcode_pb2.Empty()
756
+
757
+ async def HealthCheck(self, request, context):
758
+ result = await self._servicer.HealthCheck({}, context)
759
+ from crabcode_gateway.grpc import crabcode_pb2
760
+ return crabcode_pb2.HealthCheckResponse(**result)
761
+
762
+ @staticmethod
763
+ def _proto_event(event_dict: dict):
764
+ from crabcode_gateway.grpc import crabcode_pb2
765
+ return crabcode_pb2.CoreEventProto(**event_dict)