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,1496 @@
1
+ """Session management routes — /session/*."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from copy import deepcopy
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+
14
+ from crabcode_gateway.schemas import (
15
+ ArchiveSessionRequest,
16
+ ClearSessionRequest,
17
+ CompactRequest,
18
+ ExportSessionRequest,
19
+ ForkSessionRequest,
20
+ InterruptRequest,
21
+ NewSessionRequest,
22
+ PruneSessionsRequest,
23
+ ResumeSessionRequest,
24
+ SearchSessionsRequest,
25
+ SendMessageRequest,
26
+ SessionInfo,
27
+ SessionRuntimeStatus,
28
+ SearchIndexStatus,
29
+ )
30
+ from crabcode_gateway.event_bus import EventBus
31
+ from crabcode_gateway.turn_details import with_turn_details
32
+ from crabcode_gateway.session_registry import get_session_load_lock, get_session_lock
33
+ from crabcode_gateway.task_registry import (
34
+ cancel_operation_task,
35
+ mark_session_closing,
36
+ operation_is_registered,
37
+ release_operation_claim,
38
+ run_session_operation,
39
+ SessionOperationRejected,
40
+ shielded_cleanup_session,
41
+ track_task,
42
+ unmark_session_closing,
43
+ )
44
+
45
+ router = APIRouter(prefix="/session", tags=["session"])
46
+
47
+ _SESSION_OVERRIDE_FIELDS = (
48
+ "model",
49
+ "provider",
50
+ "base_url",
51
+ "api_format",
52
+ "model_profile",
53
+ )
54
+
55
+
56
+ def _session_time(value: Any) -> str:
57
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
58
+ try:
59
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
60
+ except (OverflowError, OSError, ValueError):
61
+ return str(value)
62
+ return str(value or "")
63
+
64
+
65
+ def _session_info_from_row(row: dict[str, Any]) -> SessionInfo:
66
+ session_id = str(row.get("session_id") or row.get("id") or "")
67
+ return SessionInfo(
68
+ session_id=session_id,
69
+ message_count=int(row.get("message_count") or 0),
70
+ model=str(row.get("model") or ""),
71
+ provider=str(row.get("provider") or ""),
72
+ created_at=_session_time(
73
+ row.get("modified") or row.get("updated_at") or row.get("created_at")
74
+ ),
75
+ title=str(row.get("title") or ""),
76
+ cwd=str(row.get("cwd") or ""),
77
+ tokens_used=int(row.get("tokens_used") or 0),
78
+ preview=str(row.get("preview") or row.get("first_user_message") or ""),
79
+ forked_from_session_id=(
80
+ str(row["forked_from_session_id"])
81
+ if row.get("forked_from_session_id") else None
82
+ ),
83
+ forked_from_message_uuid=(
84
+ str(row["forked_from_message_uuid"])
85
+ if row.get("forked_from_message_uuid") else None
86
+ ),
87
+ forked_from_title=(
88
+ str(row["forked_from_title"])
89
+ if row.get("forked_from_title") else None
90
+ ),
91
+ )
92
+
93
+
94
+ def _validate_session_id(session_id: str) -> None:
95
+ """Reject identifiers that cannot safely address a session transcript."""
96
+ from crabcode_core.session.storage import get_transcript_path
97
+
98
+ try:
99
+ # Path construction performs the storage-boundary validation without
100
+ # touching disk. Keep malformed client input a 400 instead of letting
101
+ # it escape from CoreSession as an internal server error.
102
+ get_transcript_path(os.getcwd(), session_id)
103
+ except (TypeError, ValueError) as exc:
104
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
105
+
106
+
107
+ def _get_session(request: Request, session_id: str | None = None):
108
+ """Retrieve a CoreSession from app state."""
109
+ sessions: dict = request.app.state.sessions
110
+ # ``None`` means the selector was omitted. An explicitly supplied empty
111
+ # or malformed id must never silently fall back to another conversation.
112
+ sid = (
113
+ request.app.state.default_session_id
114
+ if session_id is None
115
+ else session_id
116
+ )
117
+ if not sid or sid not in sessions:
118
+ return None
119
+ if sid in getattr(request.app.state, "closing_sessions", set()):
120
+ return None
121
+ return sessions[sid]
122
+
123
+
124
+ def _resolve_session_selector(
125
+ selector: str,
126
+ cwd: str,
127
+ *,
128
+ loaded_sessions: dict[str, Any] | None = None,
129
+ ) -> tuple[str, str] | None:
130
+ """Resolve exact IDs, unique prefixes, or a local one-based index."""
131
+ from crabcode_core.session.meta_db import SessionMetaStore
132
+ from crabcode_core.session.storage import SessionStorage
133
+
134
+ value = str(selector or "").strip()
135
+ if not value:
136
+ return None
137
+
138
+ try:
139
+ local_rows = SessionStorage.list_sessions(cwd)
140
+ except Exception:
141
+ local_rows = []
142
+
143
+ local_ids = [
144
+ str(row.get("session_id") or "")
145
+ for row in local_rows
146
+ if row.get("session_id")
147
+ ]
148
+ if value in local_ids:
149
+ return value, cwd
150
+ local_matches = [session_id for session_id in local_ids if session_id.startswith(value)]
151
+ if len(local_matches) == 1:
152
+ return local_matches[0], cwd
153
+ if len(local_matches) > 1:
154
+ return None
155
+ try:
156
+ index = int(value) - 1
157
+ except ValueError:
158
+ index = -1
159
+ if 0 <= index < len(local_ids):
160
+ return local_ids[index], cwd
161
+
162
+ loaded = loaded_sessions or {}
163
+ if value in loaded:
164
+ session = loaded[value]
165
+ return value, str(getattr(session, "cwd", cwd))
166
+ loaded_matches = [session_id for session_id in loaded if session_id.startswith(value)]
167
+ if len(loaded_matches) == 1:
168
+ session_id = loaded_matches[0]
169
+ return session_id, str(getattr(loaded[session_id], "cwd", cwd))
170
+ if len(loaded_matches) > 1:
171
+ return None
172
+
173
+ store = SessionMetaStore()
174
+ try:
175
+ exact = store.get(value)
176
+ if exact and not exact.get("is_archived"):
177
+ return str(exact["id"]), str(exact.get("cwd") or cwd)
178
+ matches = store.find_active_by_prefix(value, limit=2)
179
+ finally:
180
+ store.close()
181
+ if len(matches) != 1:
182
+ return None
183
+ return str(matches[0]["id"]), str(matches[0].get("cwd") or cwd)
184
+
185
+
186
+ def _attach_event_bus(session, event_bus: EventBus) -> None:
187
+ async def _publish(event) -> None:
188
+ await event_bus.publish_background(
189
+ session.session_id,
190
+ event,
191
+ source=session,
192
+ )
193
+
194
+ session.set_background_event_sink(_publish)
195
+
196
+
197
+ def _has_session_overrides(req: NewSessionRequest | ResumeSessionRequest) -> bool:
198
+ return any(getattr(req, field) is not None for field in _SESSION_OVERRIDE_FIELDS)
199
+
200
+
201
+ def _build_session_settings(
202
+ req: NewSessionRequest | ResumeSessionRequest,
203
+ cwd: str,
204
+ ):
205
+ """Build caller overrides without shadowing the target project's config."""
206
+ from crabcode_core.config.manager import ConfigManager
207
+ from crabcode_core.types.config import CrabCodeSettings
208
+
209
+ explicit = (
210
+ CrabCodeSettings(
211
+ permissions={
212
+ "additional_directories": list(req.additional_directories),
213
+ },
214
+ )
215
+ if req.additional_directories
216
+ else CrabCodeSettings()
217
+ )
218
+ if req.model is not None:
219
+ explicit.api.model = req.model
220
+ if req.provider is not None:
221
+ explicit.api.provider = req.provider
222
+ if req.base_url is not None:
223
+ explicit.api.base_url = req.base_url
224
+ if req.api_format is not None:
225
+ explicit.api.format = req.api_format
226
+ if req.model_profile is not None:
227
+ configured = ConfigManager(cwd=cwd).load().models
228
+ if req.model_profile not in configured:
229
+ available = ", ".join(sorted(configured)) or "none"
230
+ raise HTTPException(
231
+ status_code=400,
232
+ detail=(
233
+ f"Unknown model profile '{req.model_profile}'. "
234
+ f"Available profiles: {available}"
235
+ ),
236
+ )
237
+ explicit.default_model = req.model_profile
238
+
239
+ settings = CrabCodeSettings()
240
+ settings._crabcode_explicit_settings = explicit
241
+ return settings
242
+
243
+
244
+ def _session_model_provider(session) -> tuple[str, str]:
245
+ settings = getattr(session, "settings", None)
246
+ if settings is None:
247
+ return "", ""
248
+ api = settings.get_api_config() if hasattr(settings, "get_api_config") else None
249
+ return str(getattr(api, "model", None) or ""), str(
250
+ getattr(api, "provider", None) or ""
251
+ )
252
+
253
+
254
+ def _bind_computer_use(
255
+ session: Any,
256
+ state: Any,
257
+ host_id: str | None,
258
+ enabled: bool | None = None,
259
+ mode: str | None = None,
260
+ target_scope: str | None = None,
261
+ delivery_policy: str | None = None,
262
+ ) -> None:
263
+ """Bind a session to a Desktop host before tools are initialized."""
264
+ if enabled is not None:
265
+ session.computer_use_enabled = enabled
266
+ session._computer_use_requested_enabled = enabled
267
+ if target_scope is not None:
268
+ mode = "foreground_desktop" if target_scope == "desktop" else "background_app"
269
+ if mode is not None:
270
+ session._computer_use_mode_override = mode
271
+ session.computer_use_mode = mode
272
+ session.computer_use_target_scope = "desktop" if mode == "foreground_desktop" else "app_window"
273
+ if delivery_policy is not None:
274
+ session._computer_use_delivery_policy_override = delivery_policy
275
+ session.computer_use_delivery_policy = delivery_policy
276
+ if host_id is None:
277
+ return
278
+ session.computer_use_backend = state.computer_use_broker
279
+ session.computer_use_host_id = host_id
280
+
281
+
282
+ def _apply_new_session_controls(session: Any, req: NewSessionRequest) -> None:
283
+ """Apply client composer controls before the new session is announced."""
284
+ if req.reasoning_effort is not None:
285
+ session.set_reasoning_effort(req.reasoning_effort)
286
+ if req.ultra_mode is not None:
287
+ session.set_ultra_mode(req.ultra_mode)
288
+ if req.permission_mode is not None:
289
+ session.set_client_permission_mode(req.permission_mode)
290
+ if req.mode is not None:
291
+ session.switch_mode(req.mode)
292
+
293
+
294
+ @router.post("/new", response_model=SessionInfo)
295
+ async def new_session(req: NewSessionRequest, request: Request) -> SessionInfo:
296
+ """Create a new CrabCode session."""
297
+ import os
298
+ from crabcode_core.session import CoreSession
299
+
300
+ if getattr(request.app.state, "gateway_closing", False):
301
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
302
+
303
+ cwd = req.cwd or os.getcwd()
304
+
305
+ settings = _build_session_settings(req, cwd)
306
+ session = None
307
+ registered_session_id: str | None = None
308
+ cleanup_owns_registry = False
309
+ try:
310
+ session = CoreSession(cwd=cwd, settings=settings)
311
+ _bind_computer_use(
312
+ session,
313
+ request.app.state,
314
+ req.computer_use_host_id,
315
+ req.computer_use_enabled,
316
+ req.computer_use_mode,
317
+ target_scope=req.computer_use_target_scope,
318
+ delivery_policy=req.computer_use_delivery_policy,
319
+ )
320
+ _attach_event_bus(session, request.app.state.event_bus)
321
+ await session.initialize()
322
+ session.new_session()
323
+ _apply_new_session_controls(session, req)
324
+
325
+ async with get_session_lock(request.app.state):
326
+ if getattr(request.app.state, "gateway_closing", False):
327
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
328
+ sessions: dict = request.app.state.sessions
329
+ session.reload_computer_use_settings()
330
+ request.app.state.event_bus.register_session(session.session_id, session)
331
+ sessions[session.session_id] = session
332
+ request.app.state.default_session_id = session.session_id
333
+ registered_session_id = session.session_id
334
+ except BaseException:
335
+ if registered_session_id is not None:
336
+ async with get_session_lock(request.app.state):
337
+ sessions = request.app.state.sessions
338
+ if sessions.get(registered_session_id) is session:
339
+ cleanup_owns_registry = True
340
+ mark_session_closing(request.app.state, registered_session_id)
341
+ close_session_events = getattr(request.app.state.event_bus, "close_session", None)
342
+ if callable(close_session_events):
343
+ close_session_events(registered_session_id, session)
344
+ sessions.pop(registered_session_id, None)
345
+ if request.app.state.default_session_id == registered_session_id:
346
+ request.app.state.default_session_id = next(iter(sessions), None)
347
+ if session is not None:
348
+ try:
349
+ await shielded_cleanup_session(
350
+ request.app.state,
351
+ getattr(session, "session_id", "") or f"unregistered:{id(session)}",
352
+ session,
353
+ owns_registry=cleanup_owns_registry,
354
+ )
355
+ except Exception:
356
+ pass
357
+ raise
358
+
359
+ model, provider = _session_model_provider(session)
360
+ meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
361
+ return SessionInfo(
362
+ session_id=session.session_id,
363
+ message_count=0,
364
+ model=model,
365
+ provider=provider,
366
+ title=str(meta.get("title") or ""),
367
+ cwd=str(getattr(session, "cwd", "") or ""),
368
+ forked_from_session_id=meta.get("forked_from_session_id"),
369
+ forked_from_message_uuid=meta.get("forked_from_message_uuid"),
370
+ forked_from_title=meta.get("forked_from_title"),
371
+ )
372
+
373
+
374
+ @router.post("/resume", response_model=SessionInfo)
375
+ async def resume_session(req: ResumeSessionRequest, request: Request) -> SessionInfo:
376
+ """Resume a session selected by ID, unique prefix, or local index."""
377
+ import os
378
+ from crabcode_core.session import CoreSession
379
+
380
+ if not req.session_id:
381
+ raise HTTPException(status_code=404, detail="Session not found")
382
+ if getattr(request.app.state, "gateway_closing", False):
383
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
384
+
385
+ async with get_session_lock(request.app.state):
386
+ loaded = dict(request.app.state.sessions)
387
+ default_id = request.app.state.default_session_id
388
+ selector_cwd = os.getcwd()
389
+ if default_id and default_id in loaded:
390
+ selector_cwd = str(getattr(loaded[default_id], "cwd", selector_cwd))
391
+ try:
392
+ resolved = _resolve_session_selector(
393
+ req.session_id,
394
+ selector_cwd,
395
+ loaded_sessions=loaded,
396
+ )
397
+ except Exception as exc:
398
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
399
+ if resolved is None:
400
+ raise HTTPException(
401
+ status_code=404,
402
+ detail="Session not found or selector is ambiguous",
403
+ )
404
+ session_id, resolved_cwd = resolved
405
+ _validate_session_id(session_id)
406
+
407
+ # Fast path for an already loaded session. The registry lock is held only
408
+ # for in-memory state; provider/LSP initialization happens below without
409
+ # blocking archive/send/stop.
410
+ async with get_session_lock(request.app.state):
411
+ sessions: dict = request.app.state.sessions
412
+ if getattr(request.app.state, "gateway_closing", False):
413
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
414
+ if session_id in getattr(request.app.state, "closing_sessions", set()):
415
+ raise HTTPException(status_code=503, detail="Session is shutting down")
416
+ session = sessions.get(session_id)
417
+ if session is not None:
418
+ if _has_session_overrides(req):
419
+ raise HTTPException(
420
+ status_code=409,
421
+ detail="Cannot apply model overrides to an already loaded session",
422
+ )
423
+ request.app.state.default_session_id = session.session_id
424
+ _bind_computer_use(
425
+ session,
426
+ request.app.state,
427
+ req.computer_use_host_id,
428
+ req.computer_use_enabled,
429
+ req.computer_use_mode,
430
+ target_scope=req.computer_use_target_scope,
431
+ delivery_policy=req.computer_use_delivery_policy,
432
+ )
433
+
434
+ if session is None:
435
+ # Serialize disk-backed loads so concurrent resume calls cannot create
436
+ # duplicate CoreSession instances for the same id. This lock is
437
+ # deliberately distinct from the short registry lock above.
438
+ async with get_session_load_lock(request.app.state):
439
+ async with get_session_lock(request.app.state):
440
+ sessions = request.app.state.sessions
441
+ if getattr(request.app.state, "gateway_closing", False):
442
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
443
+ if session_id in getattr(request.app.state, "closing_sessions", set()):
444
+ raise HTTPException(status_code=503, detail="Session is shutting down")
445
+ session = sessions.get(session_id)
446
+ if session is not None and _has_session_overrides(req):
447
+ raise HTTPException(
448
+ status_code=409,
449
+ detail="Cannot apply model overrides to an already loaded session",
450
+ )
451
+
452
+ if session is None:
453
+ candidate = CoreSession(
454
+ cwd=resolved_cwd,
455
+ settings=_build_session_settings(req, resolved_cwd),
456
+ )
457
+ _bind_computer_use(
458
+ candidate,
459
+ request.app.state,
460
+ req.computer_use_host_id,
461
+ req.computer_use_enabled,
462
+ req.computer_use_mode,
463
+ target_scope=req.computer_use_target_scope,
464
+ delivery_policy=req.computer_use_delivery_policy,
465
+ )
466
+ _attach_event_bus(candidate, request.app.state.event_bus)
467
+ try:
468
+ await candidate.initialize()
469
+ ok = await candidate.resume(session_id)
470
+ except BaseException:
471
+ try:
472
+ await shielded_cleanup_session(
473
+ request.app.state,
474
+ session_id,
475
+ candidate,
476
+ owns_registry=False,
477
+ )
478
+ except Exception:
479
+ pass
480
+ raise
481
+ if not ok:
482
+ await shielded_cleanup_session(
483
+ request.app.state,
484
+ session_id,
485
+ candidate,
486
+ owns_registry=False,
487
+ )
488
+ raise HTTPException(
489
+ status_code=404,
490
+ detail=f"Session {session_id} not found",
491
+ )
492
+
493
+ # Reacquire the registry lock only for the atomic install. A
494
+ # concurrent archive/stop may have fenced the id while the
495
+ # candidate was loading; in that case close it and reject.
496
+ discard_candidate = False
497
+ reject_candidate = False
498
+ async with get_session_lock(request.app.state):
499
+ if (
500
+ getattr(request.app.state, "gateway_closing", False)
501
+ or session_id in getattr(request.app.state, "closing_sessions", set())
502
+ ):
503
+ discard_candidate = True
504
+ reject_candidate = True
505
+ else:
506
+ existing = request.app.state.sessions.get(session_id)
507
+ if existing is None:
508
+ candidate.reload_computer_use_settings()
509
+ request.app.state.event_bus.register_session(
510
+ candidate.session_id,
511
+ candidate,
512
+ )
513
+ request.app.state.sessions[candidate.session_id] = candidate
514
+ session = candidate
515
+ else:
516
+ session = existing
517
+ # Another resume/new request won the install race;
518
+ # this fully initialized candidate is no longer
519
+ # owned by the registry and must be closed without
520
+ # fencing the winner's tasks.
521
+ discard_candidate = True
522
+ request.app.state.default_session_id = session.session_id
523
+ if discard_candidate:
524
+ try:
525
+ await shielded_cleanup_session(
526
+ request.app.state,
527
+ session_id,
528
+ candidate,
529
+ owns_registry=False,
530
+ )
531
+ except Exception:
532
+ pass
533
+ if reject_candidate:
534
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
535
+
536
+ else:
537
+ async with get_session_lock(request.app.state):
538
+ request.app.state.default_session_id = session.session_id
539
+
540
+ if session is not None:
541
+ _bind_computer_use(
542
+ session,
543
+ request.app.state,
544
+ req.computer_use_host_id,
545
+ req.computer_use_enabled,
546
+ req.computer_use_mode,
547
+ target_scope=req.computer_use_target_scope,
548
+ delivery_policy=req.computer_use_delivery_policy,
549
+ )
550
+
551
+ # Capture the primitive count while the installed object is still fenced
552
+ # by the registry. Returning ``len(session.messages)`` after releasing the
553
+ # lock could observe a replacement/close or a concurrently appended turn.
554
+ async with get_session_lock(request.app.state):
555
+ if (
556
+ request.app.state.sessions.get(session.session_id) is not session
557
+ or session.session_id in getattr(request.app.state, "closing_sessions", set())
558
+ ):
559
+ raise HTTPException(status_code=503, detail="Session is changing")
560
+ message_count = len(getattr(session, "messages", ()))
561
+
562
+ model, provider = _session_model_provider(session)
563
+ meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
564
+ return SessionInfo(
565
+ session_id=session.session_id,
566
+ message_count=message_count,
567
+ model=model,
568
+ provider=provider,
569
+ title=str(meta.get("title") or ""),
570
+ cwd=str(getattr(session, "cwd", "") or ""),
571
+ forked_from_session_id=meta.get("forked_from_session_id"),
572
+ forked_from_message_uuid=meta.get("forked_from_message_uuid"),
573
+ forked_from_title=meta.get("forked_from_title"),
574
+ )
575
+
576
+
577
+ @router.get("/messages")
578
+ async def session_messages(
579
+ request: Request,
580
+ session_id: str | None = None,
581
+ ) -> list[dict[str, Any]]:
582
+ """Return the active message projection for one session.
583
+
584
+ The selector is intentionally session-scoped: an omitted selector uses the
585
+ legacy default session, while an explicit unknown or empty selector is a
586
+ 404 and never falls through to another conversation. Copy the list while
587
+ holding the registry lock so archive/resume cannot replace it halfway
588
+ through serialization.
589
+ """
590
+ async with get_session_lock(request.app.state):
591
+ session = _get_session(request, session_id)
592
+ if session is None:
593
+ raise HTTPException(status_code=404, detail="Session not found")
594
+ # Copy message objects, not only the list container. A query loop can
595
+ # update an assistant message in place while it streams; serializing
596
+ # the original objects after releasing the registry lock could return
597
+ # a mixed/partially-mutated response.
598
+ messages = []
599
+ for message in getattr(session, "messages", ()):
600
+ try:
601
+ copier = getattr(message, "model_copy", None)
602
+ messages.append(
603
+ copier(deep=True) if callable(copier) else deepcopy(message)
604
+ )
605
+ except Exception:
606
+ # Lightweight integrations may expose non-copyable message
607
+ # doubles. Keep the endpoint usable for those callers.
608
+ messages.append(message)
609
+
610
+ result: list[dict[str, Any]] = []
611
+ for message in messages:
612
+ try:
613
+ payload = message.model_dump(mode="json")
614
+ except (AttributeError, TypeError, ValueError):
615
+ # Keep the endpoint useful for lightweight CoreSession doubles used
616
+ # by integrations and tests.
617
+ payload = {
618
+ key: getattr(message, key)
619
+ for key in ("uuid", "parent_uuid", "role", "content", "timestamp")
620
+ if hasattr(message, key)
621
+ }
622
+ if not isinstance(payload, dict):
623
+ continue
624
+ role = payload.get("role")
625
+ if hasattr(role, "value"):
626
+ payload["role"] = role.value
627
+ result.append(payload)
628
+ return with_turn_details(result, session.session_id)
629
+
630
+
631
+ @router.post("/fork", response_model=SessionInfo)
632
+ async def fork_session(req: ForkSessionRequest, request: Request) -> SessionInfo:
633
+ """Fork a durable session from a selected, or the latest, assistant reply."""
634
+ if getattr(request.app.state, "gateway_closing", False):
635
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
636
+ from crabcode_core.session.storage import SessionStorage
637
+ async with get_session_lock(request.app.state):
638
+ loaded = dict(request.app.state.sessions)
639
+ default_id = request.app.state.default_session_id
640
+ selector_cwd = os.getcwd()
641
+ if default_id and default_id in loaded:
642
+ selector_cwd = str(getattr(loaded[default_id], "cwd", selector_cwd))
643
+ resolved = _resolve_session_selector(
644
+ req.session_id,
645
+ selector_cwd,
646
+ loaded_sessions=loaded,
647
+ )
648
+ if resolved is None:
649
+ raise HTTPException(status_code=404, detail="Session not found or selector is ambiguous")
650
+ source_id, source_cwd = resolved
651
+ source = loaded.get(source_id)
652
+ if source is not None and (
653
+ bool(getattr(source, "_foreground_turn_active", False))
654
+ or bool(getattr(getattr(source, "_turn_lock", None), "locked", lambda: False)())
655
+ ):
656
+ raise HTTPException(status_code=409, detail="Cannot fork a session while it is running")
657
+ try:
658
+ forked = SessionStorage.fork_from(
659
+ source_cwd,
660
+ source_id,
661
+ req.message_uuid,
662
+ title=req.title,
663
+ )
664
+ except ValueError as exc:
665
+ detail = str(exc)
666
+ status = 404 if "not found" in detail.lower() else 400
667
+ raise HTTPException(status_code=status, detail=detail) from exc
668
+ return _session_info_from_row({**forked.meta, "session_id": forked.session_id})
669
+
670
+
671
+ @router.get("/list", response_model=list[SessionInfo])
672
+ async def list_sessions(
673
+ request: Request,
674
+ cwd: str | None = None,
675
+ ) -> list[SessionInfo]:
676
+ """List all persisted sessions for the current working directory.
677
+
678
+ Uses SessionStorage.list_sessions (disk-based) so sub-agent sessions
679
+ are never included — only top-level user conversations appear here.
680
+ """
681
+ import os
682
+ from crabcode_core.session.storage import SessionStorage
683
+
684
+ # Preserve the existing default-session behavior when cwd is omitted.
685
+ selected_cwd = cwd
686
+ if selected_cwd is None:
687
+ async with get_session_lock(request.app.state):
688
+ sessions: dict = request.app.state.sessions
689
+ default_id = request.app.state.default_session_id
690
+ selected_cwd = os.getcwd()
691
+ if default_id and default_id in sessions:
692
+ selected_cwd = getattr(sessions[default_id], "cwd", selected_cwd)
693
+ else:
694
+ candidate = Path(selected_cwd).expanduser()
695
+ try:
696
+ candidate = candidate.resolve(strict=True)
697
+ except OSError as exc:
698
+ raise HTTPException(status_code=404, detail="Working directory not found") from exc
699
+ if not candidate.is_dir():
700
+ raise HTTPException(status_code=400, detail="cwd must be a directory")
701
+ selected_cwd = str(candidate)
702
+
703
+ try:
704
+ stored = SessionStorage.list_sessions(selected_cwd)
705
+ except Exception:
706
+ stored = []
707
+
708
+ return [_session_info_from_row({**s, "cwd": selected_cwd}) for s in stored]
709
+
710
+
711
+ @router.get("/resolve", response_model=SessionInfo)
712
+ async def resolve_session(selector: str, request: Request) -> SessionInfo:
713
+ """Resolve a session ID, unique prefix, or local list index."""
714
+ from crabcode_core.session.meta_db import SessionMetaStore
715
+ from crabcode_core.session.storage import SessionStorage
716
+
717
+ async with get_session_lock(request.app.state):
718
+ loaded = dict(request.app.state.sessions)
719
+ default_id = request.app.state.default_session_id
720
+ cwd = os.getcwd()
721
+ if default_id and default_id in loaded:
722
+ cwd = str(getattr(loaded[default_id], "cwd", cwd))
723
+ try:
724
+ resolved = _resolve_session_selector(selector, cwd, loaded_sessions=loaded)
725
+ except Exception as exc:
726
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
727
+ if resolved is None:
728
+ raise HTTPException(status_code=404, detail="Session not found or selector is ambiguous")
729
+ session_id, resolved_cwd = resolved
730
+
731
+ session = loaded.get(session_id)
732
+ if session is not None:
733
+ current_name = getattr(session, "_current_model_name", None)
734
+ settings = getattr(session, "settings", None)
735
+ active_config = (
736
+ settings.get_api_config(current_name)
737
+ if settings is not None and hasattr(settings, "get_api_config")
738
+ else None
739
+ )
740
+ session_meta = getattr(getattr(session, "_session_storage", None), "meta", {}) or {}
741
+ return SessionInfo(
742
+ session_id=session_id,
743
+ message_count=len(getattr(session, "messages", ())),
744
+ model=str(getattr(active_config, "model", "") or ""),
745
+ provider=str(getattr(active_config, "provider", "") or ""),
746
+ cwd=resolved_cwd,
747
+ title=str(session_meta.get("title") or ""),
748
+ forked_from_session_id=session_meta.get("forked_from_session_id"),
749
+ forked_from_message_uuid=session_meta.get("forked_from_message_uuid"),
750
+ forked_from_title=session_meta.get("forked_from_title"),
751
+ )
752
+
753
+ store = SessionMetaStore()
754
+ try:
755
+ row = store.get(session_id)
756
+ finally:
757
+ store.close()
758
+ if row is not None:
759
+ return _session_info_from_row(row)
760
+ local = next(
761
+ (
762
+ item
763
+ for item in SessionStorage.list_sessions(resolved_cwd)
764
+ if item.get("session_id") == session_id
765
+ ),
766
+ None,
767
+ )
768
+ if local is None:
769
+ raise HTTPException(status_code=404, detail="Session not found")
770
+ return _session_info_from_row({**local, "cwd": resolved_cwd})
771
+
772
+
773
+ @router.post("/send")
774
+ async def send_message(req: SendMessageRequest, request: Request):
775
+ """Send a message and stream back CoreEvents as SSE.
776
+
777
+ This is the primary interaction endpoint. It starts the query
778
+ loop and streams events via the event bus SSE channel.
779
+ """
780
+ event_bus: EventBus = request.app.state.event_bus
781
+
782
+ # Fire-and-forget: run the query loop, publish events to the bus
783
+ import asyncio
784
+
785
+ async def _run():
786
+ try:
787
+ from crabcode_core.types.event import TurnCompleteEvent
788
+
789
+ images = [img.model_dump() for img in req.images] if req.images else None
790
+ pending_terminal: TurnCompleteEvent | None = None
791
+ async for event in session.send_message(req.text, max_turns=req.max_turns, images=images):
792
+ if isinstance(event, TurnCompleteEvent):
793
+ # A steering message can extend the same Core generator
794
+ # after query_loop produced a candidate terminal. Publish
795
+ # only the candidate that remains when the generator ends.
796
+ pending_terminal = event
797
+ continue
798
+ pending_terminal = None
799
+ await event_bus.publish(
800
+ session.session_id,
801
+ event,
802
+ source=session,
803
+ operation_id=operation_id,
804
+ operation_scope="foreground",
805
+ )
806
+ if pending_terminal is None:
807
+ # A transport adapter may finish without Core's explicit
808
+ # terminal event (for example after yielding a recoverable
809
+ # error). Synthesize one so HTTP/SSE and WebSocket clients
810
+ # share the same foreground lifecycle contract.
811
+ pending_terminal = TurnCompleteEvent(reason="error")
812
+ await event_bus.publish(
813
+ session.session_id,
814
+ pending_terminal,
815
+ source=session,
816
+ operation_id=operation_id,
817
+ operation_scope="foreground",
818
+ )
819
+ current = asyncio.current_task()
820
+ if current is not None:
821
+ setattr(current, "_crabcode_terminal_published", True)
822
+ except asyncio.CancelledError:
823
+ raise
824
+ except Exception as exc:
825
+ from crabcode_core.types.event import ErrorEvent
826
+ await event_bus.publish(
827
+ session.session_id,
828
+ ErrorEvent(message=str(exc), recoverable=False, error_type="internal"),
829
+ source=session,
830
+ operation_id=operation_id,
831
+ operation_scope="foreground",
832
+ )
833
+ from crabcode_core.types.event import TurnCompleteEvent
834
+ await event_bus.publish(
835
+ session.session_id,
836
+ TurnCompleteEvent(reason="error"),
837
+ source=session,
838
+ operation_id=operation_id,
839
+ operation_scope="foreground",
840
+ )
841
+ current = asyncio.current_task()
842
+ if current is not None:
843
+ setattr(current, "_crabcode_terminal_published", True)
844
+
845
+ # Resolve and register under the same app lock used by archive/stop. This
846
+ # closes the race where a request captured a session just before it was
847
+ # removed and then started a query after CoreSession.close().
848
+ async with get_session_lock(request.app.state):
849
+ if getattr(request.app.state, "gateway_closing", False):
850
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
851
+ session = _get_session(request, req.session_id)
852
+ if not session:
853
+ raise HTTPException(status_code=404, detail="Session not found")
854
+ _bind_computer_use(
855
+ session, request.app.state, None, req.computer_use_enabled, req.computer_use_mode,
856
+ req.computer_use_target_scope, req.computer_use_delivery_policy,
857
+ )
858
+ operation_id = req.operation_id or uuid.uuid4().hex
859
+ if req.operation_id is None:
860
+ while operation_is_registered(
861
+ request.app.state,
862
+ session.session_id,
863
+ operation_id,
864
+ ):
865
+ operation_id = uuid.uuid4().hex
866
+ elif operation_is_registered(
867
+ request.app.state,
868
+ session.session_id,
869
+ operation_id,
870
+ ):
871
+ raise HTTPException(
872
+ status_code=409,
873
+ detail=f"Operation already active: {operation_id}",
874
+ )
875
+ task = asyncio.create_task(_run())
876
+ track_task(
877
+ request.app.state,
878
+ session.session_id,
879
+ task,
880
+ operation_id=operation_id,
881
+ operation_scope="foreground",
882
+ )
883
+
884
+ return {
885
+ "status": "started",
886
+ "session_id": session.session_id,
887
+ "operation_id": operation_id,
888
+ }
889
+
890
+
891
+ @router.post("/compact")
892
+ async def compact_session(req: CompactRequest, request: Request):
893
+ """Manually trigger conversation compaction."""
894
+ session = _get_session(request, req.session_id)
895
+ if not session:
896
+ raise HTTPException(status_code=404, detail="Session not found")
897
+
898
+ try:
899
+ accepted = await run_session_operation(
900
+ request.app.state,
901
+ session,
902
+ lambda: session.compact(req.custom_instructions),
903
+ )
904
+ except SessionOperationRejected as exc:
905
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
906
+ return {"status": "ok" if accepted else "not_compacted"}
907
+
908
+
909
+ @router.post("/clear")
910
+ async def clear_session(req: ClearSessionRequest, request: Request):
911
+ """Clear and durably persist the active conversation projection."""
912
+ session = _get_session(request, req.session_id)
913
+ if not session:
914
+ raise HTTPException(status_code=404, detail="Session not found")
915
+
916
+ try:
917
+ cleared = await run_session_operation(
918
+ request.app.state,
919
+ session,
920
+ session.clear_history,
921
+ )
922
+ except SessionOperationRejected as exc:
923
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
924
+ return {"status": "ok", "messages_cleared": cleared}
925
+
926
+
927
+ @router.post("/interrupt")
928
+ async def interrupt_session(req: InterruptRequest, request: Request):
929
+ """Interrupt the current query loop."""
930
+ session = _get_session(request, req.session_id)
931
+ if not session:
932
+ raise HTTPException(status_code=404, detail="Session not found")
933
+
934
+ if req.operation_id is not None:
935
+ cancelled = await cancel_operation_task(
936
+ request.app.state,
937
+ session.session_id,
938
+ req.operation_id,
939
+ expected_session=session,
940
+ )
941
+ if cancelled is None:
942
+ raise HTTPException(status_code=404, detail="Operation not found")
943
+ task = cancelled.task
944
+ try:
945
+ if not getattr(task, "_crabcode_terminal_published", False):
946
+ from crabcode_core.types.event import TurnCompleteEvent
947
+
948
+ await request.app.state.event_bus.publish(
949
+ session.session_id,
950
+ TurnCompleteEvent(reason="interrupted"),
951
+ source=session,
952
+ operation_id=req.operation_id,
953
+ operation_scope=(
954
+ getattr(task, "_crabcode_operation_scope", None)
955
+ or "foreground"
956
+ ),
957
+ )
958
+ setattr(task, "_crabcode_terminal_published", True)
959
+ finally:
960
+ release_operation_claim(
961
+ request.app.state,
962
+ session.session_id,
963
+ req.operation_id,
964
+ cancelled.claim,
965
+ )
966
+ return {
967
+ "status": "ok",
968
+ "operation_id": req.operation_id,
969
+ }
970
+
971
+ try:
972
+ await run_session_operation(
973
+ request.app.state,
974
+ session,
975
+ session.interrupt,
976
+ )
977
+ except SessionOperationRejected as exc:
978
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
979
+ return {"status": "ok"}
980
+
981
+
982
+ @router.get("/status", response_model=SessionRuntimeStatus)
983
+ async def session_status(
984
+ request: Request,
985
+ session_id: str | None = None,
986
+ ) -> SessionRuntimeStatus:
987
+ """Return the complete non-secret runtime status for one session."""
988
+ async with get_session_lock(request.app.state):
989
+ session = _get_session(request, session_id)
990
+ if not session:
991
+ raise HTTPException(status_code=404, detail="Session not found")
992
+
993
+ messages = list(getattr(session, "messages", ()))
994
+ initialized = bool(getattr(session, "_initialized", False))
995
+ current_name = getattr(session, "_current_model_name", None)
996
+ settings = getattr(session, "settings", None)
997
+ active_config = None
998
+ if settings is not None and hasattr(settings, "get_api_config"):
999
+ active_config = settings.get_api_config(current_name)
1000
+ model = active_config.model or ""
1001
+ provider = active_config.provider or ""
1002
+ else:
1003
+ # Retain compatibility with lightweight integration doubles and
1004
+ # older session implementations that expose flat attributes.
1005
+ model = getattr(session, "model", "")
1006
+ provider = getattr(session, "provider", "")
1007
+ mode = getattr(session, "agent_mode", getattr(session, "mode", "agent"))
1008
+ reasoning_effort = getattr(session, "reasoning_effort", None)
1009
+ ultra_mode = bool(getattr(session, "ultra_mode", False))
1010
+ permission_mode = getattr(session, "client_permission_mode", "default")
1011
+ permission_manager = getattr(session, "_permission_manager", None)
1012
+ describe_policy = getattr(permission_manager, "describe_policy", None)
1013
+ permission_policy = describe_policy() if callable(describe_policy) else None
1014
+ sid = session.session_id
1015
+
1016
+ enabled_tools = None
1017
+ if initialized:
1018
+ enabled_tools = sum(
1019
+ 1 for tool in getattr(session, "tools", ())
1020
+ if bool(getattr(tool, "is_enabled", True))
1021
+ )
1022
+
1023
+ try:
1024
+ agents = list(session.list_agents())
1025
+ except (AttributeError, RuntimeError):
1026
+ agents = []
1027
+ try:
1028
+ monitors = list(session.list_monitor_tasks())
1029
+ except (AttributeError, RuntimeError):
1030
+ monitors = []
1031
+
1032
+ token_source = getattr(session, "last_context_token_source", "estimated")
1033
+ used = max(0, int(getattr(session, "last_context_used_tokens", 0) or 0))
1034
+ window = max(0, int(getattr(session, "last_context_window_tokens", 0) or 0))
1035
+ if not used and messages:
1036
+ from crabcode_core.compact.compact import estimate_token_count
1037
+
1038
+ used = max(0, int(estimate_token_count(messages)))
1039
+ token_source = "estimated"
1040
+ if not window and settings is not None:
1041
+ from crabcode_core.api.model_info import (
1042
+ DEFAULT_CONTEXT_WINDOW,
1043
+ lookup_context_window,
1044
+ )
1045
+
1046
+ window = max(
1047
+ 0,
1048
+ int(
1049
+ getattr(active_config, "context_window", None)
1050
+ or 0
1051
+ ),
1052
+ )
1053
+ adapter = getattr(session, "_api_adapter", None)
1054
+ if not window and provider == "codex" and hasattr(
1055
+ adapter, "resolve_context_window"
1056
+ ):
1057
+ try:
1058
+ window = max(0, int(await adapter.resolve_context_window()))
1059
+ except Exception:
1060
+ window = 0
1061
+ if not window:
1062
+ window = max(
1063
+ 0,
1064
+ int(
1065
+ lookup_context_window(getattr(active_config, "model", None))
1066
+ or DEFAULT_CONTEXT_WINDOW
1067
+ ),
1068
+ )
1069
+
1070
+ search_index = None
1071
+ extra_tools = list(getattr(settings, "extra_tools", ()) or ())
1072
+ if "crabcode_search.CodebaseSearchTool" in extra_tools:
1073
+ try:
1074
+ from crabcode_search.background import read_background_status
1075
+
1076
+ raw_search = read_background_status(getattr(session, "cwd", os.getcwd()))
1077
+ except Exception:
1078
+ raw_search = None
1079
+ if isinstance(raw_search, dict):
1080
+ def _optional_int(key: str) -> int | None:
1081
+ value = raw_search.get(key)
1082
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
1083
+
1084
+ search_index = SearchIndexStatus(
1085
+ state=str(raw_search.get("state") or "unknown"),
1086
+ chunks=_optional_int("chunks"),
1087
+ files=_optional_int("files"),
1088
+ done=_optional_int("done"),
1089
+ total=_optional_int("total"),
1090
+ )
1091
+ else:
1092
+ search_index = SearchIndexStatus(state="waiting")
1093
+
1094
+ agent_settings = getattr(settings, "agent", None)
1095
+ from crabcode_core.compact.compact import compaction_input_limit
1096
+ from crabcode_core.types.config import DEFAULT_COMPACT_BUFFER_TOKENS
1097
+
1098
+ compact_buffer = getattr(settings, "compact_buffer_tokens", DEFAULT_COMPACT_BUFFER_TOKENS)
1099
+ compact_override = getattr(settings, "max_context_length", None)
1100
+ max_output = max(0, int(getattr(active_config, "max_tokens", 0) or 0))
1101
+ result = SessionRuntimeStatus(
1102
+ session_id=sid,
1103
+ cwd=str(getattr(session, "cwd", "") or ""),
1104
+ initialized=initialized,
1105
+ message_count=len(messages),
1106
+ model=model,
1107
+ model_profile=current_name,
1108
+ provider=provider,
1109
+ mode=mode if mode in {"agent", "plan"} else "agent",
1110
+ reasoning_effort=reasoning_effort,
1111
+ ultra_mode=ultra_mode,
1112
+ permission_mode=permission_mode,
1113
+ permission_policy=permission_policy,
1114
+ context_used_tokens=used,
1115
+ context_token_source=token_source,
1116
+ context_window_tokens=window,
1117
+ context_remaining_tokens=max(0, window - used),
1118
+ context_used_percent=round(used / window * 100, 1) if window else 0.0,
1119
+ compact_count=max(0, int(getattr(session, "compact_count", 0) or 0)),
1120
+ auto_compact_enabled=bool(
1121
+ getattr(settings, "auto_compact_enabled", True)
1122
+ ),
1123
+ compact_buffer_tokens=compact_buffer,
1124
+ max_context_length=compact_override,
1125
+ compact_input_limit=compaction_input_limit(
1126
+ window, max_output, buffer_tokens=compact_buffer, override=compact_override,
1127
+ ) if window else None,
1128
+ thinking_enabled=bool(getattr(active_config, "thinking_enabled", False)),
1129
+ max_tokens=max_output,
1130
+ tool_count=enabled_tools,
1131
+ prompt_budget=getattr(session, "last_prompt_budget", None) or None,
1132
+ agent_total=len(agents),
1133
+ agent_active=sum(
1134
+ 1 for item in agents
1135
+ if getattr(item, "status", "") in {"queued", "running"}
1136
+ ),
1137
+ agent_failed=sum(
1138
+ 1 for item in agents if getattr(item, "status", "") == "failed"
1139
+ ),
1140
+ agent_pending_callbacks=sum(
1141
+ 1 for item in agents
1142
+ if bool(getattr(item, "callback_enabled", False))
1143
+ and getattr(item, "callback_state", "") in {"pending", "injected"}
1144
+ ),
1145
+ agent_max_concurrency=max(
1146
+ 0,
1147
+ int(getattr(agent_settings, "max_concurrency", 0) or 0),
1148
+ ),
1149
+ monitor_total=len(monitors),
1150
+ monitor_active=sum(
1151
+ 1 for item in monitors if getattr(item, "status", "") == "running"
1152
+ ),
1153
+ monitor_failed=sum(
1154
+ 1 for item in monitors if getattr(item, "status", "") == "failed"
1155
+ ),
1156
+ search_index=search_index,
1157
+ )
1158
+ return result
1159
+
1160
+
1161
+ @router.get("/recent", response_model=list[SessionInfo])
1162
+ async def recent_sessions(limit: int = 10, request: Request = None):
1163
+ """List recently updated sessions across all projects."""
1164
+ from crabcode_core.session.meta_db import SessionMetaStore
1165
+
1166
+ try:
1167
+ store = SessionMetaStore()
1168
+ try:
1169
+ rows = store.list_recent(limit=max(1, min(200, limit)))
1170
+ finally:
1171
+ store.close()
1172
+ except Exception as exc:
1173
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
1174
+ return [_session_info_from_row(row) for row in rows]
1175
+
1176
+
1177
+ @router.post("/search", response_model=list[SessionInfo])
1178
+ async def search_sessions(req: SearchSessionsRequest, request: Request):
1179
+ """Search sessions by title or first message content."""
1180
+ from crabcode_core.session.storage import SessionStorage
1181
+
1182
+ try:
1183
+ rows = SessionStorage.search_sessions(req.query, limit=req.limit)
1184
+ except Exception:
1185
+ rows = []
1186
+
1187
+ return [_session_info_from_row(row) for row in rows]
1188
+
1189
+
1190
+ @router.post("/archive")
1191
+ async def archive_session(req: ArchiveSessionRequest, request: Request):
1192
+ """Archive a loaded or persisted session selected by ID/prefix/index."""
1193
+ from crabcode_core.session.meta_db import SessionMetaStore
1194
+ from crabcode_core.session.storage import SessionStorage
1195
+
1196
+ if not req.session_id:
1197
+ raise HTTPException(status_code=404, detail="Session not found")
1198
+
1199
+ session = None
1200
+ resolved_session_id = ""
1201
+ async with get_session_load_lock(request.app.state):
1202
+ async with get_session_lock(request.app.state):
1203
+ sessions: dict = request.app.state.sessions
1204
+ loaded = dict(sessions)
1205
+ default_id = request.app.state.default_session_id
1206
+ cwd = os.getcwd()
1207
+ if default_id and default_id in sessions:
1208
+ cwd = str(getattr(sessions[default_id], "cwd", cwd))
1209
+
1210
+ try:
1211
+ resolved = _resolve_session_selector(
1212
+ req.session_id,
1213
+ cwd,
1214
+ loaded_sessions=loaded,
1215
+ )
1216
+ except Exception as exc:
1217
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
1218
+ if resolved is None:
1219
+ raise HTTPException(
1220
+ status_code=404,
1221
+ detail="Session not found or selector is ambiguous",
1222
+ )
1223
+ resolved_session_id, resolved_cwd = resolved
1224
+ _validate_session_id(resolved_session_id)
1225
+
1226
+ async with get_session_lock(request.app.state):
1227
+ if resolved_session_id in getattr(
1228
+ request.app.state,
1229
+ "closing_sessions",
1230
+ set(),
1231
+ ):
1232
+ raise HTTPException(status_code=503, detail="Session is shutting down")
1233
+ session = request.app.state.sessions.get(resolved_session_id)
1234
+ mark_session_closing(request.app.state, resolved_session_id)
1235
+
1236
+ archive_committed = False
1237
+ try:
1238
+ store = SessionMetaStore()
1239
+ try:
1240
+ row = store.get(resolved_session_id)
1241
+ if row is not None:
1242
+ store.archive(resolved_session_id)
1243
+ else:
1244
+ SessionStorage(
1245
+ resolved_cwd,
1246
+ resolved_session_id,
1247
+ ).persist_archive_marker()
1248
+ finally:
1249
+ store.close()
1250
+ archive_committed = True
1251
+ except Exception as exc:
1252
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
1253
+ finally:
1254
+ if not archive_committed:
1255
+ async with get_session_lock(request.app.state):
1256
+ unmark_session_closing(
1257
+ request.app.state,
1258
+ resolved_session_id,
1259
+ )
1260
+
1261
+ async with get_session_lock(request.app.state):
1262
+ sessions = request.app.state.sessions
1263
+ session = sessions.get(resolved_session_id)
1264
+ if session is not None:
1265
+ close_session_events = getattr(
1266
+ request.app.state.event_bus,
1267
+ "close_session",
1268
+ None,
1269
+ )
1270
+ if callable(close_session_events):
1271
+ close_session_events(resolved_session_id, session)
1272
+ sessions.pop(resolved_session_id, None)
1273
+ request.app.state.client_contexts.pop(resolved_session_id, None)
1274
+ if request.app.state.default_session_id == resolved_session_id:
1275
+ request.app.state.default_session_id = next(iter(sessions), None)
1276
+
1277
+ if session is not None:
1278
+ try:
1279
+ await shielded_cleanup_session(
1280
+ request.app.state,
1281
+ resolved_session_id,
1282
+ session,
1283
+ )
1284
+ except Exception:
1285
+ from crabcode_core.logging_utils import get_logger
1286
+ get_logger(__name__).warning(
1287
+ "Failed to close archived session %s",
1288
+ resolved_session_id,
1289
+ exc_info=True,
1290
+ )
1291
+ else:
1292
+ unmark_session_closing(request.app.state, resolved_session_id)
1293
+ return {"status": "ok", "session_id": resolved_session_id}
1294
+
1295
+
1296
+ @router.post("/prune")
1297
+ async def prune_sessions(req: PruneSessionsRequest, request: Request):
1298
+ """Archive stale inactive sessions and optionally purge their artifacts.
1299
+
1300
+ Loaded sessions are always excluded. The load lock prevents a concurrent
1301
+ resume from installing one of the selected sessions between discovery and
1302
+ its durable archive/purge transition.
1303
+ """
1304
+ from crabcode_core.session.meta_db import SessionMetaStore
1305
+ from crabcode_core.session.storage import purge_session_artifacts
1306
+
1307
+ if getattr(request.app.state, "gateway_closing", False):
1308
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
1309
+
1310
+ async with get_session_load_lock(request.app.state):
1311
+ async with get_session_lock(request.app.state):
1312
+ if getattr(request.app.state, "gateway_closing", False):
1313
+ raise HTTPException(status_code=503, detail="Gateway is shutting down")
1314
+ loaded_ids = set(request.app.state.sessions)
1315
+
1316
+ store = SessionMetaStore()
1317
+ try:
1318
+ archived = store.auto_archive(
1319
+ days=req.days,
1320
+ exclude_ids=loaded_ids,
1321
+ )
1322
+ purged = 0
1323
+ failed: list[str] = []
1324
+ if req.delete_files:
1325
+ candidates = store.purge_archived(
1326
+ delete_rows=False,
1327
+ exclude_ids=loaded_ids,
1328
+ )
1329
+ for entry in candidates:
1330
+ session_id = str(entry.get("id") or "")
1331
+ cwd = str(entry.get("cwd") or "")
1332
+ if not session_id:
1333
+ continue
1334
+ try:
1335
+ if cwd:
1336
+ purge_session_artifacts(cwd, session_id)
1337
+ store.delete(session_id)
1338
+ purged += 1
1339
+ except (OSError, ValueError):
1340
+ failed.append(session_id)
1341
+ finally:
1342
+ store.close()
1343
+
1344
+ return {
1345
+ "archived": archived,
1346
+ "purged": purged,
1347
+ "failed": failed,
1348
+ "skipped_loaded": len(loaded_ids),
1349
+ }
1350
+
1351
+
1352
+ @router.post("/export")
1353
+ async def export_session(req: ExportSessionRequest, request: Request):
1354
+ """Export a session selected by ID, unique prefix, or local index."""
1355
+ from crabcode_core.session.export import export_json, export_markdown
1356
+ from crabcode_core.session.meta_db import SessionMetaStore
1357
+ from crabcode_core.session.storage import SessionStorage, get_transcript_path
1358
+
1359
+ if not req.session_id:
1360
+ raise HTTPException(status_code=404, detail="Session not found")
1361
+
1362
+ async with get_session_lock(request.app.state):
1363
+ sessions: dict = request.app.state.sessions
1364
+ loaded = dict(sessions)
1365
+ default_id = request.app.state.default_session_id
1366
+ selector_cwd = os.getcwd()
1367
+ if default_id and default_id in sessions:
1368
+ selector_cwd = str(getattr(sessions[default_id], "cwd", selector_cwd))
1369
+
1370
+ try:
1371
+ resolved_selector = _resolve_session_selector(
1372
+ req.session_id,
1373
+ selector_cwd,
1374
+ loaded_sessions=loaded,
1375
+ )
1376
+ except Exception as exc:
1377
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
1378
+ if resolved_selector is None:
1379
+ # Archived sessions are omitted from normal selectors, but a caller
1380
+ # holding the exact ID must still be able to export the retained audit
1381
+ # transcript. Prefixes and indexes intentionally remain active-only.
1382
+ exact_id = req.session_id.strip()
1383
+ try:
1384
+ _validate_session_id(exact_id)
1385
+ except HTTPException:
1386
+ raise
1387
+ store = SessionMetaStore()
1388
+ try:
1389
+ archived_row = store.get(exact_id)
1390
+ finally:
1391
+ store.close()
1392
+ archived_cwd = (
1393
+ str(archived_row.get("cwd") or selector_cwd)
1394
+ if archived_row is not None and archived_row.get("is_archived")
1395
+ else selector_cwd
1396
+ )
1397
+ transcript = get_transcript_path(archived_cwd, exact_id)
1398
+ if not transcript.exists() or (
1399
+ archived_row is not None and not archived_row.get("is_archived")
1400
+ ):
1401
+ raise HTTPException(
1402
+ status_code=404,
1403
+ detail="Session not found or selector is ambiguous",
1404
+ )
1405
+ resolved_selector = (exact_id, archived_cwd)
1406
+ session_id, cwd = resolved_selector
1407
+ _validate_session_id(session_id)
1408
+
1409
+ async with get_session_lock(request.app.state):
1410
+ active_session = _get_session(request, session_id)
1411
+ if active_session is not None:
1412
+ cwd = str(getattr(active_session, "cwd", cwd))
1413
+
1414
+ if active_session is None:
1415
+ # Prefer the persisted project recorded in SQLite. This matters when
1416
+ # the requested session belongs to another project than the active one.
1417
+ resolved_storage = SessionStorage.from_session_id(session_id)
1418
+ if resolved_storage is not None:
1419
+ cwd = resolved_storage.cwd
1420
+
1421
+ # SQLite may be unavailable while the JSONL transcript is still
1422
+ # present. Check the local candidate before declaring the id missing.
1423
+ try:
1424
+ transcript = get_transcript_path(cwd, session_id)
1425
+ if resolved_storage is None and not transcript.exists():
1426
+ raise HTTPException(status_code=404, detail="Session not found")
1427
+ except HTTPException:
1428
+ raise
1429
+ except (TypeError, ValueError) as exc:
1430
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1431
+
1432
+ async def _render_export():
1433
+ if req.format == "json":
1434
+ return (
1435
+ export_json(session_id, cwd),
1436
+ "application/json",
1437
+ f"session-{session_id[:8]}.json",
1438
+ )
1439
+ return (
1440
+ export_markdown(session_id, cwd),
1441
+ "text/markdown",
1442
+ f"session-{session_id[:8]}.md",
1443
+ )
1444
+
1445
+ try:
1446
+ if active_session is not None:
1447
+ rendered = await run_session_operation(
1448
+ request.app.state,
1449
+ active_session,
1450
+ _render_export,
1451
+ )
1452
+ else:
1453
+ rendered = await _render_export()
1454
+ content, media_type, filename = rendered
1455
+ except SessionOperationRejected as exc:
1456
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1457
+ except Exception as exc:
1458
+ raise HTTPException(status_code=500, detail=str(exc))
1459
+
1460
+ from fastapi.responses import Response
1461
+ return Response(
1462
+ content=content,
1463
+ media_type=media_type,
1464
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
1465
+ )
1466
+
1467
+
1468
+ @router.get("/stats")
1469
+ async def session_stats(request: Request):
1470
+ """Return usage statistics: global totals and per-project breakdown."""
1471
+ import os
1472
+ from crabcode_core.session.meta_db import SessionMetaStore
1473
+
1474
+ async with get_session_lock(request.app.state):
1475
+ sessions: dict = request.app.state.sessions
1476
+ default_id = request.app.state.default_session_id
1477
+ cwd = os.getcwd()
1478
+ if default_id and default_id in sessions:
1479
+ cwd = getattr(sessions[default_id], "cwd", cwd)
1480
+
1481
+ try:
1482
+ store = SessionMetaStore()
1483
+ try:
1484
+ global_stats = store.stats_global()
1485
+ project_stats = store.stats_by_project(cwd)
1486
+ model_stats = store.stats_by_model()
1487
+ finally:
1488
+ store.close()
1489
+ except Exception as exc:
1490
+ raise HTTPException(status_code=500, detail=str(exc))
1491
+
1492
+ return {
1493
+ "global": global_stats,
1494
+ "project": {**project_stats, "cwd": cwd},
1495
+ "by_model": model_stats,
1496
+ }