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 +6 -0
- agent_bus/audit.py +125 -0
- agent_bus/chat_tui.py +384 -0
- agent_bus/cli.py +612 -0
- agent_bus/formatting.py +371 -0
- agent_bus/hooks.py +107 -0
- agent_bus/init_cmd.py +460 -0
- agent_bus/paths.py +27 -0
- agent_bus/server.py +138 -0
- agent_bus/storage.py +577 -0
- agent_bus/wake.py +171 -0
- agent_group_mcp-0.4.2.dist-info/METADATA +642 -0
- agent_group_mcp-0.4.2.dist-info/RECORD +17 -0
- agent_group_mcp-0.4.2.dist-info/WHEEL +5 -0
- agent_group_mcp-0.4.2.dist-info/entry_points.txt +2 -0
- agent_group_mcp-0.4.2.dist-info/licenses/LICENSE +21 -0
- agent_group_mcp-0.4.2.dist-info/top_level.txt +1 -0
agent_bus/__init__.py
ADDED
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()
|