agent-group-mcp 0.4.2__py3-none-any.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.
agent_bus/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Agent bus: local MCP server + CLI for inter-agent messaging."""
2
+
3
+ __version__ = "0.4.2"
4
+
5
+ DEFAULT_DB_PATH = "~/.claude-agent-bus/bus.db"
6
+ DEFAULT_AUDIT_PATH = "~/.claude-agent-bus/audit.log"
agent_bus/audit.py ADDED
@@ -0,0 +1,125 @@
1
+ """Append-only JSON-lines audit log.
2
+
3
+ The audit log is the source of truth for recovery. Bodies live in SQLite —
4
+ the log keeps a 200-char preview plus a full sha256 hash so it stays
5
+ grep-friendly while remaining tamper-evident.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Iterable, Iterator, Literal
16
+
17
+ from .paths import audit_path, ensure_parents
18
+
19
+ AuditOp = Literal["send", "read", "deliver"]
20
+
21
+ PREVIEW_LIMIT = 200
22
+
23
+
24
+ def _utc_now_iso() -> str:
25
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
26
+
27
+
28
+ def body_sha256(body: str) -> str:
29
+ return hashlib.sha256(body.encode("utf-8")).hexdigest()
30
+
31
+
32
+ def body_preview(body: str) -> str:
33
+ return body if len(body) <= PREVIEW_LIMIT else body[:PREVIEW_LIMIT]
34
+
35
+
36
+ def append(
37
+ op: AuditOp,
38
+ *,
39
+ actor: str,
40
+ message_id: str,
41
+ from_agent: str,
42
+ to_agent: str,
43
+ thread_id: str | None,
44
+ body: str,
45
+ ts: str | None = None,
46
+ log_path: Path | None = None,
47
+ ) -> dict:
48
+ """Append one audit row. Returns the row dict (useful for tests).
49
+
50
+ Writes are O_APPEND so multiple processes can write concurrently
51
+ without locking on POSIX.
52
+ """
53
+ row = {
54
+ "ts": ts or _utc_now_iso(),
55
+ "op": op,
56
+ "actor": actor,
57
+ "message_id": message_id,
58
+ "from": from_agent,
59
+ "to": to_agent,
60
+ "thread_id": thread_id,
61
+ "body_preview": body_preview(body),
62
+ "body_sha256": body_sha256(body),
63
+ }
64
+ target = log_path or audit_path()
65
+ ensure_parents(target)
66
+ line = json.dumps(row, ensure_ascii=False) + "\n"
67
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
68
+ try:
69
+ os.write(fd, line.encode("utf-8"))
70
+ finally:
71
+ os.close(fd)
72
+ return row
73
+
74
+
75
+ def tail(limit: int = 50, *, log_path: Path | None = None) -> list[dict]:
76
+ """Return up to `limit` most-recent audit rows, oldest first.
77
+
78
+ Skips malformed lines silently so a partial write can't poison reads.
79
+ """
80
+ target = log_path or audit_path()
81
+ if not target.exists():
82
+ return []
83
+ with target.open("r", encoding="utf-8", errors="replace") as f:
84
+ lines = f.readlines()
85
+ out: list[dict] = []
86
+ for line in lines[-limit:]:
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ try:
91
+ out.append(json.loads(line))
92
+ except json.JSONDecodeError:
93
+ continue
94
+ return out
95
+
96
+
97
+ def iter_all(*, log_path: Path | None = None) -> Iterator[dict]:
98
+ target = log_path or audit_path()
99
+ if not target.exists():
100
+ return iter([])
101
+ def _gen() -> Iterator[dict]:
102
+ with target.open("r", encoding="utf-8", errors="replace") as f:
103
+ for line in f:
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ yield json.loads(line)
109
+ except json.JSONDecodeError:
110
+ continue
111
+ return _gen()
112
+
113
+
114
+ def append_many(rows: Iterable[dict], *, log_path: Path | None = None) -> None:
115
+ """Atomically append a batch of pre-built rows. Used for fan-out sends."""
116
+ target = log_path or audit_path()
117
+ ensure_parents(target)
118
+ payload = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)
119
+ if not payload:
120
+ return
121
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
122
+ try:
123
+ os.write(fd, payload.encode("utf-8"))
124
+ finally:
125
+ os.close(fd)
agent_bus/chat_tui.py ADDED
@@ -0,0 +1,384 @@
1
+ """Chat TUI for a human participant on the bus.
2
+
3
+ Uses prompt_toolkit's PromptSession + patch_stdout() so the reader
4
+ thread can print incoming messages without ever clobbering the input
5
+ line you're typing on. A bottom toolbar always shows your identity,
6
+ the default target, and the number of unread messages in case the
7
+ reader thread is paused.
8
+
9
+ Default identity is `human` (override via AGENT_BUS_NAME or --name).
10
+ The human is registered just like any other agent so peers can address
11
+ them with `send_message(to="human", ...)`.
12
+
13
+ Commands inside the TUI:
14
+ @<name> <body> direct message to one peer
15
+ /to <name> set default target ('*' or 'all' for broadcast)
16
+ /agents list known agents + unread counts
17
+ /thread <id> reprint a thread (full or 8-char prefix)
18
+ /history [N] reprint last N audit `send` rows
19
+ /help, /quit, /exit
20
+ plain text send to current default target
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shlex
27
+ import sys
28
+ import threading
29
+ import time
30
+ from pathlib import Path
31
+
32
+ from prompt_toolkit import HTML, PromptSession, print_formatted_text
33
+ from prompt_toolkit.completion import WordCompleter
34
+ from prompt_toolkit.formatted_text import FormattedText
35
+ from prompt_toolkit.history import FileHistory
36
+ from prompt_toolkit.patch_stdout import patch_stdout
37
+ from prompt_toolkit.shortcuts import clear as pt_clear
38
+ from prompt_toolkit.styles import Style
39
+
40
+ from . import audit
41
+ from . import formatting as fmt
42
+ from .paths import audit_path
43
+ from .storage import BROADCAST, Message, Storage
44
+
45
+ POLL_SECONDS = 0.4
46
+ DEFAULT_HISTORY_TAIL = 10
47
+ NAME_COL = 18
48
+ TARGET_COL = 18
49
+
50
+ HELP_LINES = (
51
+ "agent-bus chat — commands:",
52
+ " @<name> <body> direct message to one peer",
53
+ " /to <name|*|all> set default target",
54
+ " /agents list known agents and unread counts",
55
+ " /thread <id> reprint a thread by 8-char id (or full)",
56
+ " /history [N] reprint last N messages (default 10)",
57
+ " /clear clear screen",
58
+ " /help this help",
59
+ " /quit, /exit leave",
60
+ " plain text send to current default target",
61
+ "",
62
+ " Note: Claude Code sessions can't be externally woken while idle.",
63
+ " Have each agent run `/loop 60s drain the agent-bus inbox` so peer",
64
+ " messages get reacted to within one poll cycle even when nobody is",
65
+ " typing. Alternatively: switch to the agent's terminal and press",
66
+ " Enter — the UserPromptSubmit hook will surface pending messages.",
67
+ )
68
+
69
+
70
+ class ChatTUI:
71
+ def __init__(
72
+ self,
73
+ *,
74
+ name: str,
75
+ repo_path: str,
76
+ storage: Storage | None = None,
77
+ poll_seconds: float = POLL_SECONDS,
78
+ ):
79
+ self.name = name
80
+ self.repo_path = repo_path
81
+ self.store = storage or Storage()
82
+ self.poll_seconds = poll_seconds
83
+ self.default_target = BROADCAST
84
+ self._stop = threading.Event()
85
+ self._lock = threading.Lock()
86
+ self._unread = 0 # only used for the bottom toolbar display
87
+
88
+ # --------------------------- rendering helpers -------------------------
89
+
90
+ def _style(self) -> Style:
91
+ agents = {a.name for a in self.store.list_agents()}
92
+ agents.add(self.name)
93
+ return Style.from_dict(fmt.style_map(agents))
94
+
95
+ def _emit(self, fragments: list[fmt.Fragment]) -> None:
96
+ print_formatted_text(FormattedText(fragments), style=self._style())
97
+
98
+ def _system(self, text: str) -> None:
99
+ print_formatted_text(FormattedText([("class:system", text)]), style=self._style())
100
+
101
+ # --------------------------- on-connect view ---------------------------
102
+
103
+ def _print_connect_banner(self) -> None:
104
+ print_formatted_text(
105
+ FormattedText([
106
+ ("class:prompt", "agent-bus chat "),
107
+ ("class:system",
108
+ f"— connected as {self.name!r} "
109
+ f"(default → {self.default_target}). /help for commands."),
110
+ ]),
111
+ style=self._style(),
112
+ )
113
+
114
+ def _print_agent_roster(self) -> None:
115
+ rows = self.store.list_agents_with_counts()
116
+ if not rows:
117
+ self._system("(no other agents registered yet)")
118
+ return
119
+ self._system(f"agents on the bus ({len(rows)}):")
120
+ for agent, count in rows:
121
+ tag = " (you)" if agent.name == self.name else ""
122
+ seen = fmt.humanize_relative(agent.last_seen)
123
+ self._emit([
124
+ ("", " "),
125
+ (f"class:agent-{agent.name}", agent.name.ljust(NAME_COL)),
126
+ ("class:system", f" last seen {seen}{tag}"),
127
+ ("class:thread", f" pending={count}"),
128
+ ])
129
+
130
+ def _print_recent_activity(self, n: int = DEFAULT_HISTORY_TAIL) -> None:
131
+ msgs = self.store.recent_messages(limit=n)
132
+ if not msgs:
133
+ return
134
+ self._system(f"recent activity (last {len(msgs)} message(s)):")
135
+ width = fmt.terminal_width()
136
+ for m in msgs:
137
+ for line in fmt.fragments_for_message_block(
138
+ sent_at=m.sent_at,
139
+ from_agent=m.from_agent,
140
+ to_agent=m.to_agent,
141
+ body=m.body,
142
+ thread_id=m.thread_id,
143
+ width=width,
144
+ ):
145
+ self._emit(line)
146
+
147
+ # --------------------------- reader thread -----------------------------
148
+
149
+ def _reader_loop(self) -> None:
150
+ while not self._stop.is_set():
151
+ try:
152
+ msgs = self.store.read_inbox(
153
+ agent=self.name,
154
+ mark_read=True,
155
+ actor=self.name,
156
+ also_deliver=True,
157
+ )
158
+ except Exception as e:
159
+ self._system(f"[reader] {e}")
160
+ self._stop.wait(self.poll_seconds)
161
+ continue
162
+ for m in msgs:
163
+ self._print_incoming(m)
164
+ with self._lock:
165
+ self._unread = self.store.pending_count(agent=self.name)
166
+ self._stop.wait(self.poll_seconds)
167
+
168
+ def _print_incoming(self, m: Message) -> None:
169
+ for line in fmt.fragments_for_message_block(
170
+ sent_at=m.sent_at,
171
+ from_agent=m.from_agent,
172
+ to_agent=m.to_agent,
173
+ body=m.body,
174
+ thread_id=m.thread_id,
175
+ width=fmt.terminal_width(),
176
+ ):
177
+ self._emit(line)
178
+
179
+ # --------------------------- send helpers ------------------------------
180
+
181
+ def _send(self, to: str, body: str) -> None:
182
+ if not body.strip():
183
+ return
184
+ try:
185
+ result = self.store.send_message(
186
+ from_agent=self.name,
187
+ to=to,
188
+ body=body,
189
+ actor=self.name,
190
+ )
191
+ except Exception as e:
192
+ self._system(f"[send] {e}")
193
+ return
194
+ ts = result.get("sent_at", "")
195
+ thread = result.get("thread_id")
196
+ is_broadcast = "message_ids" in result
197
+ if is_broadcast:
198
+ recipients = result.get("recipients", [])
199
+ if not recipients:
200
+ self._system("(no peers connected — message dropped)")
201
+ return
202
+ target_label = f"all ({len(recipients)})"
203
+ target_class = "broadcast"
204
+ else:
205
+ target_label = to
206
+ target_class = None # use default (safe_class(to))
207
+
208
+ for line in fmt.fragments_for_message_block(
209
+ sent_at=ts,
210
+ from_agent=self.name,
211
+ to_agent=target_label,
212
+ body=body,
213
+ thread_id=thread,
214
+ width=fmt.terminal_width(),
215
+ target_class=target_class,
216
+ ):
217
+ self._emit(line)
218
+
219
+ # --------------------------- command parser ---------------------------
220
+
221
+ def _resolve_thread(self, prefix: str) -> str | None:
222
+ """Allow `/thread abcd1234` (short form) by scanning audit log."""
223
+ if len(prefix) == 36: # full UUID
224
+ return prefix
225
+ for row in reversed(audit.tail(limit=1000)):
226
+ tid = row.get("thread_id") or ""
227
+ if tid.startswith(prefix):
228
+ return tid
229
+ return None
230
+
231
+ def _handle_command(self, line: str) -> bool:
232
+ try:
233
+ parts = shlex.split(line)
234
+ except ValueError as e:
235
+ self._system(f"(unbalanced quoting: {e})")
236
+ return True
237
+ if not parts:
238
+ return True
239
+ cmd = parts[0].lower()
240
+ if cmd in ("/quit", "/exit"):
241
+ return False
242
+ if cmd == "/help":
243
+ for line in HELP_LINES:
244
+ self._system(line)
245
+ return True
246
+ if cmd == "/clear":
247
+ pt_clear()
248
+ self._print_connect_banner()
249
+ return True
250
+ if cmd == "/agents":
251
+ self._print_agent_roster()
252
+ return True
253
+ if cmd == "/to":
254
+ if len(parts) < 2:
255
+ self._system("usage: /to <name|*|all>")
256
+ return True
257
+ target = parts[1]
258
+ if target.lower() in ("all", "*"):
259
+ target = BROADCAST
260
+ self.default_target = target
261
+ self._system(f"default target set to {target}")
262
+ return True
263
+ if cmd == "/thread":
264
+ if len(parts) < 2:
265
+ self._system("usage: /thread <id> (8-char prefix is enough)")
266
+ return True
267
+ tid = self._resolve_thread(parts[1])
268
+ if tid is None:
269
+ self._system(f"(no thread matching {parts[1]!r})")
270
+ return True
271
+ msgs = self.store.read_thread(thread_id=tid)
272
+ if not msgs:
273
+ self._system(f"(thread {fmt.short_thread(tid)} has no messages)")
274
+ return True
275
+ self._system(f"thread {fmt.short_thread(tid)} — {len(msgs)} message(s):")
276
+ width = fmt.terminal_width()
277
+ for m in msgs:
278
+ for line in fmt.fragments_for_message_block(
279
+ sent_at=m.sent_at,
280
+ from_agent=m.from_agent,
281
+ to_agent=m.to_agent,
282
+ body=m.body,
283
+ thread_id=None, # already grouped by thread header
284
+ width=width,
285
+ ):
286
+ self._emit(line)
287
+ return True
288
+ if cmd == "/history":
289
+ n = int(parts[1]) if len(parts) >= 2 else DEFAULT_HISTORY_TAIL
290
+ self._print_recent_activity(n)
291
+ return True
292
+ self._system(f"unknown command {cmd!r}; try /help")
293
+ return True
294
+
295
+ def _handle_line(self, line: str) -> bool:
296
+ if not line.strip():
297
+ return True
298
+ if line.startswith("/"):
299
+ return self._handle_command(line)
300
+ if line.startswith("@"):
301
+ head, _, body = line.partition(" ")
302
+ target = head[1:]
303
+ if not target or not body:
304
+ self._system("usage: @<name> <body>")
305
+ return True
306
+ self._send(target, body)
307
+ return True
308
+ self._send(self.default_target, line)
309
+ return True
310
+
311
+ # --------------------------- run loop ---------------------------------
312
+
313
+ def _bottom_toolbar(self):
314
+ with self._lock:
315
+ unread = self._unread
316
+ unread_part = f" <ansired>unread:{unread}</ansired>" if unread else ""
317
+ return HTML(
318
+ f" <b>{self.name}</b>"
319
+ f" → <ansicyan>{self.default_target}</ansicyan>"
320
+ f"{unread_part}"
321
+ f" <ansigray>/help · /quit</ansigray>"
322
+ )
323
+
324
+ def _prompt_html(self):
325
+ return HTML(
326
+ f"<ansicyan>[</ansicyan>"
327
+ f"<b>{self.name}</b>"
328
+ f" → <ansicyan>{self.default_target}</ansicyan>"
329
+ f"<ansicyan>]</ansicyan> "
330
+ )
331
+
332
+ def _history_path(self) -> Path:
333
+ # Stored alongside the bus.db, NOT inside any project repo. Stays
334
+ # user-local even if the repo is published.
335
+ from .paths import db_path
336
+
337
+ return db_path().parent / "chat_history"
338
+
339
+ def run(self) -> int:
340
+ self.store.upsert_agent(self.name, self.repo_path)
341
+
342
+ history_path = self._history_path()
343
+ history_path.parent.mkdir(parents=True, exist_ok=True)
344
+
345
+ completer = WordCompleter(
346
+ ["/agents", "/to", "/thread", "/history", "/help", "/clear", "/quit", "/exit"],
347
+ ignore_case=True,
348
+ )
349
+ session: PromptSession = PromptSession(
350
+ history=FileHistory(str(history_path)),
351
+ completer=completer,
352
+ complete_while_typing=False,
353
+ )
354
+
355
+ with patch_stdout():
356
+ self._print_connect_banner()
357
+ self._print_agent_roster()
358
+ self._print_recent_activity()
359
+ self._system("") # blank line before live messages
360
+
361
+ reader = threading.Thread(target=self._reader_loop, daemon=True)
362
+ reader.start()
363
+
364
+ try:
365
+ while True:
366
+ try:
367
+ line = session.prompt(
368
+ self._prompt_html(),
369
+ bottom_toolbar=self._bottom_toolbar,
370
+ refresh_interval=1.0,
371
+ )
372
+ except (EOFError, KeyboardInterrupt):
373
+ break
374
+ if not self._handle_line(line):
375
+ break
376
+ finally:
377
+ self._stop.set()
378
+ return 0
379
+
380
+
381
+ def main(name: str | None = None, repo_path: str | None = None) -> int:
382
+ name = name or os.environ.get("AGENT_BUS_NAME") or "human"
383
+ repo_path = repo_path or os.environ.get("AGENT_BUS_REPO") or str(Path.cwd())
384
+ return ChatTUI(name=name, repo_path=repo_path).run()