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,518 @@
1
+ """Same-machine transport for messages between independent CrabCode sessions.
2
+
3
+ Each live session publishes a small registry record and listens on a local
4
+ transport: a Unix domain socket on POSIX or loopback TCP on Windows. Registry
5
+ files provide discovery; the transport is the delivery and acknowledgement
6
+ boundary. Message contents never include transcripts or files and are always
7
+ tagged with their originating session.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections import deque
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import secrets
18
+ import sys
19
+ import tempfile
20
+ import uuid
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Awaitable, Callable, Literal
24
+ from urllib.parse import urlsplit
25
+
26
+ from pydantic import BaseModel, Field
27
+
28
+ from crabcode_core.logging_utils import get_logger
29
+ from crabcode_core.paths import get_config_home
30
+ from crabcode_core.subprocess_utils import is_process_running
31
+
32
+ logger = get_logger(__name__)
33
+
34
+ _PROTOCOL_VERSION = 1
35
+
36
+
37
+ def _now_iso() -> str:
38
+ return datetime.now(timezone.utc).isoformat()
39
+
40
+
41
+ def _safe_slug(value: str) -> str:
42
+ cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
43
+ return "-".join(part for part in cleaned.split("-") if part)[:48] or "session"
44
+
45
+
46
+ class PeerRecord(BaseModel):
47
+ """Discoverable identity for one live CrabCode session."""
48
+
49
+ version: int = _PROTOCOL_VERSION
50
+ session_id: str = Field(min_length=1)
51
+ name: str = Field(min_length=1)
52
+ cwd: str = Field(min_length=1)
53
+ pid: int = Field(gt=0)
54
+ socket_path: str = Field(min_length=1)
55
+ auth_token: str = Field(min_length=32, repr=False)
56
+ permission_class: Literal["prompting", "bypass"] = "prompting"
57
+ started_at: str = Field(default_factory=_now_iso)
58
+
59
+
60
+ class PeerMessage(BaseModel):
61
+ """Plain-text envelope delivered from one session to another."""
62
+
63
+ version: int = _PROTOCOL_VERSION
64
+ type: Literal["message"] = "message"
65
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
66
+ from_session_id: str = Field(min_length=1)
67
+ from_name: str = Field(min_length=1)
68
+ from_cwd: str = Field(min_length=1)
69
+ sender_permission_class: Literal["prompting", "bypass"] = "prompting"
70
+ to_session_id: str = Field(min_length=1)
71
+ to_auth_token: str = Field(min_length=32, repr=False)
72
+ text: str = Field(min_length=1)
73
+ sent_at: str = Field(default_factory=_now_iso)
74
+
75
+
76
+ class PeerDelivery(BaseModel):
77
+ """Acknowledgement returned by the receiving session."""
78
+
79
+ message_id: str
80
+ status: Literal["delivered", "held", "refused", "failed"]
81
+ detail: str = ""
82
+
83
+
84
+ InboundHandler = Callable[[PeerMessage], Awaitable[bool]]
85
+ HoldHandler = Callable[[PeerMessage], Awaitable[PeerDelivery]]
86
+ PermissionClassProvider = Callable[[], Literal["prompting", "bypass"]]
87
+
88
+
89
+ class PeerRuntime:
90
+ """Publish, discover, and message independent local sessions."""
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ session_id: str,
96
+ cwd: str,
97
+ on_message: InboundHandler,
98
+ on_hold: HoldHandler | None,
99
+ permission_class_provider: PermissionClassProvider,
100
+ name: str | None = None,
101
+ inbound: Literal["auto", "accept", "hold", "refuse"] = "auto",
102
+ registry_root: Path | None = None,
103
+ max_message_size_bytes: int = 10_000,
104
+ connect_timeout_seconds: float = 3.0,
105
+ ) -> None:
106
+ if not session_id:
107
+ raise ValueError("session_id is required")
108
+ if inbound not in {"auto", "accept", "hold", "refuse"}:
109
+ raise ValueError(f"Unsupported inbound policy: {inbound}")
110
+ if max_message_size_bytes <= 0:
111
+ raise ValueError("max_message_size_bytes must be greater than zero")
112
+
113
+ self.session_id = session_id
114
+ self.cwd = os.path.abspath(cwd)
115
+ self.name = name or f"{_safe_slug(Path(self.cwd).name)}-{session_id[:6]}"
116
+ self.inbound = inbound
117
+ self._on_message = on_message
118
+ self._on_hold = on_hold
119
+ self._permission_class_provider = permission_class_provider
120
+ self._registry_root = registry_root or get_config_home() / "peers"
121
+ self._max_message_size_bytes = max_message_size_bytes
122
+ self._connect_timeout = connect_timeout_seconds
123
+ self._server: asyncio.AbstractServer | None = None
124
+ self._record: PeerRecord | None = None
125
+ self._registry_path = self._registry_root / (
126
+ hashlib.sha256(session_id.encode()).hexdigest()[:24] + ".json"
127
+ )
128
+ # Darwin limits AF_UNIX paths to roughly 104 bytes. Configured roots
129
+ # (and pytest's per-test directories) can easily exceed that, so keep
130
+ # discovery records there but fall back to a short, user-scoped socket
131
+ # directory when necessary.
132
+ socket_root = self._registry_root
133
+ socket_probe = socket_root / ("x" * 24 + ".sock")
134
+ if os.name != "nt" and len(os.fsencode(socket_probe)) >= 100:
135
+ uid = os.getuid() if hasattr(os, "getuid") else os.getpid()
136
+ temp_root = Path("/private/tmp") if sys.platform == "darwin" else Path("/tmp")
137
+ socket_root = temp_root / f"crabcode-peers-{uid}"
138
+ self._socket_root = socket_root
139
+ socket_key = f"{session_id}:{os.getpid()}:{uuid.uuid4()}"
140
+ self._socket_path = self._socket_root / (
141
+ hashlib.sha256(socket_key.encode()).hexdigest()[:24] + ".sock"
142
+ )
143
+ self._endpoint = str(self._socket_path)
144
+ self._auth_token = secrets.token_urlsafe(32)
145
+ self._close_lock = asyncio.Lock()
146
+ self._seen_message_ids: dict[str, float] = {}
147
+ self._recent_payloads: dict[tuple[str, str], float] = {}
148
+ self._sender_windows: dict[str, deque[float]] = {}
149
+
150
+ @property
151
+ def record(self) -> PeerRecord | None:
152
+ return self._record.model_copy(deep=True) if self._record else None
153
+
154
+ async def start(self) -> None:
155
+ """Bind the inbox transport and atomically publish this session."""
156
+ if self._server is not None:
157
+ return
158
+ if os.name != "nt" and not hasattr(asyncio, "start_unix_server"):
159
+ raise RuntimeError("Cross-session messaging requires Unix domain sockets")
160
+
161
+ self._registry_root.mkdir(parents=True, exist_ok=True)
162
+ self._socket_root.mkdir(parents=True, exist_ok=True)
163
+ try:
164
+ os.chmod(self._registry_root, 0o700)
165
+ os.chmod(self._socket_root, 0o700)
166
+ except OSError:
167
+ logger.debug("Could not restrict peer registry permissions", exc_info=True)
168
+
169
+ try:
170
+ if os.name == "nt":
171
+ self._server = await asyncio.start_server(
172
+ self._handle_client,
173
+ host="127.0.0.1",
174
+ port=0,
175
+ limit=self._max_message_size_bytes + 16_384,
176
+ )
177
+ address = self._server.sockets[0].getsockname()
178
+ self._endpoint = f"tcp://127.0.0.1:{address[1]}"
179
+ else:
180
+ self._socket_path.unlink(missing_ok=True)
181
+ self._server = await asyncio.start_unix_server(
182
+ self._handle_client,
183
+ path=str(self._socket_path),
184
+ limit=self._max_message_size_bytes + 16_384,
185
+ )
186
+ os.chmod(self._socket_path, 0o600)
187
+ self._endpoint = str(self._socket_path)
188
+ record = PeerRecord(
189
+ session_id=self.session_id,
190
+ name=self.name,
191
+ cwd=self.cwd,
192
+ pid=os.getpid(),
193
+ socket_path=self._endpoint,
194
+ auth_token=self._auth_token,
195
+ permission_class=self._permission_class_provider(),
196
+ )
197
+ self._write_record(record)
198
+ self._record = record
199
+ except BaseException:
200
+ if self._server is not None:
201
+ self._server.close()
202
+ await self._server.wait_closed()
203
+ self._server = None
204
+ if os.name != "nt":
205
+ self._socket_path.unlink(missing_ok=True)
206
+ raise
207
+
208
+ async def close(self) -> None:
209
+ """Stop accepting messages and remove this runtime's registry entry."""
210
+ async with self._close_lock:
211
+ server = self._server
212
+ self._server = None
213
+ if server is not None:
214
+ server.close()
215
+ await server.wait_closed()
216
+ self._remove_own_record()
217
+ if os.name != "nt":
218
+ self._socket_path.unlink(missing_ok=True)
219
+ self._record = None
220
+
221
+ def close_nowait(self) -> None:
222
+ """Synchronously make a peer undiscoverable during a session switch."""
223
+ server = self._server
224
+ self._server = None
225
+ if server is not None:
226
+ server.close()
227
+ self._remove_own_record()
228
+ if os.name != "nt":
229
+ self._socket_path.unlink(missing_ok=True)
230
+ self._record = None
231
+
232
+ def list_peers(self) -> list[PeerRecord]:
233
+ """Return live discoverable peers, pruning records from dead processes."""
234
+ try:
235
+ paths = list(self._registry_root.glob("*.json"))
236
+ except OSError:
237
+ return []
238
+
239
+ peers: list[PeerRecord] = []
240
+ for path in paths:
241
+ try:
242
+ record = PeerRecord.model_validate_json(path.read_text(encoding="utf-8"))
243
+ except Exception:
244
+ logger.debug("Ignoring malformed peer record %s", path, exc_info=True)
245
+ continue
246
+ if record.session_id == self.session_id:
247
+ continue
248
+ if not self._record_is_live(record):
249
+ self._remove_stale_record(path, record)
250
+ continue
251
+ peers.append(record)
252
+ return sorted(peers, key=lambda peer: (peer.name, peer.session_id))
253
+
254
+ async def send(self, target: str, text: str) -> PeerDelivery:
255
+ """Resolve *target*, deliver plain text, and wait for an acknowledgement."""
256
+ if not isinstance(text, str) or not text.strip():
257
+ return PeerDelivery(message_id="", status="failed", detail="Message text is empty")
258
+ if len(text.encode("utf-8")) > self._max_message_size_bytes:
259
+ return PeerDelivery(
260
+ message_id="",
261
+ status="failed",
262
+ detail=f"Message exceeds {self._max_message_size_bytes} bytes",
263
+ )
264
+
265
+ try:
266
+ peer = self.resolve_peer(target)
267
+ except ValueError as exc:
268
+ return PeerDelivery(message_id="", status="failed", detail=str(exc))
269
+
270
+ message = PeerMessage(
271
+ from_session_id=self.session_id,
272
+ from_name=self.name,
273
+ from_cwd=self.cwd,
274
+ sender_permission_class=self._permission_class_provider(),
275
+ to_session_id=peer.session_id,
276
+ to_auth_token=peer.auth_token,
277
+ text=text,
278
+ )
279
+ writer: asyncio.StreamWriter | None = None
280
+ try:
281
+ if peer.socket_path.startswith("tcp://"):
282
+ address = urlsplit(peer.socket_path)
283
+ if address.hostname != "127.0.0.1" or address.port is None:
284
+ raise ValueError("Peer TCP endpoint is not local")
285
+ connection = asyncio.open_connection(address.hostname, address.port)
286
+ else:
287
+ connection = asyncio.open_unix_connection(peer.socket_path)
288
+ reader, writer = await asyncio.wait_for(
289
+ connection,
290
+ timeout=self._connect_timeout,
291
+ )
292
+ assert writer is not None
293
+ writer.write(message.model_dump_json().encode("utf-8") + b"\n")
294
+ await asyncio.wait_for(writer.drain(), timeout=self._connect_timeout)
295
+ raw = await asyncio.wait_for(reader.readline(), timeout=self._connect_timeout)
296
+ if not raw:
297
+ raise ConnectionError("Recipient closed without an acknowledgement")
298
+ payload = json.loads(raw)
299
+ return PeerDelivery(
300
+ message_id=message.id,
301
+ status=payload.get("status", "failed"),
302
+ detail=str(payload.get("detail", "")),
303
+ )
304
+ except Exception as exc:
305
+ self._remove_record_if_matches(peer)
306
+ return PeerDelivery(
307
+ message_id=message.id,
308
+ status="failed",
309
+ detail=f"Could not reach {peer.name}: {exc}",
310
+ )
311
+ finally:
312
+ if writer is not None:
313
+ writer.close()
314
+ try:
315
+ await writer.wait_closed()
316
+ except (BrokenPipeError, ConnectionError, OSError):
317
+ pass
318
+
319
+ def resolve_peer(self, target: str) -> PeerRecord:
320
+ """Resolve a unique peer by exact name, session ID, or ID prefix."""
321
+ selector = str(target or "").strip()
322
+ if not selector:
323
+ raise ValueError("Target session is required")
324
+ peers = self.list_peers()
325
+ exact_id = [peer for peer in peers if peer.session_id == selector]
326
+ if exact_id:
327
+ return exact_id[0]
328
+ exact_name = [peer for peer in peers if peer.name == selector]
329
+ if len(exact_name) == 1:
330
+ return exact_name[0]
331
+ if len(exact_name) > 1:
332
+ raise ValueError(f"Session name '{selector}' is ambiguous; use a session ID")
333
+ prefix = [peer for peer in peers if peer.session_id.startswith(selector)]
334
+ if len(prefix) == 1:
335
+ return prefix[0]
336
+ if len(prefix) > 1:
337
+ raise ValueError(f"Session ID prefix '{selector}' is ambiguous")
338
+ raise ValueError(f"No live session matches '{selector}'")
339
+
340
+ async def _handle_client(
341
+ self,
342
+ reader: asyncio.StreamReader,
343
+ writer: asyncio.StreamWriter,
344
+ ) -> None:
345
+ response: dict[str, str] = {"status": "failed", "detail": "Invalid message"}
346
+ try:
347
+ raw = await asyncio.wait_for(reader.readline(), timeout=self._connect_timeout)
348
+ if not raw or len(raw) > self._max_message_size_bytes + 16_384:
349
+ raise ValueError("Message frame is empty or too large")
350
+ payload = json.loads(raw)
351
+ message = PeerMessage.model_validate(payload)
352
+ if message.version != _PROTOCOL_VERSION:
353
+ raise ValueError(f"Unsupported peer protocol version {message.version}")
354
+ if message.to_session_id != self.session_id:
355
+ raise ValueError("Message was addressed to a different session")
356
+ if not secrets.compare_digest(message.to_auth_token, self._auth_token):
357
+ raise ValueError("Invalid inbox authentication token")
358
+ if len(message.text.encode("utf-8")) > self._max_message_size_bytes:
359
+ raise ValueError("Message text exceeds the configured byte limit")
360
+ throttle_reason = self._throttle_reason(message)
361
+ if throttle_reason:
362
+ response = {"status": "refused", "detail": throttle_reason}
363
+ else:
364
+ decision = self._inbound_decision(message.sender_permission_class)
365
+ if decision == "refuse":
366
+ response = {
367
+ "status": "refused",
368
+ "detail": "Receiving session's inbound policy refused the message",
369
+ }
370
+ elif decision == "hold":
371
+ if self._on_hold is None:
372
+ response = {
373
+ "status": "refused",
374
+ "detail": "Receiving session cannot request inbound approval",
375
+ }
376
+ else:
377
+ delivery = await self._on_hold(message)
378
+ response = {
379
+ "status": delivery.status,
380
+ "detail": delivery.detail,
381
+ }
382
+ elif await self._on_message(message):
383
+ response = {"status": "delivered", "detail": ""}
384
+ else:
385
+ response = {"status": "failed", "detail": "Receiving queue is unavailable"}
386
+ except Exception as exc:
387
+ response = {"status": "failed", "detail": str(exc)}
388
+ finally:
389
+ try:
390
+ writer.write(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
391
+ await writer.drain()
392
+ except (BrokenPipeError, ConnectionError, OSError):
393
+ pass
394
+ writer.close()
395
+ try:
396
+ await writer.wait_closed()
397
+ except (BrokenPipeError, ConnectionError, OSError):
398
+ pass
399
+
400
+ def _inbound_decision(
401
+ self,
402
+ sender_class: Literal["prompting", "bypass"],
403
+ ) -> Literal["accept", "hold", "refuse"]:
404
+ if self.inbound == "accept":
405
+ return "accept"
406
+ if self.inbound == "hold":
407
+ return "hold"
408
+ if self.inbound == "refuse":
409
+ return "refuse"
410
+ return (
411
+ "accept"
412
+ if sender_class == self._permission_class_provider()
413
+ else "hold"
414
+ )
415
+
416
+ def _throttle_reason(self, message: PeerMessage) -> str:
417
+ """Bound loops and identical repeats before they reach the model queue."""
418
+ now = asyncio.get_running_loop().time()
419
+ cutoff = now - 60.0
420
+ self._seen_message_ids = {
421
+ message_id: seen_at
422
+ for message_id, seen_at in self._seen_message_ids.items()
423
+ if seen_at >= cutoff
424
+ }
425
+ self._recent_payloads = {
426
+ key: seen_at
427
+ for key, seen_at in self._recent_payloads.items()
428
+ if seen_at >= now - 2.0
429
+ }
430
+ if message.id in self._seen_message_ids:
431
+ return "Duplicate message ID"
432
+
433
+ window = self._sender_windows.setdefault(message.from_session_id, deque())
434
+ while window and window[0] < cutoff:
435
+ window.popleft()
436
+ if len(window) >= 30:
437
+ return "Sender rate limit exceeded"
438
+
439
+ payload_key = (message.from_session_id, message.text)
440
+ if payload_key in self._recent_payloads:
441
+ return "Identical repeated message"
442
+
443
+ self._seen_message_ids[message.id] = now
444
+ self._recent_payloads[payload_key] = now
445
+ window.append(now)
446
+ return ""
447
+
448
+ def _write_record(self, record: PeerRecord) -> None:
449
+ temp_path: str | None = None
450
+ try:
451
+ with tempfile.NamedTemporaryFile(
452
+ mode="w",
453
+ encoding="utf-8",
454
+ dir=self._registry_root,
455
+ prefix=f".{self._registry_path.stem}.",
456
+ suffix=".tmp",
457
+ delete=False,
458
+ ) as handle:
459
+ temp_path = handle.name
460
+ handle.write(record.model_dump_json())
461
+ handle.flush()
462
+ os.fsync(handle.fileno())
463
+ os.chmod(temp_path, 0o600)
464
+ os.replace(temp_path, self._registry_path)
465
+ temp_path = None
466
+ finally:
467
+ if temp_path is not None:
468
+ try:
469
+ os.unlink(temp_path)
470
+ except OSError:
471
+ pass
472
+
473
+ def _remove_own_record(self) -> None:
474
+ try:
475
+ current = PeerRecord.model_validate_json(
476
+ self._registry_path.read_text(encoding="utf-8")
477
+ )
478
+ except Exception:
479
+ return
480
+ if current.socket_path == self._endpoint:
481
+ self._registry_path.unlink(missing_ok=True)
482
+
483
+ @staticmethod
484
+ def _record_is_live(record: PeerRecord) -> bool:
485
+ if (
486
+ not record.socket_path.startswith("tcp://")
487
+ and not Path(record.socket_path).exists()
488
+ ):
489
+ return False
490
+ return is_process_running(record.pid)
491
+
492
+ def _remove_stale_record(self, path: Path, record: PeerRecord) -> None:
493
+ try:
494
+ path.unlink(missing_ok=True)
495
+ if not record.socket_path.startswith("tcp://"):
496
+ socket_path = Path(record.socket_path)
497
+ allowed_roots = {
498
+ self._registry_root.resolve(),
499
+ self._socket_root.resolve(),
500
+ }
501
+ if socket_path.parent.resolve() in allowed_roots:
502
+ socket_path.unlink(missing_ok=True)
503
+ except OSError:
504
+ logger.debug("Could not prune stale peer %s", record.session_id, exc_info=True)
505
+
506
+ def _remove_record_if_matches(self, peer: PeerRecord) -> None:
507
+ path = self._registry_root / (
508
+ hashlib.sha256(peer.session_id.encode()).hexdigest()[:24] + ".json"
509
+ )
510
+ try:
511
+ current = PeerRecord.model_validate_json(path.read_text(encoding="utf-8"))
512
+ except Exception:
513
+ return
514
+ if current.socket_path == peer.socket_path:
515
+ try:
516
+ path.unlink(missing_ok=True)
517
+ except OSError:
518
+ pass
@@ -0,0 +1,7 @@
1
+ from crabcode_core.permissions.manager import (
2
+ PermissionManager,
3
+ PermissionMode,
4
+ mode_from_default_mode,
5
+ )
6
+
7
+ __all__ = ["PermissionManager", "PermissionMode", "mode_from_default_mode"]