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,821 @@
1
+ """Team message bus — event-driven message passing with backpressure.
2
+
3
+ Provides O(1) JSONL append for message writes, session injection for
4
+ delivery, and auto-wake for idle recipients. No polling required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Any, Iterator
16
+
17
+ from crabcode_core.file_lock import file_lock
18
+ from crabcode_core.logging_utils import get_logger
19
+ from crabcode_core.path_validation import validate_path_component
20
+ from crabcode_core.team.models import (
21
+ TeamConfig,
22
+ TeamMessage,
23
+ )
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ _MAX_MESSAGE_SIZE_BYTES = 10_000 # 10KB default
28
+
29
+ @contextmanager
30
+ def _team_file_lock(
31
+ storage_root: Path,
32
+ team_name: str,
33
+ *,
34
+ shared: bool = False,
35
+ ) -> Iterator[None]:
36
+ """Coordinate all disk operations for one team.
37
+
38
+ The sidecar lives beside the team directory, so deleting the directory
39
+ cannot remove the lock inode while another writer is waiting. The naming
40
+ matches ``InboxStorage`` so the two storage APIs also serialize when they
41
+ point at the same root.
42
+ """
43
+ storage_root.mkdir(parents=True, exist_ok=True)
44
+ lock_path = storage_root / f".{team_name}.team.lock"
45
+ with file_lock(lock_path, exclusive=not shared):
46
+ yield
47
+
48
+
49
+ class TeamMessageBus:
50
+ """In-process message bus for a single team.
51
+
52
+ Each team gets its own TeamMessageBus instance. Messages are:
53
+ 1. Written to the recipient's inbox (JSONL, O(1) append)
54
+ 2. Injected into the recipient's asyncio.Queue for immediate delivery
55
+ 3. Auto-wake triggers restart the recipient's prompt loop if idle
56
+
57
+ Backpressure: each agent has a bounded asyncio.Queue. When full,
58
+ the oldest unread message is dropped and a warning is logged.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ team_name: str,
64
+ config: TeamConfig,
65
+ *,
66
+ inject_fn: Any | None = None,
67
+ wake_fn: Any | None = None,
68
+ storage_root: Path | None = None,
69
+ ) -> None:
70
+ self._validate_component(team_name, "team name")
71
+ self._team_name = team_name
72
+ self._config = config
73
+ self._max_queue_size = int(config.backpressure_queue_size)
74
+ self._max_msg_size = int(config.max_message_size_bytes or _MAX_MESSAGE_SIZE_BYTES)
75
+ if self._max_queue_size <= 0:
76
+ raise ValueError("backpressure_queue_size must be greater than zero")
77
+ if self._max_msg_size <= 0:
78
+ raise ValueError("max_message_size_bytes must be greater than zero")
79
+ self._inject_fn = inject_fn # async (agent_id, from_agent, text) -> None
80
+ self._wake_fn = wake_fn # async (agent_id, from_agent) -> None
81
+
82
+ # Per-agent queues: agent_id -> asyncio.Queue[TeamMessage]
83
+ self._queues: dict[str, asyncio.Queue[TeamMessage]] = {}
84
+ # Per-agent inbox for persistence (agent_id -> list of all messages)
85
+ self._inboxes: dict[str, list[TeamMessage]] = {}
86
+ # Track which agents are registered
87
+ self._registered: set[str] = set()
88
+ # Registration-scoped wake events let receive() abandon a queue that
89
+ # was invalidated by unregister_agent() instead of waiting forever on
90
+ # an orphaned queue object.
91
+ self._registration_events: dict[str, asyncio.Event] = {}
92
+ # Inbox storage root on disk
93
+ self._storage_root = storage_root
94
+ # A per-recipient lock serializes append and read-state rewrites. Without
95
+ # this, an async mark_read rewrite can race a send append and lose data.
96
+ self._inbox_locks: dict[str, asyncio.Lock] = {}
97
+ # Disk operations run in an executor and may outlive a registration
98
+ # change. Epochs prevent a stale operation from repopulating the
99
+ # in-memory state of a later registration for the same agent id.
100
+ self._registration_epochs: dict[str, int] = {}
101
+
102
+ @staticmethod
103
+ def _validate_component(value: str, label: str) -> None:
104
+ """Reject path separators and traversal components used in inbox paths."""
105
+ validate_path_component(value, label)
106
+
107
+ # ------------------------------------------------------------------
108
+ # Registration
109
+ # ------------------------------------------------------------------
110
+
111
+ def register_agent(self, agent_id: str) -> None:
112
+ """Register an agent to receive messages."""
113
+ self._validate_component(agent_id, "agent id")
114
+ if agent_id in self._registered:
115
+ return
116
+ self._registered.add(agent_id)
117
+ self._registration_epochs[agent_id] = (
118
+ self._registration_epochs.get(agent_id, 0) + 1
119
+ )
120
+ self._registration_events[agent_id] = asyncio.Event()
121
+ self._queues[agent_id] = asyncio.Queue(maxsize=self._max_queue_size)
122
+ # Preserve the lock across unregister/re-register cycles. An
123
+ # in-flight send or mark_read may still own the previous lock object.
124
+ self._inbox_locks.setdefault(agent_id, asyncio.Lock())
125
+ # Recover durable messages before exposing the recipient. Previously a
126
+ # newly-created bus silently reset the in-memory inbox and made JSONL
127
+ # persistence useless after a restart.
128
+ messages = self.load_inbox_from_disk(agent_id)
129
+ self._inboxes[agent_id] = messages
130
+ # Queue only unread messages, retaining the full inbox as the source of
131
+ # truth. If more messages exist than the bounded queue can hold, keep
132
+ # the newest entries in the live queue; older entries remain readable.
133
+ unread = [message for message in messages if not message.read]
134
+ for message in unread[-self._max_queue_size :]:
135
+ try:
136
+ self._queues[agent_id].put_nowait(message.model_copy(deep=True))
137
+ except asyncio.QueueFull:
138
+ break
139
+
140
+ def unregister_agent(self, agent_id: str) -> None:
141
+ """Unregister an agent. Drops its queue and inbox."""
142
+ self._registration_epochs[agent_id] = (
143
+ self._registration_epochs.get(agent_id, 0) + 1
144
+ )
145
+ event = self._registration_events.pop(agent_id, None)
146
+ if event is not None:
147
+ event.set()
148
+ self._registered.discard(agent_id)
149
+ self._queues.pop(agent_id, None)
150
+ self._inboxes.pop(agent_id, None)
151
+
152
+ # ------------------------------------------------------------------
153
+ # Sending
154
+ # ------------------------------------------------------------------
155
+
156
+ async def send(
157
+ self,
158
+ *,
159
+ from_agent: str,
160
+ to_agent: str,
161
+ text: str,
162
+ msg_type: str = "text",
163
+ expected_epoch: int | None = None,
164
+ ) -> TeamMessage | None:
165
+ """Send a message from one agent to another.
166
+
167
+ Steps:
168
+ 1. Validate message size
169
+ 2. Create TeamMessage
170
+ 3. Append to recipient's inbox (source of truth)
171
+ 4. Persist to JSONL on disk
172
+ 5. Push into recipient's asyncio.Queue (with backpressure)
173
+ 6. Inject into recipient's session (delivery mechanism)
174
+ 7. Auto-wake idle recipient
175
+ """
176
+ if not isinstance(text, str):
177
+ logger.warning("Non-text message rejected for %s in team %s", to_agent, self._team_name)
178
+ return None
179
+ if to_agent not in self._registered:
180
+ logger.warning("Message to unregistered agent %s in team %s", to_agent, self._team_name)
181
+ return None
182
+ # Capture the registration generation before waiting on the recipient
183
+ # lock. An unregister/re-register cycle must fence this operation;
184
+ # otherwise a send that started for the old runtime can be delivered to
185
+ # a replacement using the same agent id after it acquires the lock.
186
+ initial_epoch = self._registration_epochs.get(to_agent)
187
+ if initial_epoch is None:
188
+ return None
189
+ if expected_epoch is not None and initial_epoch != expected_epoch:
190
+ logger.warning(
191
+ "Recipient %s registration changed before delivery in team %s",
192
+ to_agent,
193
+ self._team_name,
194
+ )
195
+ return None
196
+
197
+ if len(text.encode("utf-8")) > self._max_msg_size:
198
+ logger.warning(
199
+ "Message from %s to %s exceeds %d bytes, truncating",
200
+ from_agent, to_agent, self._max_msg_size,
201
+ )
202
+ # Ignore a partial UTF-8 sequence at the byte boundary so the
203
+ # resulting payload never exceeds the configured byte limit.
204
+ text = text.encode("utf-8")[:self._max_msg_size].decode("utf-8", errors="ignore")
205
+ if not text:
206
+ return None
207
+
208
+ msg = TeamMessage(
209
+ from_agent=from_agent,
210
+ to_agent=to_agent,
211
+ text=text,
212
+ msg_type=msg_type,
213
+ )
214
+
215
+ # 1. Append to inbox and persist under one recipient lock. Re-check
216
+ # registration after waiting: shutdown/removal may have happened while
217
+ # another send was in progress.
218
+ lock = self._inbox_locks.setdefault(to_agent, asyncio.Lock())
219
+ async with lock:
220
+ if (
221
+ to_agent not in self._registered
222
+ or self._registration_epochs.get(to_agent) != initial_epoch
223
+ ):
224
+ logger.warning("Recipient %s was unregistered before delivery", to_agent)
225
+ return None
226
+ registration_epoch = initial_epoch
227
+ if not await self._persist_message(to_agent, msg):
228
+ # Persistence is the commit point for a send. Do not expose a
229
+ # message through memory, injection, or wake-up when its durable
230
+ # append failed.
231
+ return None
232
+ if (
233
+ to_agent not in self._registered
234
+ or self._registration_epochs.get(to_agent, 0) != registration_epoch
235
+ ):
236
+ # The recipient can be removed while the executor is writing
237
+ # the durable line. Keep it for a future registration without
238
+ # recreating the old in-memory inbox or injecting into a stale
239
+ # session callback.
240
+ if to_agent in self._registered:
241
+ self._refresh_registered_inbox(to_agent)
242
+ return None
243
+ self._inboxes.setdefault(to_agent, []).append(msg)
244
+
245
+ # 3. Push into queue (with backpressure)
246
+ queue = self._queues.get(to_agent)
247
+ if queue is not None and (
248
+ to_agent in self._registered
249
+ and self._registration_epochs.get(to_agent, 0) == registration_epoch
250
+ ):
251
+ if queue.full():
252
+ # Drop oldest unread message
253
+ try:
254
+ dropped = queue.get_nowait()
255
+ logger.warning(
256
+ "Backpressure: dropped message %s from %s to %s in team %s",
257
+ dropped.id, dropped.from_agent, to_agent, self._team_name,
258
+ )
259
+ except asyncio.QueueEmpty:
260
+ pass
261
+ queue.put_nowait(msg.model_copy(deep=True))
262
+
263
+ # 4. Inject into session (delivery)
264
+ if self._inject_fn and (
265
+ to_agent in self._registered
266
+ and self._registration_epochs.get(to_agent, 0) == registration_epoch
267
+ ):
268
+ try:
269
+ await self._inject_fn(to_agent, from_agent, text)
270
+ except Exception:
271
+ logger.warning("Failed to inject message into session for %s", to_agent, exc_info=True)
272
+
273
+ # 5. Auto-wake idle recipient
274
+ if self._wake_fn and (
275
+ to_agent in self._registered
276
+ and self._registration_epochs.get(to_agent, 0) == registration_epoch
277
+ ):
278
+ try:
279
+ await self._wake_fn(to_agent, from_agent)
280
+ except Exception:
281
+ logger.warning("Failed to auto-wake %s", to_agent, exc_info=True)
282
+
283
+ return msg
284
+
285
+ def registration_epoch(self, agent_id: str) -> int | None:
286
+ """Return the current registration generation for *agent_id*.
287
+
288
+ The generation is an internal lifecycle fence used by TeamManager to
289
+ prevent an authorization made for one runtime from being delivered to
290
+ a replacement that reuses the same agent id. ``None`` means the agent
291
+ is not currently registered.
292
+ """
293
+ if agent_id not in self._registered:
294
+ return None
295
+ return self._registration_epochs.get(agent_id)
296
+
297
+ async def broadcast(
298
+ self,
299
+ *,
300
+ from_agent: str,
301
+ text: str,
302
+ msg_type: str = "text",
303
+ ) -> list[TeamMessage]:
304
+ """Broadcast a message to all registered agents except the sender."""
305
+ messages: list[TeamMessage] = []
306
+ for agent_id in list(self._registered):
307
+ if agent_id == from_agent:
308
+ continue
309
+ msg = await self.send(
310
+ from_agent=from_agent,
311
+ to_agent=agent_id,
312
+ text=text,
313
+ msg_type=msg_type,
314
+ )
315
+ if msg is not None:
316
+ messages.append(msg)
317
+ return messages
318
+
319
+ # ------------------------------------------------------------------
320
+ # Reading
321
+ # ------------------------------------------------------------------
322
+
323
+ def get_unread(self, agent_id: str) -> list[TeamMessage]:
324
+ """Get all unread messages for an agent from its inbox."""
325
+ if agent_id in self._registered and self._storage_root is not None:
326
+ # Another bus/process may have appended since this instance was
327
+ # registered. Keep query APIs from serving a permanently stale
328
+ # in-memory snapshot; real-time receive() remains queue-based.
329
+ self._refresh_registered_inbox(agent_id)
330
+ inbox = self._inboxes.get(agent_id, [])
331
+ # Do not expose mutable in-memory records: callers changing ``read``
332
+ # directly would bypass receipt generation and durable persistence.
333
+ return [msg.model_copy(deep=True) for msg in inbox if not msg.read]
334
+
335
+ def get_all(self, agent_id: str) -> list[TeamMessage]:
336
+ """Get all messages for an agent (read and unread)."""
337
+ if agent_id in self._registered and self._storage_root is not None:
338
+ self._refresh_registered_inbox(agent_id)
339
+ return [msg.model_copy(deep=True) for msg in self._inboxes.get(agent_id, [])]
340
+
341
+ async def mark_read(self, agent_id: str, message_ids: list[str] | None = None) -> int:
342
+ """Mark messages as read. If message_ids is None, mark all as read.
343
+
344
+ Returns the number of messages marked read.
345
+ Also sends delivery receipts back to the senders.
346
+ """
347
+ if agent_id not in self._registered:
348
+ return 0
349
+ # As with send(), fence a read acknowledgement that was started by a
350
+ # previous registration if it waits behind an in-flight rewrite.
351
+ initial_epoch = self._registration_epochs.get(agent_id)
352
+ if initial_epoch is None:
353
+ return 0
354
+ count = 0
355
+ # Group by sender for batched delivery receipts
356
+ # Keep the sender registration epoch with each receipt batch. A
357
+ # sender id can be reused while this method is awaiting the inbox
358
+ # rewrite; an acknowledgement for the old runtime must not be sent to
359
+ # the replacement runtime.
360
+ receipt_by_sender: dict[tuple[str, int], list[str]] = {}
361
+
362
+ # Mutate and persist under the same recipient lock used by send().
363
+ lock = self._inbox_locks.setdefault(agent_id, asyncio.Lock())
364
+ async with lock:
365
+ # Re-check after waiting on the lock. unregister_agent() can run
366
+ # synchronously while another coroutine is persisting a message.
367
+ if (
368
+ agent_id not in self._registered
369
+ or self._registration_epochs.get(agent_id) != initial_epoch
370
+ ):
371
+ return 0
372
+ registration_epoch = initial_epoch
373
+ live_refs_by_id = {
374
+ message.id: message
375
+ for message in self._inboxes.get(agent_id, [])
376
+ }
377
+ if self._storage_root is not None:
378
+ # Another bus/process may have committed messages after this
379
+ # registration loaded its in-memory snapshot. Refresh at the
380
+ # acknowledgement's linearization point so ``mark all`` (and
381
+ # explicit ids) cannot silently leave already-durable messages
382
+ # unread. A later concurrent append remains correctly outside
383
+ # this acknowledgement and is merged by _write_content().
384
+ self._refresh_registered_inbox(agent_id)
385
+ staged_inbox = [
386
+ message.model_copy(deep=True)
387
+ for message in self._inboxes.get(agent_id, [])
388
+ ]
389
+ for msg in staged_inbox:
390
+ if msg.read:
391
+ continue
392
+ if message_ids is not None and msg.id not in message_ids:
393
+ continue
394
+ msg.read = True
395
+ count += 1
396
+ # A receipt is itself a message. Generating a receipt for it
397
+ # would make two agents acknowledge acknowledgements forever.
398
+ sender_epoch = self._registration_epochs.get(msg.from_agent)
399
+ if (
400
+ msg.from_agent
401
+ and sender_epoch is not None
402
+ and msg.msg_type != "delivery_receipt"
403
+ ):
404
+ receipt_by_sender.setdefault(
405
+ (msg.from_agent, sender_epoch),
406
+ [],
407
+ ).append(msg.id)
408
+ persisted_inbox = await self._persist_inbox(
409
+ agent_id,
410
+ inbox=staged_inbox,
411
+ )
412
+ if persisted_inbox is None:
413
+ # Keep the original in-memory read state and suppress receipts
414
+ # when the durable acknowledgement could not be committed.
415
+ return 0
416
+ if (
417
+ agent_id in self._registered
418
+ and self._registration_epochs.get(agent_id) == registration_epoch
419
+ ):
420
+ # Preserve references returned by send(): historically callers
421
+ # observe their TeamMessage instance transition to read after a
422
+ # successful acknowledgement. Apply that mutation only after
423
+ # persistence succeeds, while also adopting externally merged
424
+ # messages from the committed snapshot.
425
+ existing_by_id = {
426
+ message.id: message
427
+ for message in self._inboxes.get(agent_id, [])
428
+ }
429
+ # Keep objects returned by send() live across the disk refresh
430
+ # above; callers historically observe their ``read`` field
431
+ # transition after a successful acknowledgement.
432
+ existing_by_id.update(live_refs_by_id)
433
+ committed_inbox: list[TeamMessage] = []
434
+ for persisted in persisted_inbox:
435
+ existing = existing_by_id.get(persisted.id)
436
+ if existing is None:
437
+ committed_inbox.append(persisted)
438
+ continue
439
+ existing.read = persisted.read
440
+ committed_inbox.append(existing)
441
+ self._inboxes[agent_id] = committed_inbox
442
+
443
+ # The recipient may have been unregistered while the executor was
444
+ # rewriting its file. Do not emit receipts from a stale registration.
445
+ if (
446
+ agent_id not in self._registered
447
+ or self._registration_epochs.get(agent_id, 0) != registration_epoch
448
+ ):
449
+ if agent_id in self._registered:
450
+ self._refresh_registered_inbox(agent_id)
451
+ return count
452
+
453
+ # Send delivery receipts
454
+ for (sender_id, sender_epoch), read_ids in receipt_by_sender.items():
455
+ if (
456
+ sender_id not in self._registered
457
+ or self._registration_epochs.get(sender_id) != sender_epoch
458
+ ):
459
+ continue
460
+ receipt_text = f"Messages read by {agent_id}: {', '.join(read_ids[:10])}"
461
+ if len(read_ids) > 10:
462
+ receipt_text += f" ... and {len(read_ids) - 10} more"
463
+ try:
464
+ await self.send(
465
+ from_agent=agent_id,
466
+ to_agent=sender_id,
467
+ text=receipt_text,
468
+ msg_type="delivery_receipt",
469
+ expected_epoch=sender_epoch,
470
+ )
471
+ except Exception:
472
+ logger.warning("Failed to send delivery receipt to %s", sender_id, exc_info=True)
473
+
474
+ return count
475
+
476
+ # ------------------------------------------------------------------
477
+ # Queue-based receive (for async consumers)
478
+ # ------------------------------------------------------------------
479
+
480
+ async def receive(self, agent_id: str, timeout: float | None = None) -> TeamMessage | None:
481
+ """Wait for the next message for an agent from its queue."""
482
+ queue = self._queues.get(agent_id)
483
+ registration_event = self._registration_events.get(agent_id)
484
+ if queue is None or registration_event is None:
485
+ return None
486
+ queue_task = asyncio.create_task(queue.get())
487
+ invalidated_task = asyncio.create_task(registration_event.wait())
488
+ try:
489
+ done, pending = await asyncio.wait(
490
+ {queue_task, invalidated_task},
491
+ timeout=timeout,
492
+ return_when=asyncio.FIRST_COMPLETED,
493
+ )
494
+ for task in pending:
495
+ task.cancel()
496
+ if pending:
497
+ await asyncio.gather(*pending, return_exceptions=True)
498
+ if not done or invalidated_task in done:
499
+ return None
500
+ if queue_task not in done:
501
+ return None
502
+ return queue_task.result().model_copy(deep=True)
503
+ except asyncio.CancelledError:
504
+ for task in (queue_task, invalidated_task):
505
+ if not task.done():
506
+ task.cancel()
507
+ await asyncio.gather(queue_task, invalidated_task, return_exceptions=True)
508
+ raise
509
+
510
+ def try_receive(self, agent_id: str) -> TeamMessage | None:
511
+ """Non-blocking receive. Returns None if no message is available."""
512
+ queue = self._queues.get(agent_id)
513
+ if queue is None:
514
+ return None
515
+ try:
516
+ return queue.get_nowait().model_copy(deep=True)
517
+ except asyncio.QueueEmpty:
518
+ return None
519
+
520
+ # ------------------------------------------------------------------
521
+ # Persistence
522
+ # ------------------------------------------------------------------
523
+
524
+ def _inbox_path(self, agent_id: str) -> Path | None:
525
+ """Return the JSONL inbox path for an agent, or None if no storage root."""
526
+ if self._storage_root is None:
527
+ return None
528
+ self._validate_component(agent_id, "agent id")
529
+ return self._storage_root / self._team_name / f"{agent_id}.jsonl"
530
+
531
+ async def _persist_message(self, agent_id: str, msg: TeamMessage) -> bool:
532
+ """Append a single message to the agent's JSONL inbox file (O(1))."""
533
+ path = self._inbox_path(agent_id)
534
+ if path is None:
535
+ return True
536
+ try:
537
+ line = msg.model_dump_json() + "\n"
538
+ # Use append mode for O(1) writes
539
+ loop = asyncio.get_event_loop()
540
+ operation = loop.run_in_executor(None, self._append_line, path, line)
541
+ try:
542
+ await asyncio.shield(operation)
543
+ except asyncio.CancelledError:
544
+ # Executor threads cannot be cancelled safely. Drain the
545
+ # write before propagating cancellation so shutdown cannot
546
+ # delete an inbox while an append is still in flight.
547
+ await asyncio.shield(operation)
548
+ raise
549
+ except Exception:
550
+ logger.warning("Failed to persist message for %s", agent_id, exc_info=True)
551
+ return False
552
+ return True
553
+
554
+ @staticmethod
555
+ def _append_line(path: Path, line: str) -> None:
556
+ # ``path`` is <storage_root>/<team>/<agent>.jsonl. Acquire the team
557
+ # lock before creating the directory; otherwise delete_team_inboxes()
558
+ # could remove it and this late append would recreate stale state.
559
+ storage_root = path.parent.parent
560
+ team_name = path.parent.name
561
+ with _team_file_lock(storage_root, team_name):
562
+ path.parent.mkdir(parents=True, exist_ok=True)
563
+ lock_path = path.with_name(f".{path.name}.lock")
564
+ with file_lock(lock_path):
565
+ with open(path, "a", encoding="utf-8") as f:
566
+ f.write(line)
567
+ f.flush()
568
+ os.fsync(f.fileno())
569
+
570
+ async def _persist_inbox(
571
+ self,
572
+ agent_id: str,
573
+ *,
574
+ inbox: list[TeamMessage] | None = None,
575
+ ) -> list[TeamMessage] | None:
576
+ """Persist and return the committed inbox, or ``None`` on failure."""
577
+ desired = inbox if inbox is not None else self._inboxes.get(agent_id, [])
578
+ path = self._inbox_path(agent_id)
579
+ if path is None:
580
+ return [message.model_copy(deep=True) for message in desired]
581
+ try:
582
+ lines = [msg.model_dump_json() + "\n" for msg in desired]
583
+ content = "".join(lines)
584
+ loop = asyncio.get_event_loop()
585
+ operation = loop.run_in_executor(None, self._write_content, path, content)
586
+ try:
587
+ committed = await asyncio.shield(operation)
588
+ except asyncio.CancelledError:
589
+ committed = await asyncio.shield(operation)
590
+ raise
591
+ return self._parse_inbox_content(agent_id, committed)
592
+ except Exception:
593
+ logger.warning("Failed to persist inbox for %s", agent_id, exc_info=True)
594
+ return None
595
+
596
+ @staticmethod
597
+ def _write_content(path: Path, content: str) -> str:
598
+ storage_root = path.parent.parent
599
+ team_name = path.parent.name
600
+ with _team_file_lock(storage_root, team_name):
601
+ path.parent.mkdir(parents=True, exist_ok=True)
602
+ lock_path = path.with_name(f".{path.name}.lock")
603
+ with file_lock(lock_path):
604
+ # Merge lines appended by another bus/process since this
605
+ # instance loaded its inbox. A plain rewrite would otherwise
606
+ # erase those messages even though append and rewrite each
607
+ # held a file lock.
608
+ current = (
609
+ path.read_text(encoding="utf-8")
610
+ if path.exists()
611
+ else ""
612
+ )
613
+ merged = TeamMessageBus._merge_jsonl(current, content)
614
+
615
+ # Replace atomically so a crash or concurrent reader never
616
+ # observes a half-written JSONL file.
617
+ tmp_path: str | None = None
618
+ try:
619
+ with tempfile.NamedTemporaryFile(
620
+ mode="w",
621
+ encoding="utf-8",
622
+ dir=path.parent,
623
+ prefix=f".{path.name}.",
624
+ suffix=".tmp",
625
+ delete=False,
626
+ ) as f:
627
+ tmp_path = f.name
628
+ f.write(merged)
629
+ f.flush()
630
+ os.fsync(f.fileno())
631
+ os.replace(tmp_path, path)
632
+ tmp_path = None
633
+ finally:
634
+ if tmp_path is not None:
635
+ try:
636
+ os.unlink(tmp_path)
637
+ except OSError:
638
+ pass
639
+ return merged
640
+
641
+ @staticmethod
642
+ def _merge_jsonl(current: str, desired: str) -> str:
643
+ """Merge two inbox snapshots by message id while preserving order."""
644
+ desired_by_id: dict[str, str] = {}
645
+ desired_unkeyed: list[str] = []
646
+ for raw in desired.splitlines():
647
+ if not raw.strip():
648
+ continue
649
+ try:
650
+ message_id = str(json.loads(raw).get("id", ""))
651
+ except Exception:
652
+ message_id = ""
653
+ if message_id:
654
+ desired_by_id[message_id] = raw
655
+ else:
656
+ desired_unkeyed.append(raw)
657
+
658
+ merged: list[str] = []
659
+ for raw in current.splitlines():
660
+ if not raw.strip():
661
+ continue
662
+ try:
663
+ message_id = str(json.loads(raw).get("id", ""))
664
+ except Exception:
665
+ message_id = ""
666
+ if message_id and message_id in desired_by_id:
667
+ merged.append(
668
+ TeamMessageBus._merge_json_line(
669
+ raw,
670
+ desired_by_id.pop(message_id),
671
+ )
672
+ )
673
+ else:
674
+ merged.append(raw)
675
+ merged.extend(desired_by_id.values())
676
+ merged.extend(desired_unkeyed)
677
+ return "".join(f"{line}\n" for line in merged)
678
+
679
+ @staticmethod
680
+ def _merge_json_line(current: str, desired: str) -> str:
681
+ """Merge a line while keeping the monotonic read acknowledgement."""
682
+ try:
683
+ current_obj = json.loads(current)
684
+ desired_obj = json.loads(desired)
685
+ if current_obj.get("read"):
686
+ desired_obj["read"] = True
687
+ return json.dumps(desired_obj, ensure_ascii=False, separators=(",", ":"))
688
+ except Exception:
689
+ return desired
690
+
691
+ def load_inbox_from_disk(self, agent_id: str) -> list[TeamMessage]:
692
+ """Load messages from an agent's JSONL inbox file on disk."""
693
+ self._validate_component(agent_id, "agent id")
694
+ path = self._inbox_path(agent_id)
695
+ if path is None or not path.exists():
696
+ self._inboxes[agent_id] = []
697
+ return []
698
+ messages: list[TeamMessage] = []
699
+ try:
700
+ # Read under the same sidecar lock used by append/rewrite. Without
701
+ # this, a second process can observe a partially appended JSON line,
702
+ # skip it as invalid, and later rewrite it away permanently.
703
+ content = self._read_content(path)
704
+ messages = self._parse_inbox_content(agent_id, content)
705
+ except OSError:
706
+ logger.warning("Failed to read inbox for %s", agent_id, exc_info=True)
707
+ self._inboxes[agent_id] = messages
708
+ return messages
709
+
710
+ @staticmethod
711
+ def _parse_inbox_content(agent_id: str, content: str) -> list[TeamMessage]:
712
+ """Parse a persisted inbox while tolerating isolated corrupt lines."""
713
+ messages: list[TeamMessage] = []
714
+ for line in content.splitlines():
715
+ line = line.strip()
716
+ if not line:
717
+ continue
718
+ try:
719
+ messages.append(TeamMessage.model_validate_json(line))
720
+ except Exception:
721
+ logger.debug(
722
+ "Skipping invalid inbox line for %s",
723
+ agent_id,
724
+ exc_info=True,
725
+ )
726
+ return messages
727
+
728
+ def _refresh_registered_inbox(self, agent_id: str) -> None:
729
+ """Merge durable messages into a replacement registration.
730
+
731
+ A stale append/rewrite may finish after ``register_agent`` has loaded
732
+ the file. Refresh synchronously at that boundary and enqueue any new
733
+ unread records, while preserving read acknowledgements already held in
734
+ memory. This never invokes the old session's injection callback.
735
+ """
736
+ if agent_id not in self._registered:
737
+ return
738
+ queue = self._queues.get(agent_id)
739
+ queued_before_refresh = {
740
+ getattr(message, "id", "")
741
+ for message in list(getattr(queue, "_queue", ()))
742
+ } if queue is not None else set()
743
+ existing = [
744
+ message.model_copy(deep=True)
745
+ for message in self._inboxes.get(agent_id, [])
746
+ ]
747
+ disk = self.load_inbox_from_disk(agent_id)
748
+ merged_by_id: dict[str, TeamMessage] = {}
749
+ order: list[str] = []
750
+ for message in [*existing, *disk]:
751
+ if message.id not in merged_by_id:
752
+ merged_by_id[message.id] = message.model_copy(deep=True)
753
+ order.append(message.id)
754
+ continue
755
+ current = merged_by_id[message.id]
756
+ merged_by_id[message.id] = message.model_copy(deep=True)
757
+ merged_by_id[message.id].read = current.read or message.read
758
+ merged = [merged_by_id[message_id] for message_id in order]
759
+ self._inboxes[agent_id] = merged
760
+
761
+ if queue is None:
762
+ return
763
+ queued_ids = {
764
+ getattr(message, "id", "")
765
+ for message in list(getattr(queue, "_queue", ()))
766
+ } | queued_before_refresh
767
+ for message in merged:
768
+ if message.read or message.id in queued_ids:
769
+ continue
770
+ if queue.full():
771
+ break
772
+ queue.put_nowait(message.model_copy(deep=True))
773
+ queued_ids.add(message.id)
774
+
775
+ @staticmethod
776
+ def _read_content(path: Path) -> str:
777
+ """Read a JSONL file while sharing its process/POSIX lock."""
778
+ storage_root = path.parent.parent
779
+ team_name = path.parent.name
780
+ with _team_file_lock(storage_root, team_name, shared=True):
781
+ lock_path = path.with_name(f".{path.name}.lock")
782
+ with file_lock(lock_path, exclusive=False):
783
+ return path.read_text(encoding="utf-8")
784
+
785
+ # ------------------------------------------------------------------
786
+ # Cleanup
787
+ # ------------------------------------------------------------------
788
+
789
+ async def delete_team_inboxes(self) -> None:
790
+ """Delete all inbox files for this team."""
791
+ if self._storage_root is None:
792
+ return
793
+ team_dir = self._storage_root / self._team_name
794
+ loop = asyncio.get_event_loop()
795
+ operation = loop.run_in_executor(None, self._remove_dir, team_dir)
796
+ try:
797
+ await asyncio.shield(operation)
798
+ except asyncio.CancelledError:
799
+ # rmtree runs in a thread and cannot be cancelled safely. Drain
800
+ # it before propagating cancellation so a caller cannot reuse the
801
+ # team directory while deletion is still in progress.
802
+ await asyncio.shield(operation)
803
+ raise
804
+
805
+ def _remove_dir(self, path: Path) -> None:
806
+ import shutil
807
+ with _team_file_lock(self._storage_root or path.parent, self._team_name):
808
+ if path.exists():
809
+ shutil.rmtree(path, ignore_errors=True)
810
+
811
+ @property
812
+ def team_name(self) -> str:
813
+ return self._team_name
814
+
815
+ @property
816
+ def config(self) -> TeamConfig:
817
+ return self._config
818
+
819
+ @property
820
+ def registered_agents(self) -> set[str]:
821
+ return set(self._registered)