agent-mailbox 0.5.0__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_mailbox/__init__.py +3 -0
- agent_mailbox/__main__.py +8 -0
- agent_mailbox/cleanup.py +177 -0
- agent_mailbox/reap.py +44 -0
- agent_mailbox/server.py +268 -0
- agent_mailbox/store.py +893 -0
- agent_mailbox/watch.py +119 -0
- agent_mailbox/web.py +383 -0
- agent_mailbox/webhook.py +184 -0
- agent_mailbox-0.5.0.dist-info/METADATA +308 -0
- agent_mailbox-0.5.0.dist-info/RECORD +14 -0
- agent_mailbox-0.5.0.dist-info/WHEEL +4 -0
- agent_mailbox-0.5.0.dist-info/entry_points.txt +3 -0
- agent_mailbox-0.5.0.dist-info/licenses/LICENSE +21 -0
agent_mailbox/cleanup.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Scan a mail root for suspected test residue (dry-run by default).
|
|
2
|
+
|
|
3
|
+
``python -m agent_mailbox.cleanup --dry-run`` lists — never deletes —
|
|
4
|
+
directories and letters that look like leftovers:
|
|
5
|
+
|
|
6
|
+
- agent ``inbox/``/``archive/`` directories whose id is not in registry.json;
|
|
7
|
+
- NEWBIE/WBTEST-style test-named directories;
|
|
8
|
+
- orphan letters: stray files directly under inbox/ or archive/, JSON a
|
|
9
|
+
mailbox_list can no longer parse, and ``*.tmp`` left by an interrupted
|
|
10
|
+
atomic write.
|
|
11
|
+
|
|
12
|
+
The default mode is dry-run. Actual deletion requires explicit ``--yes`` and
|
|
13
|
+
an interactive confirmation; registered-but-test-named directories are
|
|
14
|
+
reported as review-only and never deleted. Pure stdlib.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# case-insensitive substrings that mark a directory as test residue
|
|
27
|
+
TEST_NAME_MARKERS = ("test", "newbie", "demo", "scratch")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_test_name(name: str) -> bool:
|
|
31
|
+
low = name.lower()
|
|
32
|
+
return any(marker in low for marker in TEST_NAME_MARKERS)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _human(n: int) -> str:
|
|
36
|
+
if n >= 1024 * 1024:
|
|
37
|
+
return f"{n / 1024 / 1024:.1f} MB"
|
|
38
|
+
if n >= 1024:
|
|
39
|
+
return f"{n / 1024:.1f} KB"
|
|
40
|
+
return f"{n} B"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _dir_stats(d: Path) -> tuple[int, int]:
|
|
44
|
+
files = [p for p in d.rglob("*") if p.is_file()]
|
|
45
|
+
return sum(p.stat().st_size for p in files), len(files)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan(root: Path) -> list[dict]:
|
|
49
|
+
"""Return suspected-residue findings for ``root`` (no mutation)."""
|
|
50
|
+
findings: list[dict] = []
|
|
51
|
+
agents: set[str] = set()
|
|
52
|
+
try:
|
|
53
|
+
reg = json.loads((root / "registry.json").read_text(encoding="utf-8"))
|
|
54
|
+
agents = set(reg.get("agents", {}))
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
pass # empty/missing registry: every agent dir counts as residue
|
|
57
|
+
except json.JSONDecodeError as e:
|
|
58
|
+
raise SystemExit(f"cleanup: corrupt registry.json: {e}") from e
|
|
59
|
+
|
|
60
|
+
for base_name in ("inbox", "archive"):
|
|
61
|
+
base = root / base_name
|
|
62
|
+
if not base.is_dir():
|
|
63
|
+
continue
|
|
64
|
+
for entry in sorted(base.iterdir()):
|
|
65
|
+
rel = f"{base_name}/{entry.name}"
|
|
66
|
+
if entry.is_file():
|
|
67
|
+
findings.append({
|
|
68
|
+
"kind": "orphan-letter",
|
|
69
|
+
"path": rel,
|
|
70
|
+
"size": entry.stat().st_size,
|
|
71
|
+
"reason": "stray file directly under inbox/archive (no agent dir)",
|
|
72
|
+
"action": "delete file",
|
|
73
|
+
})
|
|
74
|
+
continue
|
|
75
|
+
size, n_files = _dir_stats(entry)
|
|
76
|
+
registered = entry.name in agents
|
|
77
|
+
test_named = _is_test_name(entry.name)
|
|
78
|
+
if not registered:
|
|
79
|
+
kind = "test-named-dir" if test_named else "unregistered-dir"
|
|
80
|
+
reason = "agent not in registry.json"
|
|
81
|
+
if test_named:
|
|
82
|
+
reason += "; name matches test pattern"
|
|
83
|
+
findings.append({
|
|
84
|
+
"kind": kind, "path": rel, "size": size,
|
|
85
|
+
"reason": reason, "action": f"delete dir ({n_files} files)",
|
|
86
|
+
})
|
|
87
|
+
elif test_named:
|
|
88
|
+
findings.append({
|
|
89
|
+
"kind": "test-named-dir", "path": rel, "size": size,
|
|
90
|
+
"reason": f"test-named but registered ({n_files} files)",
|
|
91
|
+
"action": "review only — not deleted",
|
|
92
|
+
})
|
|
93
|
+
else:
|
|
94
|
+
for p in sorted(entry.glob("*.json")):
|
|
95
|
+
try:
|
|
96
|
+
json.loads(p.read_text(encoding="utf-8"))
|
|
97
|
+
except (json.JSONDecodeError, OSError):
|
|
98
|
+
findings.append({
|
|
99
|
+
"kind": "orphan-letter",
|
|
100
|
+
"path": f"{rel}/{p.name}",
|
|
101
|
+
"size": p.stat().st_size,
|
|
102
|
+
"reason": "unparseable JSON — invisible to mailbox_list",
|
|
103
|
+
"action": "delete file",
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
for p in sorted(root.glob("*.tmp")):
|
|
107
|
+
findings.append({
|
|
108
|
+
"kind": "orphan-letter",
|
|
109
|
+
"path": p.name,
|
|
110
|
+
"size": p.stat().st_size,
|
|
111
|
+
"reason": "stray *.tmp from an interrupted atomic write",
|
|
112
|
+
"action": "delete file",
|
|
113
|
+
})
|
|
114
|
+
return findings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _delete(root: Path, findings: list[dict]) -> int:
|
|
118
|
+
n = 0
|
|
119
|
+
for f in findings:
|
|
120
|
+
if not f["action"].startswith("delete"):
|
|
121
|
+
continue
|
|
122
|
+
path = root / f["path"]
|
|
123
|
+
if path.is_dir():
|
|
124
|
+
shutil.rmtree(path)
|
|
125
|
+
elif path.exists():
|
|
126
|
+
path.unlink()
|
|
127
|
+
n += 1
|
|
128
|
+
return n
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> None:
|
|
132
|
+
parser = argparse.ArgumentParser(
|
|
133
|
+
prog="agent-mailbox-cleanup",
|
|
134
|
+
description="List (default) or delete suspected test residue in a mail root.",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--root", default=None,
|
|
138
|
+
help="mail root (default $AGENT_MAIL_HOME or ~/.agent-mail)",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--dry-run", action="store_true",
|
|
142
|
+
help="list suspects only, never delete (the default behaviour)",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--yes", action="store_true",
|
|
146
|
+
help="actually delete the listed suspects (asks for confirmation)",
|
|
147
|
+
)
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
|
|
150
|
+
root = Path(args.root or os.environ.get("AGENT_MAIL_HOME", "~/.agent-mail")).expanduser()
|
|
151
|
+
if not root.is_dir():
|
|
152
|
+
print(f"cleanup: mail root not found: {root}", file=sys.stderr)
|
|
153
|
+
raise SystemExit(1)
|
|
154
|
+
|
|
155
|
+
findings = scan(root)
|
|
156
|
+
if not findings:
|
|
157
|
+
print(f"cleanup: no suspected residue in {root}")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
total = 0
|
|
161
|
+
for f in findings:
|
|
162
|
+
total += f["size"]
|
|
163
|
+
print(f"[{f['kind']}] {f['path']} {_human(f['size'])} ({f['reason']}) -> {f['action']}")
|
|
164
|
+
print(f"\n{len(findings)} finding(s), {_human(total)} total")
|
|
165
|
+
|
|
166
|
+
if args.dry_run or not args.yes:
|
|
167
|
+
print("nothing deleted (pass --yes to actually delete)")
|
|
168
|
+
return
|
|
169
|
+
answer = input(f"Delete the listed items in {root}? Type 'yes' to confirm: ")
|
|
170
|
+
if answer.strip().lower() != "yes":
|
|
171
|
+
print("aborted — nothing deleted")
|
|
172
|
+
return
|
|
173
|
+
print(f"deleted {_delete(root, findings)} item(s)")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
agent_mailbox/reap.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Reap orphaned ``acked`` mail back to ``pending`` (task t-6 wiring, v0.5).
|
|
2
|
+
|
|
3
|
+
``MailStore.check()`` moves pending -> acked and hands the letters to the
|
|
4
|
+
caller; a caller that dies, is cancelled, or was a foreign-identity check
|
|
5
|
+
leaves them acked forever, invisible to a pending-only drain. This CLI is the
|
|
6
|
+
thin shell the wake loop calls *before* counting pending, so those letters
|
|
7
|
+
come back into view instead of hiding behind a zero count.
|
|
8
|
+
|
|
9
|
+
``python -m agent_mailbox.reap --agent ZC --ttl 3600``
|
|
10
|
+
Never deletes anything: status flips back to pending and ``handled_log``
|
|
11
|
+
records a ``reclaimed`` entry. Pure stdlib.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .store import MailboxError, MailStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="agent-mailbox-reap")
|
|
24
|
+
parser.add_argument("--agent", required=True, help="mailbox to reap (defaults to AGENT_MAIL_ID when omitted)")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--ttl",
|
|
27
|
+
type=float,
|
|
28
|
+
default=3600.0,
|
|
29
|
+
help="seconds a letter may stay acked before it is reclaimed (default: 3600)",
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
reaped = MailStore().reap_stale_acked(args.agent, ttl_seconds=args.ttl)
|
|
35
|
+
except MailboxError as e:
|
|
36
|
+
print(f"reap: {e}", file=sys.stderr)
|
|
37
|
+
raise SystemExit(1) from e
|
|
38
|
+
for mid in reaped:
|
|
39
|
+
print(f"reclaimed {mid}")
|
|
40
|
+
print(f"reap: {len(reaped)} reclaimed (agent={args.agent}, ttl={args.ttl:g}s)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
agent_mailbox/server.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""agent-mailbox MCP server.
|
|
2
|
+
|
|
3
|
+
Expose a local, file-backed mailbox as MCP tools. Any MCP-capable agent on
|
|
4
|
+
this machine can register once and then message every other agent — no cron,
|
|
5
|
+
no polling daemons, no shared markdown files.
|
|
6
|
+
|
|
7
|
+
Run: ``agent-mailbox`` (stdio transport, for host apps)
|
|
8
|
+
``agent-mailbox --http 8642`` (streamable HTTP, for remote agents)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from mcp.server.mcpserver import MCPServer
|
|
19
|
+
|
|
20
|
+
from .store import MailboxError, MailStore
|
|
21
|
+
|
|
22
|
+
server = MCPServer(
|
|
23
|
+
"agent-mailbox",
|
|
24
|
+
instructions=(
|
|
25
|
+
"A global mailbox for local AI agents. Register once with mailbox_register, "
|
|
26
|
+
"then use mailbox_send / mailbox_check / mailbox_reply / mailbox_list / "
|
|
27
|
+
"mailbox_done / mailbox_broadcast. Check your inbox when you start a session "
|
|
28
|
+
"and after finishing a task — messages wait here even when the recipient is offline. "
|
|
29
|
+
"Task cards: task_create / task_move / task_list manage a shared task board; "
|
|
30
|
+
"creating or moving a card auto-messages the assignee, so board motion wakes "
|
|
31
|
+
"agents without polling."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_store: MailStore | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _store_instance() -> MailStore:
|
|
39
|
+
global _store
|
|
40
|
+
if _store is None:
|
|
41
|
+
_store = MailStore()
|
|
42
|
+
return _store
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------- tools
|
|
46
|
+
|
|
47
|
+
@server.tool()
|
|
48
|
+
def mailbox_register(agent_id: str, owner: str = "", description: str = "") -> dict:
|
|
49
|
+
"""Register this agent and claim its mailbox. Idempotent — safe to call again."""
|
|
50
|
+
return _store_instance().register(agent_id, owner, description)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@server.tool()
|
|
54
|
+
def mailbox_send(
|
|
55
|
+
to: str | list[str],
|
|
56
|
+
subject: str,
|
|
57
|
+
body: str,
|
|
58
|
+
priority: str = "normal",
|
|
59
|
+
reply_to: str | None = None,
|
|
60
|
+
from_id: str = "",
|
|
61
|
+
dedupe: bool = True,
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Send a message to one agent, a list of agents, or \"all\" for broadcast.
|
|
64
|
+
|
|
65
|
+
dedupe=True (default) suppresses a re-send of semantically identical
|
|
66
|
+
mail to a recipient whose inbox still holds it non-terminal (pending /
|
|
67
|
+
acked) within the 24h dedup window: that recipient's entry comes back as
|
|
68
|
+
{\"to\", \"deduped\": true, \"existing_id\"} with zero side effects — no
|
|
69
|
+
letter, no sent.log line, no webhook. Pass dedupe=False to exempt
|
|
70
|
+
periodic jobs. \"count\" counts only letters that actually landed.
|
|
71
|
+
"""
|
|
72
|
+
frm = from_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
73
|
+
if not frm:
|
|
74
|
+
raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
|
|
75
|
+
sent = _store_instance().send(
|
|
76
|
+
frm, to, subject, body, reply_to=reply_to, priority=priority, dedupe=dedupe
|
|
77
|
+
)
|
|
78
|
+
delivered = sum(1 for e in sent if not e.get("deduped"))
|
|
79
|
+
return {"delivered": sent, "count": delivered}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@server.tool()
|
|
83
|
+
def mailbox_check(agent_id: str = "", mark: bool = True) -> dict:
|
|
84
|
+
"""Fetch your pending messages (they become acked). Call at session start."""
|
|
85
|
+
me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
86
|
+
if not me:
|
|
87
|
+
raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
|
|
88
|
+
msgs = _store_instance().check(me, mark=mark)
|
|
89
|
+
return {"agent_id": me, "unread": len(msgs), "messages": msgs}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@server.tool()
|
|
93
|
+
def mailbox_reply(msg_id: str, body: str, agent_id: str = "") -> dict:
|
|
94
|
+
"""Reply to a message thread. Routes to the original sender automatically."""
|
|
95
|
+
me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
96
|
+
if not me:
|
|
97
|
+
raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
|
|
98
|
+
st = _store_instance()
|
|
99
|
+
mine = [m for m in st.list_messages(me) if m["id"] == msg_id]
|
|
100
|
+
from_archive = False
|
|
101
|
+
if not mine:
|
|
102
|
+
mine = [m for m in st.list_archived(me) if m["id"] == msg_id]
|
|
103
|
+
from_archive = True
|
|
104
|
+
if not mine:
|
|
105
|
+
raise MailboxError(
|
|
106
|
+
f"message {msg_id!r} not found in inbox or archive for {me!r}"
|
|
107
|
+
)
|
|
108
|
+
original = mine[0]
|
|
109
|
+
sent = st.send(
|
|
110
|
+
me,
|
|
111
|
+
original["from"],
|
|
112
|
+
f"Re: {original['subject']}",
|
|
113
|
+
body,
|
|
114
|
+
reply_to=msg_id,
|
|
115
|
+
dedupe=False, # replies are thread-addressed; keep the legacy contract
|
|
116
|
+
)
|
|
117
|
+
if from_archive:
|
|
118
|
+
# Original is already done + archived; nothing left to close.
|
|
119
|
+
return {"replied": sent[0], "closed": None, "archived_original": msg_id}
|
|
120
|
+
st.set_status(me, msg_id, "done")
|
|
121
|
+
return {"replied": sent[0], "closed": msg_id}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@server.tool()
|
|
125
|
+
def mailbox_list(agent_id: str = "", status: str | None = None) -> dict:
|
|
126
|
+
"""List messages in your mailbox, optionally filtered by status."""
|
|
127
|
+
me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
128
|
+
if not me:
|
|
129
|
+
raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
|
|
130
|
+
msgs = _store_instance().list_messages(me, status)
|
|
131
|
+
return {"agent_id": me, "count": len(msgs), "messages": msgs}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@server.tool()
|
|
135
|
+
def mailbox_done(msg_id: str, agent_id: str = "") -> dict:
|
|
136
|
+
"""Mark a message as handled. Done messages can be archived."""
|
|
137
|
+
me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
138
|
+
if not me:
|
|
139
|
+
raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
|
|
140
|
+
m = _store_instance().set_status(me, msg_id, "done")
|
|
141
|
+
n = _store_instance().archive_done(me)
|
|
142
|
+
return {"message": m["id"], "status": "done", "archived": n}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@server.tool()
|
|
146
|
+
def mailbox_broadcast(subject: str, body: str, from_id: str = "", dedupe: bool = True) -> dict:
|
|
147
|
+
"""Broadcast to every registered agent (including boss). dedupe=True
|
|
148
|
+
(default) suppresses semantically identical re-broadcasts per recipient
|
|
149
|
+
within the dedup window — see mailbox_send."""
|
|
150
|
+
frm = from_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
151
|
+
if not frm:
|
|
152
|
+
raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
|
|
153
|
+
sent = _store_instance().send(frm, "all", subject, body, priority="high", dedupe=dedupe)
|
|
154
|
+
delivered = sum(1 for e in sent if not e.get("deduped"))
|
|
155
|
+
return {"delivered": sent, "count": delivered}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@server.tool()
|
|
159
|
+
def mailbox_whoami() -> dict:
|
|
160
|
+
"""List all registered agents and the mail root location."""
|
|
161
|
+
st = _store_instance()
|
|
162
|
+
reg = st.registry()
|
|
163
|
+
return {
|
|
164
|
+
"mail_root": str(st.root),
|
|
165
|
+
"default_identity": os.environ.get("AGENT_MAIL_ID", ""),
|
|
166
|
+
"agents": reg["agents"],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@server.tool()
|
|
171
|
+
def mailbox_wait(agent_id: str = "", timeout_seconds: float = 25.0) -> dict:
|
|
172
|
+
"""Block until a new message arrives (long-poll, up to timeout). Returns
|
|
173
|
+
immediately if pending messages exist. Import 'time' is at module top."""
|
|
174
|
+
me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
175
|
+
if not me:
|
|
176
|
+
raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
|
|
177
|
+
st = _store_instance()
|
|
178
|
+
deadline = time.time() + max(1.0, min(timeout_seconds, 60.0))
|
|
179
|
+
while True:
|
|
180
|
+
msgs = st.list_messages(me, status="pending")
|
|
181
|
+
if msgs:
|
|
182
|
+
got = st.check(me, mark=True)
|
|
183
|
+
return {"agent_id": me, "received": len(got), "messages": got}
|
|
184
|
+
if time.time() >= deadline:
|
|
185
|
+
return {"agent_id": me, "received": 0, "messages": [], "timeout": True}
|
|
186
|
+
time.sleep(0.5)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------- task tools
|
|
190
|
+
|
|
191
|
+
@server.tool()
|
|
192
|
+
def task_create(
|
|
193
|
+
title: str, assignee: str, due: str = "", from_id: str = "", notify: bool = True
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Create a task card (starts at todo). The assignee is auto-messaged —
|
|
196
|
+
skip with notify=False."""
|
|
197
|
+
me = from_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
198
|
+
if not me:
|
|
199
|
+
raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
|
|
200
|
+
task = _store_instance().task_create(title, assignee, me, due, notify=notify)
|
|
201
|
+
return {"task": task}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@server.tool()
|
|
205
|
+
def task_move(
|
|
206
|
+
task_id: str,
|
|
207
|
+
status: str,
|
|
208
|
+
assignee: str | None = None,
|
|
209
|
+
note: str = "",
|
|
210
|
+
force: bool = False,
|
|
211
|
+
notify: bool = True,
|
|
212
|
+
from_id: str = "",
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Move a task along todo→doing→review→done. Skips need force=True;
|
|
215
|
+
done is terminal. Pass assignee to reassign. The (new) assignee is
|
|
216
|
+
auto-messaged — moving a card wakes its owner."""
|
|
217
|
+
me = from_id or os.environ.get("AGENT_MAIL_ID", "")
|
|
218
|
+
if not me:
|
|
219
|
+
raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
|
|
220
|
+
task = _store_instance().task_move(
|
|
221
|
+
task_id, status, moved_by=me, assignee=assignee,
|
|
222
|
+
force=force, notify=notify, note=note,
|
|
223
|
+
)
|
|
224
|
+
return {"task": task}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@server.tool()
|
|
228
|
+
def task_list(assignee: str | None = None, status: str | None = None) -> dict:
|
|
229
|
+
"""List task cards, optionally filtered by assignee and/or status."""
|
|
230
|
+
tasks = _store_instance().task_list(assignee=assignee, status=status)
|
|
231
|
+
return {"count": len(tasks), "tasks": tasks}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
# MCP stdio speaks UTF-8; on Windows/macOS CI the default console codec
|
|
236
|
+
# (cp1252 etc.) cannot encode arrows/CJK in tool output and crashes the
|
|
237
|
+
# child process before the handshake completes.
|
|
238
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
239
|
+
if _stream is not None and hasattr(_stream, "reconfigure"):
|
|
240
|
+
try:
|
|
241
|
+
_stream.reconfigure(encoding="utf-8")
|
|
242
|
+
except (OSError, ValueError):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
parser = argparse.ArgumentParser(prog="agent-mailbox")
|
|
246
|
+
parser.add_argument("--http", metavar="PORT", type=int, default=None,
|
|
247
|
+
help="serve streamable HTTP on PORT (default: stdio)")
|
|
248
|
+
parser.add_argument("--web", metavar="PORT", type=int, default=None,
|
|
249
|
+
help="serve the kanban board UI + JSON API on PORT (default: stdio)")
|
|
250
|
+
parser.add_argument("--home", metavar="DIR", default=None,
|
|
251
|
+
help="mail root directory (default: ~/.agent-mail)")
|
|
252
|
+
args = parser.parse_args()
|
|
253
|
+
|
|
254
|
+
if args.home:
|
|
255
|
+
os.environ["AGENT_MAIL_HOME"] = args.home
|
|
256
|
+
|
|
257
|
+
if args.web:
|
|
258
|
+
from .web import run_web
|
|
259
|
+
|
|
260
|
+
run_web(args.web)
|
|
261
|
+
elif args.http:
|
|
262
|
+
server.run(transport="streamable-http", port=args.http)
|
|
263
|
+
else:
|
|
264
|
+
server.run(transport="stdio")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|