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,232 @@
1
+ """Read-only Git review scopes for local and remote workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from pydantic import BaseModel, Field
13
+
14
+ from crabcode_gateway.routes.workspace import _is_within, _resolve_directory, _workspace_roots
15
+
16
+ router = APIRouter(prefix="/workspace/git", tags=["workspace"])
17
+ GitScope = Literal["uncommitted", "unstaged", "staged", "commit", "branch"]
18
+ MAX_FILES = 200
19
+ MAX_PATCH_BYTES = 2 * 1024 * 1024
20
+ MAX_OUTPUT_BYTES = 16 * 1024 * 1024
21
+
22
+
23
+ class GitCommit(BaseModel):
24
+ id: str
25
+ subject: str
26
+
27
+
28
+ class GitReviewInfo(BaseModel):
29
+ available: bool
30
+ reason: str | None = None
31
+ root: str | None = None
32
+ head: str | None = None
33
+ branch: str | None = None
34
+ default_base: str | None = None
35
+ branches: list[str] = Field(default_factory=list)
36
+ commits: list[GitCommit] = Field(default_factory=list)
37
+
38
+
39
+ class GitReviewFile(BaseModel):
40
+ path: str
41
+ action: Literal["create", "modify", "delete"]
42
+ added: int = 0
43
+ removed: int = 0
44
+ diff: str | None = None
45
+ note: str | None = None
46
+
47
+
48
+ class GitReviewDiff(BaseModel):
49
+ files: list[GitReviewFile]
50
+ added: int
51
+ removed: int
52
+ total_files: int
53
+ truncated: bool
54
+ base: str | None = None
55
+ target: str | None = None
56
+
57
+
58
+ def _git(root: Path, *args: str, optional: bool = False) -> bytes | None:
59
+ env = dict(os.environ, GIT_OPTIONAL_LOCKS="0", LC_ALL="C")
60
+ for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
61
+ env.pop(key, None)
62
+ try:
63
+ result = subprocess.run(
64
+ ["git", "--no-pager", "--literal-pathspecs", "-c", "core.fsmonitor=false",
65
+ "-c", "core.quotePath=false", "-C", str(root), *args],
66
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
67
+ env=env, timeout=15, check=False,
68
+ )
69
+ except FileNotFoundError as exc:
70
+ raise HTTPException(503, "Gateway 未安装 Git") from exc
71
+ except subprocess.TimeoutExpired as exc:
72
+ raise HTTPException(504, "读取 Git 超时,请缩小变更范围后重试") from exc
73
+ if result.returncode:
74
+ if optional:
75
+ return None
76
+ raise HTTPException(400, "无法读取 Git 变更:" + result.stderr.decode("utf-8", "replace").strip()[:500])
77
+ if len(result.stdout) > MAX_OUTPUT_BYTES:
78
+ raise HTTPException(413, "Git 输出过大,请选择更小的变更范围")
79
+ return result.stdout
80
+
81
+
82
+ def _repository(request: Request, path: str) -> Path | None:
83
+ roots = _workspace_roots(request)
84
+ directory = _resolve_directory(path, roots)
85
+ value = _git(directory, "rev-parse", "--show-toplevel", optional=True)
86
+ if value is None:
87
+ return None
88
+ root = Path(os.fsdecode(value.rstrip(b"\n"))).resolve()
89
+ if not _is_within(root, roots):
90
+ raise HTTPException(403, "Git 仓库不在允许浏览的目录内")
91
+ return root
92
+
93
+
94
+ def _revision(root: Path, ref: str, *, optional: bool = False) -> str | None:
95
+ value = _git(root, "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}", optional=optional)
96
+ return value.decode().strip() if value is not None else None
97
+
98
+
99
+ @router.get("/info", response_model=GitReviewInfo)
100
+ def git_info(request: Request, path: str) -> GitReviewInfo:
101
+ try:
102
+ root = _repository(request, path)
103
+ except HTTPException as exc:
104
+ if exc.status_code == 503:
105
+ return GitReviewInfo(available=False, reason=str(exc.detail))
106
+ raise
107
+ if root is None:
108
+ return GitReviewInfo(available=False, reason="当前目录不在 Git 仓库中")
109
+ head = _revision(root, "HEAD", optional=True)
110
+ branch_value = _git(root, "symbolic-ref", "--quiet", "--short", "HEAD", optional=True)
111
+ branch = branch_value.decode("utf-8", "replace").strip() if branch_value else None
112
+ refs = _git(root, "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes") or b""
113
+ branches = [ref for ref in refs.decode("utf-8", "replace").splitlines() if not ref.endswith("/HEAD")]
114
+ remote_head = _git(root, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", optional=True)
115
+ defaults = [remote_head.decode().strip() if remote_head else "", "origin/main", "main", "origin/master", "master"]
116
+ default_base = next((ref for ref in defaults + branches if ref in branches and ref != branch), None)
117
+ default_base = default_base or (branches[0] if branches else None)
118
+ commits: list[GitCommit] = []
119
+ if head:
120
+ log = _git(root, "log", "-100", "--format=%H%x00%s%x00", head, "--") or b""
121
+ fields = log.decode("utf-8", "replace").split("\0")
122
+ commits = [GitCommit(id=fields[i].strip(), subject=fields[i + 1]) for i in range(0, len(fields) - 1, 2)]
123
+ return GitReviewInfo(available=True, root=str(root), head=head, branch=branch,
124
+ default_base=default_base, branches=branches, commits=commits)
125
+
126
+
127
+ def _new_file(root: Path, name: str) -> GitReviewFile | None:
128
+ path = root / name
129
+ if not _is_within(path.parent.resolve(), (root,)):
130
+ raise HTTPException(403, "文件不在 Git 工作区内")
131
+ try:
132
+ if path.is_symlink():
133
+ raw = os.fsencode(os.readlink(path))
134
+ else:
135
+ if not path.is_file():
136
+ return None
137
+ with path.open("rb") as handle:
138
+ raw = handle.read(MAX_PATCH_BYTES + 1)
139
+ except FileNotFoundError:
140
+ return None # The working tree can change while the review is loading.
141
+ if len(raw) > MAX_PATCH_BYTES:
142
+ return GitReviewFile(path=name, action="create", note="文件超过 2 MiB,未加载 diff")
143
+ if b"\0" in raw:
144
+ return GitReviewFile(path=name, action="create", note="二进制文件,无法显示文本 diff")
145
+ try:
146
+ text = raw.decode("utf-8")
147
+ except UnicodeDecodeError:
148
+ return GitReviewFile(path=name, action="create", note="非 UTF-8 文件,无法显示文本 diff")
149
+ lines = text.splitlines(keepends=True)
150
+ patch = "".join(difflib.unified_diff([], lines, fromfile="/dev/null", tofile=f"b/{name}"))
151
+ return GitReviewFile(path=name, action="create", added=len(lines), diff=patch,
152
+ note="空文件" if not lines else None)
153
+
154
+
155
+ @router.get("/diff", response_model=GitReviewDiff)
156
+ def git_diff(request: Request, path: str, scope: GitScope, ref: str | None = None) -> GitReviewDiff:
157
+ root = _repository(request, path)
158
+ if root is None:
159
+ raise HTTPException(400, "当前目录不在 Git 仓库中")
160
+ head = _revision(root, "HEAD", optional=True)
161
+ base: str | None = None
162
+ target: str | None = None
163
+ unborn_worktree = scope == "uncommitted" and head is None
164
+ if scope == "commit":
165
+ target = _revision(root, ref or head or "HEAD")
166
+ base = _revision(root, f"{target}^", optional=True)
167
+ command = ["diff", base, target] if base else ["diff-tree", "--root", "--no-commit-id", "-r", target]
168
+ elif scope == "branch":
169
+ if not head or not ref:
170
+ raise HTTPException(400, "请选择比较分支,并确保当前分支已有提交")
171
+ reference = _revision(root, ref)
172
+ common = _git(root, "merge-base", reference, head, optional=True)
173
+ if common is None:
174
+ raise HTTPException(400, "所选分支与当前分支没有共同祖先")
175
+ base, target = common.decode().strip(), head
176
+ command = ["diff", base, target]
177
+ elif scope == "staged":
178
+ base = head
179
+ command = ["diff", "--cached"]
180
+ elif scope == "uncommitted":
181
+ base = head
182
+ command = ["diff", head] if head else ["diff", "--cached"]
183
+ else:
184
+ command = ["diff"]
185
+ # Treat renames as deletion + addition, preserving both paths without
186
+ # ambiguous quoted-path parsing. Git never runs external diff drivers.
187
+ options = ["--no-ext-diff", "--no-textconv", "--no-renames", "--ignore-submodules=none", "--no-color"]
188
+ stats = _git(root, *command, *options, "--numstat", "-z", "--") or b""
189
+ entries: dict[str, tuple[int, int, bool]] = {}
190
+ for record in stats.split(b"\0"):
191
+ if not record:
192
+ continue
193
+ added, removed, name = record.split(b"\t", 2)
194
+ binary = added == b"-" or removed == b"-"
195
+ entries[os.fsdecode(name)] = (0 if binary else int(added), 0 if binary else int(removed), binary)
196
+ untracked: set[str] = set()
197
+ if scope in ("uncommitted", "unstaged"):
198
+ raw = _git(root, "ls-files", "--others", "--exclude-standard", "-z") or b""
199
+ untracked = {os.fsdecode(name) for name in raw.split(b"\0") if name}
200
+ for name in sorted(untracked):
201
+ entries.setdefault(name, (0, 0, False))
202
+ files: list[GitReviewFile] = []
203
+ remaining_bytes = MAX_OUTPUT_BYTES
204
+ for name, (added, removed, binary) in list(entries.items())[:MAX_FILES]:
205
+ if name in untracked or unborn_worktree:
206
+ file = _new_file(root, name)
207
+ if file:
208
+ patch_bytes = len((file.diff or "").encode("utf-8"))
209
+ if patch_bytes > min(MAX_PATCH_BYTES, remaining_bytes):
210
+ file.diff = None
211
+ file.note = "diff 过大,仅显示行数"
212
+ else:
213
+ remaining_bytes -= patch_bytes
214
+ files.append(file)
215
+ continue
216
+ patch = _git(root, *command, *options, "--patch", "--", name) or b""
217
+ text = patch.decode("utf-8", "replace")
218
+ action = "create" if "\nnew file mode " in text else "delete" if "\ndeleted file mode " in text else "modify"
219
+ note = None
220
+ if binary:
221
+ note = "二进制文件,无法显示文本 diff"
222
+ elif len(patch) > min(MAX_PATCH_BYTES, remaining_bytes):
223
+ note = "diff 过大,仅显示行数"
224
+ elif "\n@@ " not in text:
225
+ note = "文件属性或子模块变更,无文本差异"
226
+ if note is None:
227
+ remaining_bytes -= len(patch)
228
+ files.append(GitReviewFile(path=name, action=action, added=added, removed=removed,
229
+ diff=None if note else text, note=note))
230
+ return GitReviewDiff(files=files, added=sum(file.added for file in files),
231
+ removed=sum(file.removed for file in files), total_files=len(entries),
232
+ truncated=len(entries) > MAX_FILES, base=base, target=target)
@@ -0,0 +1,14 @@
1
+ """Health check endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from crabcode_gateway.schemas import HealthResponse
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/health", response_model=HealthResponse)
13
+ async def health() -> HealthResponse:
14
+ return HealthResponse()
@@ -0,0 +1,78 @@
1
+ """Cross-session peer discovery and messaging routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from crabcode_gateway.schemas import PeerDeliveryInfo, PeerInfo, PeerSendRequest
10
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
11
+
12
+
13
+ router = APIRouter(tags=["peer"])
14
+
15
+
16
+ def _get_session(request: Request, session_id: str | None = None):
17
+ sessions: dict[str, Any] = request.app.state.sessions
18
+ sid = request.app.state.default_session_id if session_id is None else session_id
19
+ session = sessions.get(sid) if sid else None
20
+ if session is None or getattr(session, "session_id", None) in getattr(request.app.state, "closing_sessions", set()):
21
+ return None
22
+ return session
23
+
24
+
25
+ def _peer_info(peer: Any) -> PeerInfo:
26
+ data = peer.model_dump(exclude={"auth_token"}) if hasattr(peer, "model_dump") else dict(peer)
27
+ return PeerInfo.model_validate(data)
28
+
29
+
30
+ @router.get("/peer/list", response_model=list[PeerInfo])
31
+ @router.get("/peers", response_model=list[PeerInfo])
32
+ async def list_peers(request: Request, session_id: str | None = None) -> list[PeerInfo]:
33
+ session = _get_session(request, session_id)
34
+ if not session:
35
+ raise HTTPException(status_code=404, detail="Session not found")
36
+
37
+ async def _list() -> list[PeerInfo]:
38
+ runtime = await session.ensure_peer_runtime()
39
+ if runtime is None:
40
+ return []
41
+ return [_peer_info(peer) for peer in runtime.list_peers()]
42
+
43
+ try:
44
+ return await run_session_operation(request.app.state, session, _list)
45
+ except SessionOperationRejected as exc:
46
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
47
+ except (RuntimeError, OSError, ValueError) as exc:
48
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
49
+
50
+
51
+ @router.post("/peer/send", response_model=PeerDeliveryInfo)
52
+ @router.post("/peers/send", response_model=PeerDeliveryInfo)
53
+ async def send_peer_message(req: PeerSendRequest, request: Request) -> PeerDeliveryInfo:
54
+ session = _get_session(request, req.session_id)
55
+ if not session:
56
+ raise HTTPException(status_code=404, detail="Session not found")
57
+
58
+ async def _send() -> PeerDeliveryInfo:
59
+ runtime = await session.ensure_peer_runtime()
60
+ if runtime is None:
61
+ raise RuntimeError("Cross-session messaging is disabled")
62
+ delivery = await runtime.send(req.to, req.text)
63
+ return PeerDeliveryInfo.model_validate(
64
+ delivery.model_dump() if hasattr(delivery, "model_dump") else delivery
65
+ )
66
+
67
+ try:
68
+ delivery = await run_session_operation(request.app.state, session, _send)
69
+ except SessionOperationRejected as exc:
70
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
71
+ except (RuntimeError, OSError, ValueError) as exc:
72
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
73
+ if delivery.status in {"failed", "refused"}:
74
+ # Preserve the structured acknowledgement while giving HTTP clients a
75
+ # useful status code for automation.
76
+ return delivery
77
+ return delivery
78
+
@@ -0,0 +1,121 @@
1
+ """Permission and choice interaction routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+
7
+ from crabcode_gateway.schemas import (
8
+ ChoiceResponseRequest,
9
+ PermissionResponseRequest,
10
+ )
11
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
12
+
13
+ router = APIRouter(tags=["interaction"])
14
+
15
+
16
+ def _get_session(
17
+ request: Request,
18
+ session_id: str | None = None,
19
+ *,
20
+ agent_id: str | None = None,
21
+ ):
22
+ sessions: dict = request.app.state.sessions
23
+ closing = getattr(request.app.state, "closing_sessions", set())
24
+
25
+ # Preserve an explicit empty selector as invalid; ``None`` alone means
26
+ # that the legacy default/agent-owner lookup may be used.
27
+ requested = session_id if session_id is not None else None
28
+
29
+ def _owns(candidate: object | None) -> bool:
30
+ if candidate is None:
31
+ return False
32
+ if getattr(candidate, "session_id", None) in closing:
33
+ return False
34
+ if not agent_id:
35
+ return True
36
+ try:
37
+ return bool(candidate.get_agent(agent_id)) # type: ignore[attr-defined]
38
+ except Exception:
39
+ return False
40
+
41
+ # An explicit session selector must not silently fall back to the default
42
+ # session when the agent belongs elsewhere.
43
+ if requested is not None:
44
+ selected = sessions.get(requested)
45
+ return selected if _owns(selected) else None
46
+
47
+ default_id = request.app.state.default_session_id
48
+ selected = sessions.get(default_id) if default_id else None
49
+ if _owns(selected):
50
+ return selected
51
+
52
+ if agent_id:
53
+ for candidate in sessions.values():
54
+ if _owns(candidate):
55
+ return candidate
56
+ return None
57
+
58
+
59
+ @router.post("/permission/respond")
60
+ async def respond_permission(req: PermissionResponseRequest, request: Request):
61
+ """Respond to a permission request from the agent."""
62
+ from crabcode_core.types.event import PermissionResponseEvent
63
+
64
+ session = _get_session(
65
+ request,
66
+ req.session_id
67
+ if req.session_id is not None
68
+ else request.query_params.get("session_id"),
69
+ agent_id=req.agent_id,
70
+ )
71
+ if not session:
72
+ raise HTTPException(status_code=404, detail="Session not found")
73
+
74
+ event = PermissionResponseEvent(
75
+ tool_use_id=req.tool_use_id,
76
+ allowed=req.allowed,
77
+ always_allow=req.always_allow,
78
+ agent_id=req.agent_id,
79
+ feedback=req.feedback,
80
+ )
81
+ try:
82
+ await run_session_operation(
83
+ request.app.state,
84
+ session,
85
+ lambda: session.respond_permission(event),
86
+ )
87
+ except SessionOperationRejected as exc:
88
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
89
+ return {"status": "ok"}
90
+
91
+
92
+ @router.post("/choice/respond")
93
+ async def respond_choice(req: ChoiceResponseRequest, request: Request):
94
+ """Respond to a choice request from the agent."""
95
+ from crabcode_core.types.event import ChoiceResponseEvent
96
+
97
+ session = _get_session(
98
+ request,
99
+ req.session_id
100
+ if req.session_id is not None
101
+ else request.query_params.get("session_id"),
102
+ agent_id=req.agent_id,
103
+ )
104
+ if not session:
105
+ raise HTTPException(status_code=404, detail="Session not found")
106
+
107
+ event = ChoiceResponseEvent(
108
+ tool_use_id=req.tool_use_id,
109
+ selected=req.selected,
110
+ cancelled=req.cancelled,
111
+ agent_id=req.agent_id,
112
+ )
113
+ try:
114
+ await run_session_operation(
115
+ request.app.state,
116
+ session,
117
+ lambda: session.respond_choice(event),
118
+ )
119
+ except SessionOperationRejected as exc:
120
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
121
+ return {"status": "ok"}