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,1698 @@
1
+ """Configuration and context routes — /config/*, /context, /tools, /skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import codecs
7
+ import json
8
+ import os
9
+ import stat
10
+ import tempfile
11
+ import inspect
12
+ import time
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from fastapi import APIRouter, HTTPException, Request
18
+ from fastapi.responses import StreamingResponse
19
+ from pydantic import BaseModel
20
+
21
+ from crabcode_core.config.manager import ConfigManager
22
+ from crabcode_core.filesystem import replace_with_retry
23
+ from crabcode_core.paths import get_config_home
24
+ from crabcode_core.skills.loader import load_skills
25
+ from crabcode_core.text_io import normalize_newlines, read_utf8_text
26
+ from crabcode_gateway.session_registry import get_session_lock
27
+ from crabcode_gateway.schemas import (
28
+ ContextPushRequest,
29
+ GoalRequest,
30
+ GoalState,
31
+ ModelInfo,
32
+ ModelSettingsEntry,
33
+ ModelSettingsMutationRequest,
34
+ ModelSettingsResponse,
35
+ ModelSettingsSource,
36
+ PromptSectionInfo,
37
+ PromptSettingsMutationRequest,
38
+ PromptSettingsResponse,
39
+ PromptTemplateView,
40
+ RuntimeSettingsMutationRequest,
41
+ RuntimeSettingsResponse,
42
+ UserAppendPromptView,
43
+ SetPermissionModeRequest,
44
+ SetReasoningEffortRequest,
45
+ SetUltraModeRequest,
46
+ SkillExpandRequest,
47
+ SkillExpansion,
48
+ SkillInfo,
49
+ LogsResponse,
50
+ SwitchModeRequest,
51
+ SwitchModelRequest,
52
+ ToolInfo,
53
+ )
54
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
55
+
56
+ router = APIRouter(tags=["config"])
57
+
58
+
59
+ class ModelTestRequest(BaseModel):
60
+ name: str
61
+ cwd: str | None = None
62
+
63
+
64
+ def _model_test_error(exc: Exception) -> str:
65
+ # Do not return raw provider messages: they may contain keys, headers or URLs.
66
+ from crabcode_core.api.network import certificate_failure
67
+ if certificate_failure(exc):
68
+ return "证书校验失败,请检查证书或网络拦截;未自动重试"
69
+ status = getattr(exc, "status_code", None)
70
+ if status:
71
+ reason = {
72
+ 400: "请求参数或 API 格式不兼容",
73
+ 401: "认证失败,请检查 API Key",
74
+ 403: "没有访问权限,请检查密钥权限或服务区域限制",
75
+ 404: "模型或 API 地址不存在,请检查模型 ID 和 Base URL",
76
+ 429: "请求限流或额度不足,请检查服务商额度",
77
+ }.get(status, "模型服务返回错误,请检查服务商状态与配置")
78
+ return f"HTTP {status}:{reason}"
79
+ name = type(exc).__name__
80
+ if isinstance(exc, (TimeoutError, asyncio.TimeoutError)) or "Timeout" in name:
81
+ return "测试超时(最多 30 秒),请检查网络、代理和模型服务状态"
82
+ if "Connection" in name or isinstance(exc, OSError):
83
+ return "连接失败,请检查 Base URL、网络、代理和 TLS 证书"
84
+ if "credential" in str(exc).lower() or "api_key" in str(exc).lower():
85
+ return "缺少认证信息,请检查 API Key 环境变量是否已配置,并重启 Gateway"
86
+ if str(exc) == "Empty model response":
87
+ return "连接已建立,但模型没有返回文本,请检查模型能力和 API 格式"
88
+ return f"模型初始化或响应失败({name}),请检查 Provider、API 格式和模型配置"
89
+
90
+
91
+ async def _probe_model(config: Any, cwd: str | None = None) -> None:
92
+ from crabcode_core.api import ModelConfig, create_adapter
93
+ from crabcode_core.usage import tracked_stream_message
94
+ from crabcode_core.types.message import create_user_message
95
+
96
+ adapter = None
97
+ stream = None
98
+ try:
99
+ config.request_max_retries = 0
100
+ config.max_retries = 0
101
+ config.unbounded_connection_retries = False
102
+ config.timeout = 30
103
+ adapter = create_adapter(config)
104
+ client = getattr(adapter, "client", None)
105
+ if callable(getattr(client, "with_options", None)):
106
+ adapter.client = client.with_options(max_retries=0, timeout=30)
107
+ stream = tracked_stream_message(
108
+ adapter,
109
+ messages=[create_user_message("Reply with OK.")],
110
+ system=[], tools=[],
111
+ config=ModelConfig(model=config.model, max_tokens=256,
112
+ thinking_enabled=False, thinking_budget=0, timeout=30),
113
+ cwd=cwd, purpose="model_test",
114
+ )
115
+ async for chunk in stream:
116
+ if chunk.type == "error":
117
+ raise RuntimeError(chunk.error or "Model stream error")
118
+ if chunk.type == "text" and chunk.text.strip():
119
+ return
120
+ raise RuntimeError("Empty model response")
121
+ finally:
122
+ for resource in (stream, getattr(adapter, "client", None)):
123
+ close = getattr(resource, "aclose", None) or getattr(resource, "close", None)
124
+ if callable(close):
125
+ try:
126
+ result = close()
127
+ if inspect.isawaitable(result):
128
+ await asyncio.wait_for(result, timeout=2)
129
+ except Exception:
130
+ pass
131
+
132
+
133
+ @router.post("/config/test-model")
134
+ async def test_model(req: ModelTestRequest, request: Request) -> dict[str, Any]:
135
+ cwd = _resolve_model_settings_cwd(request, req.cwd)
136
+ settings = ConfigManager(cwd=cwd).load()
137
+ if req.name not in settings.models:
138
+ raise HTTPException(status_code=404, detail="模型配置不存在,请刷新模型目录")
139
+ config = settings.get_api_config(req.name).model_copy(deep=True)
140
+ if not config.model:
141
+ return {"ok": False, "message": "未配置模型 ID"}
142
+ started = time.monotonic()
143
+ try:
144
+ await asyncio.wait_for(_probe_model(config, cwd), timeout=30)
145
+ except Exception as exc:
146
+ return {"ok": False, "message": _model_test_error(exc)}
147
+ return {"ok": True, "message": "连接成功,已收到模型回复",
148
+ "elapsed_ms": round((time.monotonic() - started) * 1000)}
149
+
150
+
151
+ async def _switch_model(session: Any, name: str) -> bool:
152
+ return bool(session.switch_model(name))
153
+
154
+
155
+ async def _switch_mode(session: Any, mode: str) -> bool:
156
+ return bool(session.switch_mode(mode))
157
+
158
+
159
+ async def _set_reasoning_effort(session: Any, effort: str) -> bool:
160
+ await session.initialize()
161
+ return bool(session.set_reasoning_effort(effort))
162
+
163
+
164
+ async def _set_ultra_mode(session: Any, enabled: bool | None) -> bool:
165
+ await session.initialize()
166
+ return bool(session.set_ultra_mode(enabled))
167
+
168
+
169
+ async def _set_permission_mode(session: Any, mode: str) -> bool:
170
+ await session.initialize()
171
+ return bool(session.set_client_permission_mode(mode))
172
+
173
+
174
+ async def _manage_goal(session: Any, req: GoalRequest) -> dict[str, Any] | None:
175
+ action = req.action
176
+ if action in {"set", "edit"}:
177
+ if not req.objective or not req.objective.strip():
178
+ raise ValueError("objective is required for set/edit")
179
+ if action == "set":
180
+ goal = session.create_goal(
181
+ req.objective,
182
+ token_budget=req.token_budget,
183
+ )
184
+ elif "token_budget" in req.model_fields_set:
185
+ goal = session.edit_goal(
186
+ req.objective,
187
+ token_budget=req.token_budget,
188
+ )
189
+ else:
190
+ goal = session.edit_goal(req.objective)
191
+ return goal.to_dict()
192
+ if action == "clear":
193
+ session.clear_goal()
194
+ return None
195
+ status = {
196
+ "pause": "paused",
197
+ "resume": "active",
198
+ }.get(action, action)
199
+ return session.update_goal(status).to_dict()
200
+
201
+
202
+ async def _store_context(request: Request, session: Any, req: ContextPushRequest) -> None:
203
+ contexts: dict = request.app.state.client_contexts
204
+ contexts[session.session_id] = req.model_dump()
205
+
206
+
207
+ def _get_session(request: Request, session_id: str | None = None):
208
+ sessions: dict = request.app.state.sessions
209
+ # ``None`` means the caller omitted a selector and may use the legacy
210
+ # process default. An explicitly supplied (even malformed) id is
211
+ # authoritative and must never fall through to another conversation.
212
+ sid = (
213
+ request.app.state.default_session_id
214
+ if session_id is None
215
+ else session_id
216
+ )
217
+ if not sid or sid not in sessions:
218
+ return None
219
+ if sid in getattr(request.app.state, "closing_sessions", set()):
220
+ return None
221
+ return sessions[sid]
222
+
223
+
224
+ def _list_models_from_settings(cwd: str = ".") -> list[ModelInfo]:
225
+ """Read model list directly from settings (works without a session)."""
226
+ settings = ConfigManager(cwd=cwd).load()
227
+ result: list[ModelInfo] = []
228
+ for name in settings.models:
229
+ cfg = settings.get_api_config(name)
230
+ parts = []
231
+ if cfg.provider:
232
+ parts.append(cfg.provider)
233
+ if cfg.model:
234
+ parts.append(cfg.model)
235
+ desc = "/".join(parts) if parts else "(no model set)"
236
+ result.append(
237
+ ModelInfo(
238
+ name=name,
239
+ description=desc,
240
+ group=cfg.group or "default",
241
+ )
242
+ )
243
+ return result
244
+
245
+
246
+ _MODEL_SETTING_KEYS = ("default_model", "groups", "models")
247
+ _SENSITIVE_CONFIG_KEYS = (
248
+ "api_key",
249
+ "apikey",
250
+ "authorization",
251
+ "cookie",
252
+ "password",
253
+ "secret",
254
+ "token",
255
+ )
256
+
257
+
258
+ def _merge_model_settings(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
259
+ """Merge model settings with the same nested-object semantics as ConfigManager."""
260
+ result = dict(base)
261
+ for key, value in override.items():
262
+ normalized_key = str(key).lower().replace("-", "_")
263
+ if (
264
+ value == "[redacted]"
265
+ and not normalized_key.endswith("_env")
266
+ and not normalized_key.endswith("_path")
267
+ and any(part in normalized_key for part in _SENSITIVE_CONFIG_KEYS)
268
+ ):
269
+ # GET responses redact secrets. Treating that marker as an update
270
+ # would destroy the original credential during an otherwise
271
+ # unrelated edit, including nested headers/extra_body objects.
272
+ continue
273
+ current = result.get(key)
274
+ if isinstance(current, dict) and isinstance(value, dict):
275
+ result[key] = _merge_model_settings(current, value)
276
+ elif isinstance(current, list) and isinstance(value, list):
277
+ merged: list[Any] = []
278
+ seen: set[str] = set()
279
+ for item in current + value:
280
+ marker = (
281
+ json.dumps(item, sort_keys=True)
282
+ if isinstance(item, dict)
283
+ else str(item)
284
+ )
285
+ if marker not in seen:
286
+ seen.add(marker)
287
+ merged.append(item)
288
+ result[key] = merged
289
+ else:
290
+ result[key] = value
291
+ return result
292
+
293
+
294
+ def _redact_model_settings(value: Any, key: str = "") -> Any:
295
+ """Keep useful configuration visible without echoing embedded credentials."""
296
+ normalized_key = key.lower().replace("-", "_")
297
+ is_reference = normalized_key.endswith("_env") or normalized_key.endswith("_path")
298
+ if key and not is_reference and any(part in normalized_key for part in _SENSITIVE_CONFIG_KEYS):
299
+ return "[redacted]"
300
+ if isinstance(value, dict):
301
+ return {
302
+ child_key: _redact_model_settings(child, str(child_key))
303
+ for child_key, child in value.items()
304
+ }
305
+ if isinstance(value, list):
306
+ return [_redact_model_settings(child) for child in value]
307
+ return value
308
+
309
+
310
+ def _is_writable_settings_path(path: Path) -> bool:
311
+ candidate = path
312
+ while not candidate.exists() and candidate != candidate.parent:
313
+ candidate = candidate.parent
314
+ return os.access(candidate, os.W_OK)
315
+
316
+
317
+ def _model_settings_from_files(cwd: str) -> ModelSettingsResponse:
318
+ """Read raw model settings by layer, then resolve their effective values."""
319
+ from crabcode_core.config.manager import SETTING_SOURCES
320
+ from crabcode_core.types.config import CrabCodeSettings
321
+ from pydantic import ValidationError
322
+
323
+ manager = ConfigManager(cwd=cwd)
324
+ merged: dict[str, Any] = {}
325
+ sources: list[str] = []
326
+ model_sources: dict[str, list[str]] = {}
327
+ group_sources: dict[str, list[str]] = {}
328
+
329
+ for source_name in SETTING_SOURCES:
330
+ raw = manager.get_settings_for_source(source_name)
331
+ if raw is None:
332
+ continue
333
+ relevant = {key: raw[key] for key in _MODEL_SETTING_KEYS if key in raw}
334
+ if not relevant:
335
+ continue
336
+ source_path = manager.settings_file_paths.get(source_name)
337
+ if source_path:
338
+ sources.append(source_path)
339
+ raw_models = raw.get("models")
340
+ if isinstance(raw_models, dict):
341
+ for model_name in raw_models:
342
+ model_sources.setdefault(str(model_name), []).append(source_path)
343
+ raw_groups_for_source = raw.get("groups")
344
+ if isinstance(raw_groups_for_source, dict):
345
+ for group_name in raw_groups_for_source:
346
+ group_sources.setdefault(str(group_name), []).append(source_path)
347
+ merged = _merge_model_settings(merged, relevant)
348
+
349
+ try:
350
+ settings = CrabCodeSettings.model_validate(merged)
351
+ except ValidationError as exc:
352
+ messages = []
353
+ for error in exc.errors(include_input=False, include_url=False)[:5]:
354
+ location = ".".join(str(part) for part in error.get("loc", ()))
355
+ message = str(error.get("msg", "invalid value"))
356
+ messages.append(f"{location}: {message}" if location else message)
357
+ raise HTTPException(
358
+ status_code=422,
359
+ detail="模型配置无效:" + "; ".join(messages),
360
+ ) from exc
361
+
362
+ raw_groups = merged.get("groups") if isinstance(merged.get("groups"), dict) else {}
363
+ raw_models = merged.get("models") if isinstance(merged.get("models"), dict) else {}
364
+ warnings: list[str] = []
365
+ entries: list[ModelSettingsEntry] = []
366
+
367
+ for name, configured_value in raw_models.items():
368
+ configured = configured_value if isinstance(configured_value, dict) else {}
369
+ group = configured.get("group") if isinstance(configured.get("group"), str) else None
370
+ if group and group not in raw_groups:
371
+ warnings.append(f"模型“{name}”引用了不存在的配置组“{group}”")
372
+ effective = settings.get_api_config(str(name)).model_dump(exclude_none=True)
373
+ entries.append(
374
+ ModelSettingsEntry(
375
+ name=str(name),
376
+ group=group,
377
+ is_default=str(name) == settings.default_model,
378
+ configured=_redact_model_settings(configured),
379
+ effective=_redact_model_settings(effective),
380
+ overridden_fields=[str(field) for field in configured if field != "group"],
381
+ sources=model_sources.get(str(name), []),
382
+ )
383
+ )
384
+
385
+ if settings.default_model and settings.default_model not in raw_models:
386
+ warnings.append(f"默认模型“{settings.default_model}”不存在")
387
+
388
+ editable_sources = []
389
+ for source_name, label in (
390
+ ("userSettings", "用户配置"),
391
+ ("projectSettings", "项目配置"),
392
+ ("localSettings", "项目本地配置"),
393
+ ):
394
+ source_path = manager.settings_file_paths.get(source_name)
395
+ if not source_path:
396
+ continue
397
+ path = Path(source_path)
398
+ editable_sources.append(
399
+ ModelSettingsSource(
400
+ id=source_name,
401
+ label=label,
402
+ path=str(path),
403
+ exists=path.is_file(),
404
+ writable=_is_writable_settings_path(path),
405
+ )
406
+ )
407
+
408
+ return ModelSettingsResponse(
409
+ cwd=cwd,
410
+ default_model=settings.default_model,
411
+ sources=sources,
412
+ groups=_redact_model_settings(raw_groups),
413
+ group_sources=group_sources,
414
+ models=entries,
415
+ warnings=warnings,
416
+ editable_sources=editable_sources,
417
+ )
418
+
419
+
420
+ def _runtime_settings_from_files(
421
+ cwd: str, pending: tuple[str, dict[str, Any]] | None = None,
422
+ ) -> RuntimeSettingsResponse:
423
+ """Read effective runtime settings by configuration layer."""
424
+ from crabcode_core.config.manager import SETTING_SOURCES, _merge_settings
425
+ from crabcode_core.permissions.manager import PermissionManager
426
+ from crabcode_core.types.config import CrabCodeSettings
427
+ from pydantic import ValidationError
428
+
429
+ manager = ConfigManager(cwd=cwd)
430
+ merged: dict[str, Any] = {}
431
+ sources: list[str] = []
432
+ extra_tools_by_source: dict[str, list[str]] = {}
433
+
434
+ for source_name in SETTING_SOURCES:
435
+ raw = (
436
+ pending[1] if pending is not None and pending[0] == source_name
437
+ else manager.get_settings_for_source(source_name)
438
+ )
439
+ if raw is None:
440
+ continue
441
+ relevant = {
442
+ key: raw[key]
443
+ for key in (
444
+ "snapshot", "computer_use", "extra_tools", "permissions",
445
+ "auto_compact_enabled", "compact_buffer_tokens", "max_context_length",
446
+ )
447
+ if key in raw
448
+ }
449
+ if not relevant:
450
+ continue
451
+ source_path = manager.settings_file_paths.get(source_name)
452
+ if source_path:
453
+ sources.append(source_path)
454
+ raw_tools = raw.get("extra_tools")
455
+ if isinstance(raw_tools, list):
456
+ extra_tools_by_source[source_name] = [
457
+ item for item in raw_tools if isinstance(item, str)
458
+ ]
459
+ merged = _merge_settings(merged, relevant)
460
+
461
+ try:
462
+ settings = CrabCodeSettings.model_validate(merged)
463
+ except ValidationError as exc:
464
+ messages = []
465
+ for error in exc.errors(include_input=False, include_url=False)[:5]:
466
+ location = ".".join(str(part) for part in error.get("loc", ()))
467
+ message = str(error.get("msg", "invalid value"))
468
+ messages.append(f"{location}: {message}" if location else message)
469
+ raise HTTPException(
470
+ status_code=422,
471
+ detail="运行与工具配置无效:" + "; ".join(messages),
472
+ ) from exc
473
+
474
+ editable_sources = []
475
+ for source_name, label in (
476
+ ("userSettings", "用户配置"),
477
+ ("projectSettings", "项目配置"),
478
+ ("localSettings", "项目本地配置"),
479
+ ):
480
+ source_path = manager.settings_file_paths.get(source_name)
481
+ if not source_path:
482
+ continue
483
+ path = Path(source_path)
484
+ editable_sources.append(
485
+ ModelSettingsSource(
486
+ id=source_name,
487
+ label=label,
488
+ path=str(path),
489
+ exists=path.is_file(),
490
+ writable=_is_writable_settings_path(path),
491
+ )
492
+ )
493
+
494
+ return RuntimeSettingsResponse(
495
+ cwd=cwd,
496
+ permission_policy=PermissionManager(settings.permissions).describe_policy(),
497
+ snapshot_enabled=settings.snapshot.enabled,
498
+ snapshot_max_size_mb=settings.snapshot.max_size_mb,
499
+ auto_compact_enabled=settings.auto_compact_enabled,
500
+ compact_buffer_tokens=settings.compact_buffer_tokens,
501
+ max_context_length=settings.max_context_length,
502
+ computer_use_mode=settings.computer_use.mode,
503
+ computer_use_target_scope=settings.computer_use.target_scope,
504
+ computer_use_delivery_policy=settings.computer_use.delivery_policy,
505
+ extra_tools=list(settings.extra_tools),
506
+ extra_tools_by_source=extra_tools_by_source,
507
+ sources=sources,
508
+ warnings=[],
509
+ editable_sources=editable_sources,
510
+ )
511
+
512
+
513
+ def _resolve_model_settings_cwd(request: Request, cwd: str | None) -> str:
514
+ resolved_cwd = os.getcwd()
515
+ if cwd:
516
+ from crabcode_gateway.routes.workspace import _resolve_directory, _workspace_roots
517
+
518
+ resolved_cwd = str(_resolve_directory(cwd, _workspace_roots(request)))
519
+ return resolved_cwd
520
+
521
+
522
+ def _validate_model_settings_name(name: str | None) -> str:
523
+ if not name or not name.strip():
524
+ raise HTTPException(status_code=400, detail="name is required")
525
+ value = name.strip()
526
+ if value in {".", ".."} or any(char in value for char in "/\\"):
527
+ raise HTTPException(status_code=400, detail="name must be a simple configuration name")
528
+ if len(value) > 120:
529
+ raise HTTPException(status_code=400, detail="name is too long")
530
+ return value
531
+
532
+
533
+ def _settings_mutation_path(cwd: str, source: str) -> Path:
534
+ manager = ConfigManager(cwd=cwd)
535
+ path_str = manager.settings_file_paths.get(source)
536
+ if not path_str or source in {"flagSettings", "policySettings"}:
537
+ raise HTTPException(status_code=400, detail="settings source is not writable")
538
+ path = Path(path_str)
539
+ # Project layers must remain inside the selected workspace. User settings
540
+ # intentionally live in the Gateway user's home directory.
541
+ if source in {"projectSettings", "localSettings"}:
542
+ expected_parent = (Path(cwd).resolve() / ".crabcode").resolve()
543
+ try:
544
+ path.parent.resolve().relative_to(expected_parent)
545
+ except ValueError as exc:
546
+ raise HTTPException(status_code=403, detail="settings source is outside the workspace") from exc
547
+ return path
548
+
549
+
550
+ def _read_settings_object(path: Path) -> dict[str, Any]:
551
+ if not path.exists():
552
+ return {}
553
+ try:
554
+ value = json.loads(read_utf8_text(path).text)
555
+ except (json.JSONDecodeError, OSError, UnicodeError) as exc:
556
+ raise HTTPException(status_code=422, detail="settings file is not valid JSON") from exc
557
+ if not isinstance(value, dict):
558
+ raise HTTPException(status_code=422, detail="settings file must contain a JSON object")
559
+ return value
560
+
561
+
562
+ def _atomic_write_settings(path: Path, value: dict[str, Any]) -> None:
563
+ payload = json.dumps(value, indent=2, ensure_ascii=False) + "\n"
564
+ newline = "\n"
565
+ has_bom = False
566
+ if path.exists():
567
+ source = read_utf8_text(path)
568
+ newline = source.newline or newline
569
+ has_bom = source.has_bom
570
+ payload = normalize_newlines(payload, newline)
571
+ raw_payload = payload.encode("utf-8")
572
+ if has_bom:
573
+ raw_payload = codecs.BOM_UTF8 + raw_payload
574
+ temporary: Path | None = None
575
+ try:
576
+ path.parent.mkdir(parents=True, exist_ok=True)
577
+ with tempfile.NamedTemporaryFile(
578
+ "wb",
579
+ dir=path.parent,
580
+ prefix=f".{path.name}.",
581
+ suffix=".tmp",
582
+ delete=False,
583
+ ) as handle:
584
+ temporary = Path(handle.name)
585
+ handle.write(raw_payload)
586
+ handle.flush()
587
+ os.fsync(handle.fileno())
588
+ if path.exists():
589
+ temporary.chmod(stat.S_IMODE(path.stat().st_mode))
590
+ replace_with_retry(temporary, path)
591
+ except OSError as exc:
592
+ if temporary is not None:
593
+ try:
594
+ temporary.unlink(missing_ok=True)
595
+ except OSError:
596
+ pass
597
+ raise HTTPException(status_code=403, detail="settings file is not writable") from exc
598
+
599
+
600
+ def _mutate_model_settings(request: Request, req: ModelSettingsMutationRequest) -> ModelSettingsResponse:
601
+ cwd = _resolve_model_settings_cwd(request, req.cwd)
602
+ path = _settings_mutation_path(cwd, req.source)
603
+ current = _read_settings_object(path)
604
+ action = req.action
605
+
606
+ if action in {"upsert_model", "delete_model"}:
607
+ name = _validate_model_settings_name(req.name)
608
+ models = current.get("models")
609
+ if models is None:
610
+ models = {}
611
+ current["models"] = models
612
+ if not isinstance(models, dict):
613
+ raise HTTPException(status_code=422, detail="models must be a JSON object")
614
+ if action == "delete_model":
615
+ if name not in models:
616
+ raise HTTPException(status_code=404, detail=f"模型“{name}”不在所选配置层中")
617
+ models.pop(name, None)
618
+ if not models:
619
+ current.pop("models", None)
620
+ if current.get("default_model") == name:
621
+ current["default_model"] = None
622
+ else:
623
+ config = req.config or {}
624
+ if not isinstance(config, dict):
625
+ raise HTTPException(status_code=422, detail="model config must be a JSON object")
626
+ existing = models.get(name, {})
627
+ if not isinstance(existing, dict):
628
+ existing = {}
629
+ merged = _merge_model_settings(existing, config)
630
+ for field_name in req.remove_fields:
631
+ if isinstance(field_name, str):
632
+ merged.pop(field_name, None)
633
+ try:
634
+ from crabcode_core.types.config import ApiConfig
635
+
636
+ ApiConfig.model_validate(merged)
637
+ except Exception as exc:
638
+ raise HTTPException(status_code=422, detail=f"模型配置无效:{exc}") from exc
639
+ models[name] = merged
640
+ if req.previous_name and req.previous_name.strip() != name:
641
+ models.pop(_validate_model_settings_name(req.previous_name), None)
642
+ elif action in {"upsert_group", "delete_group"}:
643
+ name = _validate_model_settings_name(req.name)
644
+ groups = current.get("groups")
645
+ if groups is None:
646
+ groups = {}
647
+ current["groups"] = groups
648
+ if not isinstance(groups, dict):
649
+ raise HTTPException(status_code=422, detail="groups must be a JSON object")
650
+ if action == "delete_group":
651
+ if name not in groups:
652
+ raise HTTPException(status_code=404, detail=f"配置组“{name}”不在所选配置层中")
653
+ groups.pop(name, None)
654
+ if not groups:
655
+ current.pop("groups", None)
656
+ else:
657
+ config = req.config or {}
658
+ existing = groups.get(name, {})
659
+ if not isinstance(existing, dict):
660
+ existing = {}
661
+ merged = _merge_model_settings(existing, config)
662
+ for field_name in req.remove_fields:
663
+ if isinstance(field_name, str):
664
+ merged.pop(field_name, None)
665
+ try:
666
+ from crabcode_core.types.config import ApiConfig
667
+
668
+ ApiConfig.model_validate(merged)
669
+ except Exception as exc:
670
+ raise HTTPException(status_code=422, detail=f"配置组无效:{exc}") from exc
671
+ groups[name] = merged
672
+ if req.previous_name and req.previous_name.strip() != name:
673
+ previous_name = _validate_model_settings_name(req.previous_name)
674
+ groups.pop(previous_name, None)
675
+ models = current.get("models")
676
+ if isinstance(models, dict):
677
+ for model_config in models.values():
678
+ if isinstance(model_config, dict) and model_config.get("group") == previous_name:
679
+ model_config["group"] = name
680
+ elif action == "set_default_model":
681
+ name = _validate_model_settings_name(req.name)
682
+ preview = _model_settings_from_files(cwd)
683
+ if not any(model.name == name for model in preview.models):
684
+ raise HTTPException(status_code=400, detail=f"模型“{name}”不存在")
685
+ current["default_model"] = name
686
+ elif action == "clear_default_model":
687
+ # Keep an explicit null in this layer so a lower-priority default does
688
+ # not silently become active again after the user clears it here.
689
+ current["default_model"] = None
690
+
691
+ _atomic_write_settings(path, current)
692
+ ConfigManager(cwd=cwd).reset_cache()
693
+ return _model_settings_from_files(cwd)
694
+
695
+
696
+ def _validate_extra_tool_path(tool_path: str | None) -> str:
697
+ if not tool_path or not tool_path.strip():
698
+ raise HTTPException(status_code=400, detail="tool_path is required")
699
+ value = tool_path.strip()
700
+ if any(char.isspace() for char in value) or len(value) > 240:
701
+ raise HTTPException(status_code=400, detail="tool_path must be a compact import path")
702
+ return value
703
+
704
+
705
+ def _mutate_runtime_settings(
706
+ request: Request,
707
+ req: RuntimeSettingsMutationRequest,
708
+ ) -> RuntimeSettingsResponse:
709
+ cwd = _resolve_model_settings_cwd(request, req.cwd)
710
+ path = _settings_mutation_path(cwd, req.source)
711
+ current = _read_settings_object(path)
712
+
713
+ if req.action == "set_compaction":
714
+ if req.auto_compact_enabled is not None:
715
+ current["auto_compact_enabled"] = req.auto_compact_enabled
716
+ if req.compact_buffer_tokens is not None:
717
+ current["compact_buffer_tokens"] = req.compact_buffer_tokens
718
+ if "max_context_length" in req.model_fields_set:
719
+ # Explicit null clears the earlier threshold, including an inherited one.
720
+ current["max_context_length"] = req.max_context_length
721
+ _runtime_settings_from_files(cwd, pending=(req.source, current))
722
+ elif req.action == "set_snapshot":
723
+ snapshot = current.get("snapshot")
724
+ if snapshot is None:
725
+ snapshot = {}
726
+ current["snapshot"] = snapshot
727
+ if not isinstance(snapshot, dict):
728
+ raise HTTPException(status_code=422, detail="snapshot must be a JSON object")
729
+ if req.snapshot_enabled is not None:
730
+ snapshot["enabled"] = req.snapshot_enabled
731
+ if req.snapshot_max_size_mb is not None:
732
+ snapshot["max_size_mb"] = req.snapshot_max_size_mb
733
+ try:
734
+ from crabcode_core.types.config import SnapshotSettings
735
+
736
+ SnapshotSettings.model_validate(snapshot)
737
+ except Exception as exc:
738
+ raise HTTPException(status_code=422, detail=f"快照配置无效:{exc}") from exc
739
+ elif req.action in {"set_computer_use_mode", "set_computer_use_options"}:
740
+ computer_use = current.get("computer_use")
741
+ if computer_use is None:
742
+ computer_use = {}
743
+ current["computer_use"] = computer_use
744
+ if not isinstance(computer_use, dict):
745
+ raise HTTPException(status_code=422, detail="computer_use must be a JSON object")
746
+ if req.computer_use_mode is not None:
747
+ computer_use["target_scope"] = (
748
+ "desktop" if req.computer_use_mode == "foreground_desktop" else "app_window"
749
+ )
750
+ if req.computer_use_target_scope is not None:
751
+ computer_use["target_scope"] = req.computer_use_target_scope
752
+ if req.computer_use_delivery_policy is not None:
753
+ computer_use["delivery_policy"] = req.computer_use_delivery_policy
754
+ if "mode" in computer_use:
755
+ computer_use.setdefault(
756
+ "target_scope", "desktop" if computer_use["mode"] == "foreground_desktop" else "app_window"
757
+ )
758
+ computer_use.pop("mode")
759
+ try:
760
+ from crabcode_core.types.config import ComputerUseSettings
761
+
762
+ ComputerUseSettings.model_validate(computer_use)
763
+ except Exception as exc:
764
+ raise HTTPException(status_code=422, detail=f"Invalid Computer Use configuration: {exc}") from exc
765
+ else:
766
+ tool_path = _validate_extra_tool_path(req.tool_path)
767
+ extra_tools = current.get("extra_tools")
768
+ if extra_tools is None:
769
+ extra_tools = []
770
+ current["extra_tools"] = extra_tools
771
+ if (
772
+ not isinstance(extra_tools, list)
773
+ or any(not isinstance(item, str) for item in extra_tools)
774
+ ):
775
+ raise HTTPException(status_code=422, detail="extra_tools must be a list of import paths")
776
+ if req.action == "add_extra_tool":
777
+ if tool_path not in extra_tools:
778
+ extra_tools.append(tool_path)
779
+ else:
780
+ extra_tools[:] = [item for item in extra_tools if item != tool_path]
781
+ if not extra_tools:
782
+ current.pop("extra_tools", None)
783
+
784
+ if req.action in {"set_computer_use_mode", "set_computer_use_options"}:
785
+ effective = _runtime_settings_from_files(cwd, pending=(req.source, current))
786
+ if (
787
+ effective.computer_use_target_scope == "desktop"
788
+ and effective.computer_use_delivery_policy == "strict_background"
789
+ ):
790
+ raise HTTPException(
791
+ status_code=422,
792
+ detail="Desktop scope requires allow_foreground; strict_background supports app-window scope only.",
793
+ )
794
+ _atomic_write_settings(path, current)
795
+ ConfigManager(cwd=cwd).reset_cache()
796
+ return _runtime_settings_from_files(cwd)
797
+
798
+
799
+ def _prompt_source_map(manager: ConfigManager, key: str) -> dict[str, str]:
800
+ from crabcode_core.config.manager import SETTING_SOURCES
801
+
802
+ sources: dict[str, str] = {}
803
+ for source_name in SETTING_SOURCES:
804
+ raw = manager.get_settings_for_source(source_name) or {}
805
+ items = raw.get(key)
806
+ if not isinstance(items, list):
807
+ continue
808
+ for item in items:
809
+ if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].strip():
810
+ sources[item["id"].strip()] = source_name
811
+ return sources
812
+
813
+
814
+ def _editable_prompt_sources(cwd: str) -> list[ModelSettingsSource]:
815
+ manager = ConfigManager(cwd=cwd)
816
+ editable_sources = []
817
+ for source_name, label in (
818
+ ("userSettings", "用户配置"),
819
+ ("projectSettings", "项目配置"),
820
+ ("localSettings", "项目本地配置"),
821
+ ):
822
+ source_path = manager.settings_file_paths.get(source_name)
823
+ if not source_path:
824
+ continue
825
+ path = Path(source_path)
826
+ editable_sources.append(
827
+ ModelSettingsSource(
828
+ id=source_name,
829
+ label=label,
830
+ path=str(path),
831
+ exists=path.is_file(),
832
+ writable=_is_writable_settings_path(path),
833
+ )
834
+ )
835
+ return editable_sources
836
+
837
+
838
+ def _prompt_settings_from_files(cwd: str) -> PromptSettingsResponse:
839
+ from crabcode_core.prompts.library import PROMPT_SECTIONS, last_by_id
840
+ from crabcode_core.prompts.system import get_default_prompt_sections
841
+
842
+ default_sections = get_default_prompt_sections()
843
+ manager = ConfigManager(cwd=cwd)
844
+ settings = manager.load()
845
+ templates = last_by_id(list(settings.prompt_templates))
846
+ user_prompts = last_by_id(list(settings.user_append_prompts))
847
+ template_sources = _prompt_source_map(manager, "prompt_templates")
848
+ prompt_sources = _prompt_source_map(manager, "user_append_prompts")
849
+ template_ids = {item.id for item in templates}
850
+ active = settings.active_prompt_template
851
+ warnings: list[str] = []
852
+ if active and active not in template_ids:
853
+ warnings.append("已选择的提示词模版不存在,已回退为默认。")
854
+ active = None
855
+ return PromptSettingsResponse(
856
+ cwd=cwd,
857
+ active_template_id=active,
858
+ templates=[
859
+ PromptTemplateView(
860
+ id=item.id,
861
+ name=item.name,
862
+ sections=dict(item.sections),
863
+ source=template_sources.get(item.id, "userSettings"),
864
+ )
865
+ for item in templates
866
+ ],
867
+ user_prompts=[
868
+ UserAppendPromptView(
869
+ id=item.id,
870
+ text=item.text,
871
+ enabled=item.enabled,
872
+ source=prompt_sources.get(item.id, "userSettings"),
873
+ )
874
+ for item in user_prompts
875
+ ],
876
+ sections=[
877
+ PromptSectionInfo(
878
+ key=key,
879
+ label=label,
880
+ description=(
881
+ "用于手动和自动压缩,随模版生效。留空使用内置默认;/compact 的临时要求会继续追加。"
882
+ if key == "compact_prompt" else None
883
+ ),
884
+ default_text=default_sections.get(key),
885
+ )
886
+ for key, label in PROMPT_SECTIONS
887
+ ],
888
+ warnings=warnings,
889
+ editable_sources=_editable_prompt_sources(cwd),
890
+ )
891
+
892
+
893
+ def _stored_prompt_items(current: dict[str, Any], key: str) -> list[dict[str, Any]]:
894
+ items = current.get(key)
895
+ if items is None:
896
+ items = []
897
+ current[key] = items
898
+ if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
899
+ raise HTTPException(status_code=422, detail=f"{key} must be a list of objects")
900
+ return items
901
+
902
+
903
+ def _clean_prompt_sections(sections: dict[str, str] | None) -> dict[str, str]:
904
+ from crabcode_core.prompts.library import PROMPT_SECTION_KEYS
905
+
906
+ cleaned: dict[str, str] = {}
907
+ for key, value in (sections or {}).items():
908
+ if key not in PROMPT_SECTION_KEYS or not isinstance(value, str):
909
+ continue
910
+ text = value.strip()
911
+ if not text:
912
+ continue
913
+ if len(text) > 20000:
914
+ raise HTTPException(status_code=422, detail=f"{key} is too long")
915
+ cleaned[key] = text
916
+ return cleaned
917
+
918
+
919
+ _PROMPT_LAYERS = ("localSettings", "projectSettings", "userSettings")
920
+
921
+
922
+ def _layers_containing_prompt(cwd: str, key: str, item_id: str) -> list[tuple[str, Path, dict[str, Any], list[Any]]]:
923
+ found = []
924
+ for source in _PROMPT_LAYERS:
925
+ path = _settings_mutation_path(cwd, source)
926
+ if not path.is_file():
927
+ continue
928
+ current = _read_settings_object(path)
929
+ items = current.get(key)
930
+ if not isinstance(items, list):
931
+ continue
932
+ if any(isinstance(item, dict) and item.get("id") == item_id for item in items):
933
+ found.append((source, path, current, items))
934
+ return found
935
+
936
+
937
+ def _remove_prompt_id(cwd: str, key: str, item_id: str, *, clear_active: bool) -> None:
938
+ layers = _layers_containing_prompt(cwd, key, item_id)
939
+ if not layers:
940
+ label = "模版" if key == "prompt_templates" else "用户提示"
941
+ raise HTTPException(status_code=404, detail=f"找不到要删除的{label}")
942
+ for _source, path, current, items in layers:
943
+ kept = [item for item in items if not (isinstance(item, dict) and item.get("id") == item_id)]
944
+ if kept:
945
+ current[key] = kept
946
+ else:
947
+ current.pop(key, None)
948
+ if clear_active and current.get("active_prompt_template") == item_id:
949
+ current["active_prompt_template"] = None
950
+ _atomic_write_settings(path, current)
951
+
952
+
953
+ def _set_user_prompt_enabled(cwd: str, prompt_id: str, enabled: bool) -> None:
954
+ layers = _layers_containing_prompt(cwd, "user_append_prompts", prompt_id)
955
+ if not layers:
956
+ raise HTTPException(status_code=404, detail="找不到要更新的用户提示")
957
+ for _source, path, current, items in layers:
958
+ for item in items:
959
+ if isinstance(item, dict) and item.get("id") == prompt_id:
960
+ item["enabled"] = enabled
961
+ current["user_append_prompts"] = items
962
+ _atomic_write_settings(path, current)
963
+
964
+
965
+ def _mutate_prompt_settings(
966
+ request: Request,
967
+ req: PromptSettingsMutationRequest,
968
+ ) -> PromptSettingsResponse:
969
+ import uuid
970
+
971
+ from crabcode_core.types.config import PromptTemplateConfig, UserAppendPromptConfig
972
+
973
+ cwd = _resolve_model_settings_cwd(request, req.cwd)
974
+ path = _settings_mutation_path(cwd, req.source)
975
+ current = _read_settings_object(path)
976
+
977
+ if req.action == "save_template":
978
+ name = req.template_name or ""
979
+ if name == "默认":
980
+ raise HTTPException(status_code=400, detail="模版名称不能是「默认」")
981
+ sections = _clean_prompt_sections(req.sections)
982
+ template_id = (req.template_id or "").strip() or uuid.uuid4().hex
983
+ preview = _prompt_settings_from_files(cwd)
984
+ if any(item.name == name and item.id != template_id for item in preview.templates):
985
+ raise HTTPException(status_code=409, detail=f"模版「{name}」已存在")
986
+ try:
987
+ stored = PromptTemplateConfig(id=template_id, name=name, sections=sections).model_dump()
988
+ except Exception as exc:
989
+ raise HTTPException(status_code=422, detail=f"提示词模版无效:{exc}") from exc
990
+ templates = _stored_prompt_items(current, "prompt_templates")
991
+ if len(templates) >= 40 and all(item.get("id") != template_id for item in templates):
992
+ raise HTTPException(status_code=422, detail="提示词模版数量已达上限")
993
+ replaced = False
994
+ for index, item in enumerate(templates):
995
+ if item.get("id") == template_id:
996
+ templates[index] = stored
997
+ replaced = True
998
+ break
999
+ if not replaced:
1000
+ templates.append(stored)
1001
+ current["active_prompt_template"] = template_id
1002
+ elif req.action == "delete_template":
1003
+ _remove_prompt_id(cwd, "prompt_templates", req.template_id or "", clear_active=True)
1004
+ ConfigManager(cwd=cwd).reset_cache()
1005
+ return _prompt_settings_from_files(cwd)
1006
+ elif req.action == "set_active_template":
1007
+ template_id = req.template_id
1008
+ if template_id:
1009
+ preview = _prompt_settings_from_files(cwd)
1010
+ if template_id not in {item.id for item in preview.templates}:
1011
+ raise HTTPException(status_code=400, detail="提示词模版不存在")
1012
+ current["active_prompt_template"] = template_id
1013
+ elif req.action == "add_user_prompt":
1014
+ prompts = _stored_prompt_items(current, "user_append_prompts")
1015
+ if len(prompts) >= 80:
1016
+ raise HTTPException(status_code=422, detail="用户提示数量已达上限")
1017
+ try:
1018
+ stored = UserAppendPromptConfig(
1019
+ id=uuid.uuid4().hex,
1020
+ text=req.prompt_text or "",
1021
+ enabled=True,
1022
+ ).model_dump()
1023
+ except Exception as exc:
1024
+ raise HTTPException(status_code=422, detail=f"用户提示无效:{exc}") from exc
1025
+ prompts.append(stored)
1026
+ elif req.action == "set_user_prompt_enabled":
1027
+ _set_user_prompt_enabled(cwd, req.prompt_id or "", bool(req.enabled))
1028
+ ConfigManager(cwd=cwd).reset_cache()
1029
+ return _prompt_settings_from_files(cwd)
1030
+ else:
1031
+ _remove_prompt_id(cwd, "user_append_prompts", req.prompt_id or "", clear_active=False)
1032
+ ConfigManager(cwd=cwd).reset_cache()
1033
+ return _prompt_settings_from_files(cwd)
1034
+
1035
+ _atomic_write_settings(path, current)
1036
+ ConfigManager(cwd=cwd).reset_cache()
1037
+ return _prompt_settings_from_files(cwd)
1038
+
1039
+
1040
+ def _reload_prompt_settings_for_source(request: Request, cwd: str, source: str) -> None:
1041
+ sessions = getattr(request.app.state, "sessions", None)
1042
+ if not isinstance(sessions, dict):
1043
+ return
1044
+ changed_path = _settings_mutation_path(cwd, source).resolve()
1045
+ for session in sessions.values():
1046
+ session_cwd = getattr(session, "cwd", None)
1047
+ if not isinstance(session_cwd, str) or not session_cwd:
1048
+ continue
1049
+ session_path = ConfigManager(cwd=session_cwd).settings_file_paths.get(source)
1050
+ if not session_path or Path(session_path).resolve() != changed_path:
1051
+ continue
1052
+ reload_prompt = getattr(session, "reload_prompt_settings", None)
1053
+ if callable(reload_prompt):
1054
+ reload_prompt()
1055
+
1056
+
1057
+ @router.get("/config/prompt-settings", response_model=PromptSettingsResponse)
1058
+ async def get_prompt_settings(
1059
+ request: Request,
1060
+ cwd: str | None = None,
1061
+ ) -> PromptSettingsResponse:
1062
+ """Inspect prompt templates and prompts that can be appended to user input."""
1063
+ return _prompt_settings_from_files(_resolve_model_settings_cwd(request, cwd))
1064
+
1065
+
1066
+ @router.post("/config/prompt-settings", response_model=PromptSettingsResponse)
1067
+ async def mutate_prompt_settings(
1068
+ req: PromptSettingsMutationRequest,
1069
+ request: Request,
1070
+ ) -> PromptSettingsResponse:
1071
+ """Save a prompt template or change which user prompts are appended."""
1072
+ lock = getattr(request.app.state, "model_settings_lock", None)
1073
+ if lock is None:
1074
+ lock = asyncio.Lock()
1075
+ request.app.state.model_settings_lock = lock
1076
+ async with lock:
1077
+ async with get_session_lock(request.app.state):
1078
+ result = _mutate_prompt_settings(request, req)
1079
+ _reload_prompt_settings_for_source(request, result.cwd, req.source)
1080
+ return result
1081
+
1082
+
1083
+ @router.get("/config/models", response_model=list[ModelInfo])
1084
+ async def list_models(
1085
+ request: Request,
1086
+ session_id: str | None = None,
1087
+ cwd: str | None = None,
1088
+ ) -> list[ModelInfo]:
1089
+ """List available named models.
1090
+
1091
+ An explicit cwd reads that project's catalog independently of sessions.
1092
+ Legacy callers try the active session first, then process-cwd settings.
1093
+ """
1094
+ if cwd is not None:
1095
+ if session_id is not None:
1096
+ raise HTTPException(status_code=400, detail="Specify cwd or session_id, not both")
1097
+ return _list_models_from_settings(_resolve_model_settings_cwd(request, cwd))
1098
+ async with get_session_lock(request.app.state):
1099
+ session = _get_session(request, session_id)
1100
+ if session_id is not None and session is None:
1101
+ raise HTTPException(status_code=404, detail="Session not found")
1102
+ if session:
1103
+ models = dict(session.list_models())
1104
+ else:
1105
+ models = None
1106
+ if models is not None:
1107
+ session_settings = getattr(session, "settings", None)
1108
+ return [
1109
+ ModelInfo(
1110
+ name=name,
1111
+ description=desc,
1112
+ group=(
1113
+ getattr(session_settings.get_api_config(name), "group", None)
1114
+ if session_settings is not None
1115
+ and hasattr(session_settings, "get_api_config")
1116
+ else None
1117
+ )
1118
+ or "default",
1119
+ )
1120
+ for name, desc in models.items()
1121
+ ]
1122
+ return _list_models_from_settings()
1123
+
1124
+
1125
+ @router.get("/config/model-settings", response_model=ModelSettingsResponse)
1126
+ async def get_model_settings(
1127
+ request: Request,
1128
+ cwd: str | None = None,
1129
+ ) -> ModelSettingsResponse:
1130
+ """Inspect named model settings and available mutation layers."""
1131
+ return _model_settings_from_files(_resolve_model_settings_cwd(request, cwd))
1132
+
1133
+
1134
+ @router.post("/config/model-settings", response_model=ModelSettingsResponse)
1135
+ async def mutate_model_settings(
1136
+ req: ModelSettingsMutationRequest,
1137
+ request: Request,
1138
+ ) -> ModelSettingsResponse:
1139
+ """Create, update, or remove a named model/group configuration."""
1140
+ lock = getattr(request.app.state, "model_settings_lock", None)
1141
+ if lock is None:
1142
+ lock = asyncio.Lock()
1143
+ request.app.state.model_settings_lock = lock
1144
+ async with lock:
1145
+ return _mutate_model_settings(request, req)
1146
+
1147
+
1148
+ @router.get("/config/runtime-settings", response_model=RuntimeSettingsResponse)
1149
+ async def get_runtime_settings(
1150
+ request: Request,
1151
+ cwd: str | None = None,
1152
+ ) -> RuntimeSettingsResponse:
1153
+ """Inspect runtime settings and available mutation layers."""
1154
+ return _runtime_settings_from_files(_resolve_model_settings_cwd(request, cwd))
1155
+
1156
+
1157
+ @router.post("/config/runtime-settings", response_model=RuntimeSettingsResponse)
1158
+ async def mutate_runtime_settings(
1159
+ req: RuntimeSettingsMutationRequest,
1160
+ request: Request,
1161
+ ) -> RuntimeSettingsResponse:
1162
+ """Update runtime settings in a selected layer."""
1163
+ lock = getattr(request.app.state, "model_settings_lock", None)
1164
+ if lock is None:
1165
+ lock = asyncio.Lock()
1166
+ request.app.state.model_settings_lock = lock
1167
+ async with lock:
1168
+ if req.action not in {"set_compaction", "set_computer_use_mode", "set_computer_use_options"}:
1169
+ return _mutate_runtime_settings(request, req)
1170
+
1171
+ # Session registration uses this lock too. Keep the file write and
1172
+ # in-memory refresh together so a newly registered session cannot
1173
+ # retain settings read just before the write.
1174
+ async with get_session_lock(request.app.state):
1175
+ result = _mutate_runtime_settings(request, req)
1176
+ changed_path = _settings_mutation_path(result.cwd, req.source).resolve()
1177
+ for session in request.app.state.sessions.values():
1178
+ session_path = ConfigManager(cwd=session.cwd).settings_file_paths.get(req.source)
1179
+ if session_path and Path(session_path).resolve() == changed_path:
1180
+ if req.action == "set_compaction":
1181
+ session.reload_compaction_settings()
1182
+ else:
1183
+ session.reload_computer_use_settings()
1184
+ return result
1185
+
1186
+
1187
+ @router.post("/config/switch-model")
1188
+ async def switch_model(req: SwitchModelRequest, request: Request):
1189
+ """Switch to a named model."""
1190
+ session = _get_session(request, req.session_id)
1191
+ if not session:
1192
+ raise HTTPException(status_code=404, detail="Session not found")
1193
+
1194
+ try:
1195
+ ok = await run_session_operation(
1196
+ request.app.state,
1197
+ session,
1198
+ lambda: _switch_model(session, req.name),
1199
+ )
1200
+ except SessionOperationRejected as exc:
1201
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1202
+ if not ok:
1203
+ raise HTTPException(status_code=400, detail=f"Model '{req.name}' not found")
1204
+ return {"status": "ok"}
1205
+
1206
+
1207
+ @router.post("/config/switch-mode")
1208
+ async def switch_mode(req: SwitchModeRequest, request: Request):
1209
+ """Switch between agent and plan mode."""
1210
+ session = _get_session(request, req.session_id)
1211
+ if not session:
1212
+ raise HTTPException(status_code=404, detail="Session not found")
1213
+
1214
+ try:
1215
+ ok = await run_session_operation(
1216
+ request.app.state,
1217
+ session,
1218
+ lambda: _switch_mode(session, req.mode),
1219
+ )
1220
+ except SessionOperationRejected as exc:
1221
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1222
+ if not ok:
1223
+ raise HTTPException(status_code=400, detail=f"Invalid mode '{req.mode}'")
1224
+ return {"status": "ok", "mode": req.mode}
1225
+
1226
+
1227
+ @router.post("/config/reasoning-effort")
1228
+ async def set_reasoning_effort(req: SetReasoningEffortRequest, request: Request):
1229
+ """Set the active session's reasoning effort for subsequent requests."""
1230
+ session = _get_session(request, req.session_id)
1231
+ if not session:
1232
+ raise HTTPException(status_code=404, detail="Session not found")
1233
+
1234
+ try:
1235
+ ok = await run_session_operation(
1236
+ request.app.state,
1237
+ session,
1238
+ lambda: _set_reasoning_effort(session, req.effort),
1239
+ )
1240
+ except SessionOperationRejected as exc:
1241
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1242
+ if not ok:
1243
+ raise HTTPException(status_code=400, detail=f"Invalid reasoning effort '{req.effort}'")
1244
+ return {"status": "ok", "reasoning_effort": session.reasoning_effort}
1245
+
1246
+
1247
+ @router.post("/config/ultra-mode")
1248
+ async def set_ultra_mode(req: SetUltraModeRequest, request: Request):
1249
+ """Set ultra mode, or toggle it when ``enabled`` is omitted."""
1250
+ session = _get_session(request, req.session_id)
1251
+ if not session:
1252
+ raise HTTPException(status_code=404, detail="Session not found")
1253
+
1254
+ try:
1255
+ enabled = await run_session_operation(
1256
+ request.app.state,
1257
+ session,
1258
+ lambda: _set_ultra_mode(session, req.enabled),
1259
+ )
1260
+ except SessionOperationRejected as exc:
1261
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1262
+ return {"status": "ok", "ultra_mode": enabled}
1263
+
1264
+
1265
+ @router.post("/config/permission-mode")
1266
+ async def set_permission_mode(req: SetPermissionModeRequest, request: Request):
1267
+ """Set the per-client tool permission override for a session."""
1268
+ session = _get_session(request, req.session_id)
1269
+ if not session:
1270
+ raise HTTPException(status_code=404, detail="Session not found")
1271
+ try:
1272
+ ok = await run_session_operation(
1273
+ request.app.state,
1274
+ session,
1275
+ lambda: _set_permission_mode(session, req.mode),
1276
+ )
1277
+ except SessionOperationRejected as exc:
1278
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1279
+ if not ok:
1280
+ raise HTTPException(status_code=400, detail=f"Invalid permission mode '{req.mode}'")
1281
+ return {"status": "ok", "permission_mode": getattr(session, "client_permission_mode", req.mode)}
1282
+
1283
+
1284
+ @router.get("/config/goal", response_model=GoalState)
1285
+ async def get_goal(request: Request, session_id: str | None = None) -> GoalState:
1286
+ """Return the current session goal."""
1287
+ async with get_session_lock(request.app.state):
1288
+ session = _get_session(request, session_id)
1289
+ if not session:
1290
+ raise HTTPException(status_code=404, detail="Session not found")
1291
+ goal = session.get_goal()
1292
+ data = goal.to_dict() if goal is not None else None
1293
+ return GoalState(goal=data)
1294
+
1295
+
1296
+ @router.post("/config/goal", response_model=GoalState)
1297
+ async def manage_goal(req: GoalRequest, request: Request) -> GoalState:
1298
+ """Set, edit, pause, resume, finish, block, or clear a session goal."""
1299
+ session = _get_session(request, req.session_id)
1300
+ if not session:
1301
+ raise HTTPException(status_code=404, detail="Session not found")
1302
+ try:
1303
+ data = await run_session_operation(
1304
+ request.app.state,
1305
+ session,
1306
+ lambda: _manage_goal(session, req),
1307
+ )
1308
+ except SessionOperationRejected as exc:
1309
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1310
+ except (RuntimeError, ValueError) as exc:
1311
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1312
+ return GoalState(goal=data)
1313
+
1314
+
1315
+ @router.get("/tools", response_model=list[ToolInfo])
1316
+ async def list_tools(
1317
+ request: Request,
1318
+ session_id: str | None = None,
1319
+ cwd: str | None = None,
1320
+ ) -> list[ToolInfo]:
1321
+ """List enabled tools for the active session or a workspace."""
1322
+ async with get_session_lock(request.app.state):
1323
+ session = _get_session(request, session_id)
1324
+ if session_id is not None and session is None:
1325
+ raise HTTPException(status_code=404, detail="Session not found")
1326
+
1327
+ if session:
1328
+ async def _list_initialized_tools() -> list[ToolInfo]:
1329
+ initializer = getattr(session, "initialize", None)
1330
+ if callable(initializer):
1331
+ await initializer()
1332
+ return [
1333
+ ToolInfo(
1334
+ name=t.name,
1335
+ description=t.description or "",
1336
+ is_read_only=t.is_read_only,
1337
+ is_enabled=t.is_enabled,
1338
+ is_loaded=(t.name in session.last_prompt_budget.get("loaded_names", [])
1339
+ if getattr(session, "last_prompt_budget", None) else None),
1340
+ )
1341
+ for t in session.tools
1342
+ if bool(getattr(t, "is_enabled", True))
1343
+ ]
1344
+
1345
+ try:
1346
+ return await run_session_operation(
1347
+ request.app.state,
1348
+ session,
1349
+ _list_initialized_tools,
1350
+ )
1351
+ except SessionOperationRejected as exc:
1352
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1353
+
1354
+ # Plugin discovery is also used before the first chat is opened. Return
1355
+ # the built-in registry in that case; session initialization can still add
1356
+ # MCP and project-specific tools later.
1357
+ from crabcode_core.tools import get_default_tools
1358
+
1359
+ return [
1360
+ ToolInfo(
1361
+ name=t.name,
1362
+ description=t.description or "",
1363
+ is_read_only=t.is_read_only,
1364
+ is_enabled=t.is_enabled,
1365
+ )
1366
+ for t in get_default_tools()
1367
+ if bool(getattr(t, "is_enabled", True))
1368
+ ]
1369
+
1370
+
1371
+ @router.get("/skills", response_model=list[SkillInfo])
1372
+ async def list_skills(
1373
+ request: Request,
1374
+ session_id: str | None = None,
1375
+ cwd: str | None = None,
1376
+ ) -> list[SkillInfo]:
1377
+ """List all skills visible from the current working directory."""
1378
+ async with get_session_lock(request.app.state):
1379
+ session = _get_session(request, session_id)
1380
+ if session_id is not None and session is None:
1381
+ raise HTTPException(status_code=404, detail="Session not found")
1382
+ session_cwd = getattr(session, "cwd", None) if session else None
1383
+ if not cwd and session and hasattr(session, "skills") and session.skills:
1384
+ skills = [
1385
+ SkillInfo(name=s.name, description=s.description or "")
1386
+ for s in session.skills
1387
+ ]
1388
+ else:
1389
+ skills = None
1390
+ if skills is not None:
1391
+ return skills
1392
+ # Fallback: load from cwd when no session is active yet
1393
+ import os
1394
+
1395
+ skill_cwd = cwd or session_cwd or os.getcwd()
1396
+ if cwd:
1397
+ from crabcode_gateway.routes.workspace import _resolve_directory, _workspace_roots
1398
+
1399
+ skill_cwd = str(_resolve_directory(cwd, _workspace_roots(request)))
1400
+ skills = load_skills(skill_cwd)
1401
+ return [SkillInfo(name=s.name, description=s.description or "") for s in skills]
1402
+
1403
+
1404
+ @router.post("/skills/expand", response_model=SkillExpansion)
1405
+ async def expand_skill(req: SkillExpandRequest, request: Request) -> SkillExpansion:
1406
+ """Expand a slash-invoked skill deterministically, matching the CLI."""
1407
+ async with get_session_lock(request.app.state):
1408
+ session = _get_session(request, req.session_id)
1409
+ if req.session_id is not None and session is None:
1410
+ raise HTTPException(status_code=404, detail="Session not found")
1411
+ skills = list(getattr(session, "skills", ())) if session else []
1412
+ cwd = getattr(session, "cwd", None) if session else None
1413
+
1414
+ if not skills:
1415
+ import os
1416
+
1417
+ skills = load_skills(cwd or os.getcwd())
1418
+ skill = next((item for item in skills if item.name == req.name), None)
1419
+ if skill is None:
1420
+ raise HTTPException(status_code=404, detail=f"Skill {req.name} not found")
1421
+
1422
+ prompt = skill.content
1423
+ user_input = req.user_input.strip()
1424
+ if user_input:
1425
+ if "$USER_INPUT" in prompt:
1426
+ prompt = prompt.replace("$USER_INPUT", user_input)
1427
+ else:
1428
+ prompt = f"{prompt}\n\nUser input: {user_input}"
1429
+ return SkillExpansion(name=skill.name, prompt=prompt)
1430
+
1431
+
1432
+ @router.post("/context")
1433
+ async def push_context(req: ContextPushRequest, request: Request):
1434
+ """Push workspace context from a client (e.g. VSCode extension).
1435
+
1436
+ The gateway stores this per-session so that it can be injected
1437
+ into the system prompt or tool context as needed.
1438
+ """
1439
+ session = _get_session(request, req.session_id)
1440
+ if session is None:
1441
+ raise HTTPException(status_code=404, detail="Session not found")
1442
+ try:
1443
+ await run_session_operation(
1444
+ request.app.state,
1445
+ session,
1446
+ lambda: _store_context(request, session, req),
1447
+ )
1448
+ except SessionOperationRejected as exc:
1449
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1450
+ return {"status": "ok"}
1451
+
1452
+
1453
+ @router.get("/context/{session_id}")
1454
+ async def get_context(session_id: str, request: Request):
1455
+ """Retrieve the current client-pushed context for a session."""
1456
+ async with get_session_lock(request.app.state):
1457
+ contexts: dict = request.app.state.client_contexts
1458
+ sessions: dict = request.app.state.sessions
1459
+ if (
1460
+ session_id not in sessions
1461
+ or session_id in getattr(request.app.state, "closing_sessions", set())
1462
+ ):
1463
+ raise HTTPException(status_code=404, detail="Session not found")
1464
+ context = contexts.get(session_id)
1465
+ if context is not None:
1466
+ # Context payloads are plain dictionaries but may contain nested
1467
+ # client-owned lists; copy the outer mapping so archive/updates do
1468
+ # not mutate the response while it is serialized.
1469
+ context = dict(context)
1470
+ if context is None:
1471
+ return {"active_file": None, "selected_text": None, "open_files": []}
1472
+ return context
1473
+
1474
+
1475
+ @router.get("/config/plan-status")
1476
+ async def plan_status(request: Request):
1477
+ """Return the current plan mode status and plan content if available."""
1478
+ async with get_session_lock(request.app.state):
1479
+ session = _get_session(request, request.query_params.get("session_id"))
1480
+ if not session:
1481
+ raise HTTPException(status_code=404, detail="Session not found")
1482
+ mode = getattr(session, "agent_mode", getattr(session, "mode", "agent"))
1483
+ plan = getattr(session, "current_plan", None)
1484
+ if isinstance(plan, dict):
1485
+ plan = dict(plan)
1486
+ return {
1487
+ "mode": mode,
1488
+ "in_plan_mode": mode == "plan",
1489
+ "plan": plan,
1490
+ }
1491
+
1492
+
1493
+ def _logs_cwd(request: Request, session_id: str | None) -> Path:
1494
+ sessions = getattr(request.app.state, "sessions", {})
1495
+ sid = getattr(request.app.state, "default_session_id", None) if session_id is None else session_id
1496
+ session = sessions.get(sid) if sid else None
1497
+ if (
1498
+ session is not None
1499
+ and sid not in getattr(request.app.state, "closing_sessions", set())
1500
+ and not getattr(request.app.state, "gateway_closing", False)
1501
+ ):
1502
+ return Path(getattr(session, "cwd", os.getcwd())).resolve()
1503
+ if session_id is not None:
1504
+ raise HTTPException(status_code=404, detail="Session not found")
1505
+ return Path(os.getcwd()).resolve()
1506
+
1507
+
1508
+ def _discover_logs(cwd: Path) -> dict[str, Path]:
1509
+ """Read the shared log index used by core and background tools."""
1510
+ result: dict[str, Path] = {}
1511
+ lexical_root = cwd / ".crabcode" / "logs"
1512
+ try:
1513
+ logs_root = lexical_root.resolve()
1514
+ # A repository-controlled symlink must not turn the log index into a
1515
+ # capability for files outside the dedicated project log directory.
1516
+ root_is_safe = logs_root == lexical_root.absolute()
1517
+ except OSError:
1518
+ logs_root = lexical_root
1519
+ root_is_safe = False
1520
+
1521
+ raw: Any = {}
1522
+ if root_is_safe:
1523
+ index_path = logs_root / "index.json"
1524
+ try:
1525
+ with _open_regular_log(index_path, os.O_RDONLY) as handle:
1526
+ raw = json.load(handle)
1527
+ except (OSError, json.JSONDecodeError):
1528
+ raw = {}
1529
+ if isinstance(raw, dict):
1530
+ for name, value in raw.items():
1531
+ if not (
1532
+ isinstance(name, str)
1533
+ and 0 < len(name) <= 64
1534
+ and all(char.isalnum() or char in "._-" for char in name)
1535
+ and isinstance(value, str)
1536
+ ):
1537
+ continue
1538
+ candidate = Path(value).expanduser()
1539
+ if not candidate.is_absolute():
1540
+ candidate = logs_root / candidate
1541
+ try:
1542
+ resolved = candidate.resolve(strict=True)
1543
+ if resolved.parent != logs_root or candidate.is_symlink():
1544
+ continue
1545
+ with _open_regular_log(resolved, os.O_RDONLY):
1546
+ pass
1547
+ except OSError:
1548
+ continue
1549
+ result[name] = resolved
1550
+ # Keep compatibility with older search versions that wrote this path
1551
+ # without registering it in the shared index.
1552
+ legacy = cwd / ".crabcode" / "search" / "background.log"
1553
+ safe_legacy = _known_log_path(legacy)
1554
+ if safe_legacy is not None:
1555
+ result.setdefault("search", safe_legacy)
1556
+ # Gateway startup logs are useful even before a CoreSession exists.
1557
+ candidates = [get_config_home() / "gateway.log"]
1558
+ if os.name != "nt":
1559
+ candidates.append(Path("/tmp/crabcode-gateway.log"))
1560
+ for candidate in candidates:
1561
+ safe_candidate = _known_log_path(candidate)
1562
+ if safe_candidate is not None:
1563
+ result.setdefault("gateway", safe_candidate)
1564
+ return result
1565
+
1566
+
1567
+ def _open_regular_log(path: Path, flags: int):
1568
+ """Open one regular, single-link log without following a final symlink."""
1569
+ open_flags = flags | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
1570
+ fd = os.open(path, open_flags)
1571
+ try:
1572
+ file_stat = os.fstat(fd)
1573
+ if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1:
1574
+ raise OSError("Log path is not a single-link regular file")
1575
+ mode = "r" if flags == os.O_RDONLY else "w"
1576
+ return os.fdopen(fd, mode, encoding="utf-8", errors="replace")
1577
+ except BaseException:
1578
+ os.close(fd)
1579
+ raise
1580
+
1581
+
1582
+ def _known_log_path(path: Path) -> Path | None:
1583
+ """Validate a fixed, application-owned log location."""
1584
+ try:
1585
+ if path.is_symlink():
1586
+ return None
1587
+ resolved = path.resolve(strict=True)
1588
+ with _open_regular_log(resolved, os.O_RDONLY):
1589
+ pass
1590
+ return resolved
1591
+ except OSError:
1592
+ return None
1593
+
1594
+
1595
+ def _tail_log(path: Path, count: int) -> tuple[list[str], bool]:
1596
+ try:
1597
+ with _open_regular_log(path, os.O_RDONLY) as handle:
1598
+ all_lines = handle.read().splitlines()
1599
+ except OSError:
1600
+ return [], False
1601
+ return all_lines[-count:], len(all_lines) > count
1602
+
1603
+
1604
+ def _clear_log(path: Path) -> None:
1605
+ # Open without O_TRUNC, validate the descriptor, then truncate that exact
1606
+ # inode. This avoids truncating a swapped symlink before validation.
1607
+ with _open_regular_log(path, os.O_WRONLY) as handle:
1608
+ os.ftruncate(handle.fileno(), 0)
1609
+
1610
+
1611
+ @router.get("/logs", response_model=LogsResponse)
1612
+ async def get_logs(
1613
+ request: Request,
1614
+ lines: int = 100,
1615
+ tail: int | None = None,
1616
+ name: str | None = None,
1617
+ clear: bool = False,
1618
+ session_id: str | None = None,
1619
+ ) -> LogsResponse:
1620
+ """List logs or read/clear a named log, matching the CLI surface."""
1621
+ cwd = _logs_cwd(request, session_id)
1622
+ logs = _discover_logs(cwd)
1623
+ if not name:
1624
+ if clear:
1625
+ raise HTTPException(status_code=400, detail="name is required when clear=true")
1626
+ entries = []
1627
+ for key, path in sorted(logs.items()):
1628
+ try:
1629
+ updated = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
1630
+ except OSError:
1631
+ updated = None
1632
+ state = None
1633
+ if key == "search":
1634
+ status_path = cwd / ".crabcode" / "search" / "background-status.json"
1635
+ try:
1636
+ raw_status = json.loads(status_path.read_text(encoding="utf-8"))
1637
+ state = raw_status.get("state") if isinstance(raw_status, dict) else None
1638
+ except (OSError, json.JSONDecodeError):
1639
+ pass
1640
+ entries.append({"name": key, "path": str(path), "updated_at": updated, "state": state})
1641
+ return LogsResponse(logs=entries)
1642
+
1643
+ path = logs.get(name)
1644
+ if path is None:
1645
+ raise HTTPException(status_code=404, detail=f"Unknown log: {name}")
1646
+ if clear:
1647
+ try:
1648
+ _clear_log(path)
1649
+ except OSError as exc:
1650
+ raise HTTPException(status_code=500, detail=f"Failed to clear log: {exc}") from exc
1651
+ count = max(1, min(10_000, int(tail if tail is not None else lines)))
1652
+ body, truncated = _tail_log(path, count)
1653
+ return LogsResponse(
1654
+ name=name,
1655
+ path=str(path),
1656
+ lines=body,
1657
+ truncated=truncated,
1658
+ note="Log is empty" if not body else None,
1659
+ )
1660
+
1661
+
1662
+ @router.get("/logs/follow")
1663
+ async def follow_log(
1664
+ request: Request,
1665
+ name: str,
1666
+ session_id: str | None = None,
1667
+ ) -> StreamingResponse:
1668
+ """Stream appended lines from a named log as server-sent events."""
1669
+ cwd = _logs_cwd(request, session_id)
1670
+ path = _discover_logs(cwd).get(name)
1671
+ if path is None:
1672
+ raise HTTPException(status_code=404, detail=f"Unknown log: {name}")
1673
+
1674
+ async def _generate():
1675
+ try:
1676
+ with _open_regular_log(path, os.O_RDONLY) as handle:
1677
+ position = os.fstat(handle.fileno()).st_size
1678
+ except OSError:
1679
+ position = 0
1680
+ while True:
1681
+ if await request.is_disconnected():
1682
+ break
1683
+ try:
1684
+ with _open_regular_log(path, os.O_RDONLY) as handle:
1685
+ current_size = os.fstat(handle.fileno()).st_size
1686
+ if current_size < position:
1687
+ # The file was cleared or rotated while following it.
1688
+ position = 0
1689
+ handle.seek(position)
1690
+ chunk = handle.readlines()
1691
+ position = handle.tell()
1692
+ except OSError:
1693
+ chunk = []
1694
+ for line in chunk:
1695
+ yield f"data: {json.dumps(line.rstrip(chr(10)), ensure_ascii=False)}\n\n"
1696
+ await asyncio.sleep(0.5)
1697
+
1698
+ return StreamingResponse(_generate(), media_type="text/event-stream")