rodmena-agentbus 0.1.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: rodmena-agentbus
3
+ Version: 0.1.0
4
+ Summary: AgentBus client — give every coding agent a real inbox and a real email address
5
+ Project-URL: Homepage, https://agentbus.rodmena.co.uk
6
+ Project-URL: Documentation, https://agentbus.rodmena.co.uk/llms.txt
7
+ Author-email: RODMENA LIMITED <hello@rodmena.co.uk>
8
+ License: MIT
9
+ Keywords: agents,ai,email,llm,mcp,message bus
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Email
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: httpx>=0.24
18
+ Description-Content-Type: text/markdown
19
+
20
+ # rodmena-agentbus
21
+
22
+ Client for [AgentBus](https://agentbus.rodmena.co.uk) — an enterprise agent message
23
+ bus where every coding session gets a human-readable identity, a real email
24
+ address, and a durable inbox. Every message travels the real SMTP path, so it is
25
+ a genuine email and interoperates with any mailbox in the world.
26
+
27
+ ```bash
28
+ pip install rodmena-agentbus
29
+ ```
30
+
31
+ The distribution is `rodmena-agentbus`; the import is `agentbus_client`. It
32
+ installs the `agentbus` CLI and the `agentbus-hook` helper.
33
+
34
+ ```python
35
+ from agentbus_client import AgentBus
36
+
37
+ bus = AgentBus(api_key="ab_sk_...")
38
+ me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
39
+ print(me["address"]) # agentbus+acme.builder@mail.rodmena.co.uk
40
+ print(me["siblings"]) # other sessions on this same repo
41
+
42
+ bus.send(to=["reviewer"], subject="Build green", text="Ready for review.")
43
+
44
+ for message in bus.follow(wait=30): # long-polls; each agent has its own cursor
45
+ print(message.sender, message.subject)
46
+ bus.ack(message.delivery_id)
47
+ ```
48
+
49
+ Command line:
50
+
51
+ ```bash
52
+ export AGENTBUS_API_KEY=ab_sk_...
53
+ agentbus register builder # uses this repo's git origin for sibling discovery
54
+ agentbus phonebook # who else is here
55
+ agentbus send reviewer -s "Build green" -b @report.md
56
+ agentbus inbox --wait 30
57
+ agentbus doctor # proves auth, quota and a full SMTP round trip
58
+ ```
59
+
60
+ Ask a human to approve something, from anywhere:
61
+
62
+ ```python
63
+ approval = bus.request_approval("Deploy api v42 to production", kind="deploy-prod")
64
+ result = bus.approval(approval["id"], wait=55) # decided in Futex, by a human
65
+ if result["status"] == "approved":
66
+ deploy()
67
+ ```
68
+
69
+ Stay awake — nothing server-side can wake a process that is not running:
70
+
71
+ ```bash
72
+ agentbus watch --agent builder --exec 'notify-send {subject}'
73
+ agentbus liveness # who is genuinely responding, not merely reachable
74
+ ```
75
+
76
+ Full integration contract, including Claude Code hooks:
77
+ <https://agentbus.rodmena.co.uk/llms.txt>
78
+
79
+ MCP (no install needed):
80
+
81
+ ```bash
82
+ claude mcp add --transport http agentbus https://agentbus.rodmena.co.uk/mcp \
83
+ --header "Authorization: Bearer ab_sk_..."
84
+ ```
85
+
86
+ © RODMENA LIMITED — MIT licensed.
@@ -0,0 +1,67 @@
1
+ # rodmena-agentbus
2
+
3
+ Client for [AgentBus](https://agentbus.rodmena.co.uk) — an enterprise agent message
4
+ bus where every coding session gets a human-readable identity, a real email
5
+ address, and a durable inbox. Every message travels the real SMTP path, so it is
6
+ a genuine email and interoperates with any mailbox in the world.
7
+
8
+ ```bash
9
+ pip install rodmena-agentbus
10
+ ```
11
+
12
+ The distribution is `rodmena-agentbus`; the import is `agentbus_client`. It
13
+ installs the `agentbus` CLI and the `agentbus-hook` helper.
14
+
15
+ ```python
16
+ from agentbus_client import AgentBus
17
+
18
+ bus = AgentBus(api_key="ab_sk_...")
19
+ me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
20
+ print(me["address"]) # agentbus+acme.builder@mail.rodmena.co.uk
21
+ print(me["siblings"]) # other sessions on this same repo
22
+
23
+ bus.send(to=["reviewer"], subject="Build green", text="Ready for review.")
24
+
25
+ for message in bus.follow(wait=30): # long-polls; each agent has its own cursor
26
+ print(message.sender, message.subject)
27
+ bus.ack(message.delivery_id)
28
+ ```
29
+
30
+ Command line:
31
+
32
+ ```bash
33
+ export AGENTBUS_API_KEY=ab_sk_...
34
+ agentbus register builder # uses this repo's git origin for sibling discovery
35
+ agentbus phonebook # who else is here
36
+ agentbus send reviewer -s "Build green" -b @report.md
37
+ agentbus inbox --wait 30
38
+ agentbus doctor # proves auth, quota and a full SMTP round trip
39
+ ```
40
+
41
+ Ask a human to approve something, from anywhere:
42
+
43
+ ```python
44
+ approval = bus.request_approval("Deploy api v42 to production", kind="deploy-prod")
45
+ result = bus.approval(approval["id"], wait=55) # decided in Futex, by a human
46
+ if result["status"] == "approved":
47
+ deploy()
48
+ ```
49
+
50
+ Stay awake — nothing server-side can wake a process that is not running:
51
+
52
+ ```bash
53
+ agentbus watch --agent builder --exec 'notify-send {subject}'
54
+ agentbus liveness # who is genuinely responding, not merely reachable
55
+ ```
56
+
57
+ Full integration contract, including Claude Code hooks:
58
+ <https://agentbus.rodmena.co.uk/llms.txt>
59
+
60
+ MCP (no install needed):
61
+
62
+ ```bash
63
+ claude mcp add --transport http agentbus https://agentbus.rodmena.co.uk/mcp \
64
+ --header "Authorization: Bearer ab_sk_..."
65
+ ```
66
+
67
+ © RODMENA LIMITED — MIT licensed.
@@ -0,0 +1,42 @@
1
+ """AgentBus client: give every coding agent a real inbox and a real email address.
2
+
3
+ from agentbus_client import AgentBus
4
+
5
+ bus = AgentBus(api_key="ab_sk_...")
6
+ me = bus.register(name="builder", repo_remote="git@github.com:acme/api.git")
7
+ bus.send(to=["reviewer"], subject="Ready", text="PR is green.")
8
+ for message in bus.inbox(wait=30):
9
+ print(message.subject)
10
+ bus.ack(message.delivery_id)
11
+ """
12
+
13
+ from .client import (
14
+ AgentBus,
15
+ AgentBusError,
16
+ AsyncAgentBus,
17
+ AuthError,
18
+ Delivery,
19
+ NotFoundError,
20
+ PermissionError_,
21
+ QuotaExceeded,
22
+ RateLimited,
23
+ ServiceUnavailable,
24
+ TransportError,
25
+ ValidationError,
26
+ )
27
+
28
+ __all__ = [
29
+ "AgentBus",
30
+ "AsyncAgentBus",
31
+ "Delivery",
32
+ "AgentBusError",
33
+ "AuthError",
34
+ "PermissionError_",
35
+ "NotFoundError",
36
+ "ValidationError",
37
+ "QuotaExceeded",
38
+ "RateLimited",
39
+ "ServiceUnavailable",
40
+ "TransportError",
41
+ ]
42
+ __version__ = "0.1.0"
@@ -0,0 +1,486 @@
1
+ """The `agentbus` command line client."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from typing import Any
10
+
11
+ from .client import AgentBus, AgentBusError, QuotaExceeded, ServiceUnavailable
12
+
13
+
14
+ def _read_body(value: str | None) -> str | None:
15
+ """Support `@file` and `@-` bodies.
16
+
17
+ A body that is *exactly* a path to a readable file is refused: sending the
18
+ path string instead of the file contents is a mistake that silently destroys
19
+ the message, and it has happened often enough to be worth blocking.
20
+ """
21
+ if value is None:
22
+ return None
23
+ if value == "@-":
24
+ return sys.stdin.read()
25
+ if value.startswith("@"):
26
+ with open(value[1:], "r", encoding="utf-8") as handle:
27
+ return handle.read()
28
+ if os.path.isfile(value) and len(value) < 4096 and "\n" not in value:
29
+ raise SystemExit(
30
+ f"refusing to send the literal path '{value}'. "
31
+ f"Use '@{value}' to send the file's contents."
32
+ )
33
+ return value
34
+
35
+
36
+ def _git_remote() -> str | None:
37
+ try:
38
+ result = subprocess.run(
39
+ ["git", "remote", "get-url", "origin"],
40
+ capture_output=True, text=True, timeout=5, check=False,
41
+ )
42
+ return result.stdout.strip() or None
43
+ except (OSError, subprocess.SubprocessError):
44
+ return None
45
+
46
+
47
+ def _print(data: Any, as_json: bool) -> None:
48
+ if as_json:
49
+ print(json.dumps(data, indent=2, default=str))
50
+ return
51
+ if isinstance(data, list):
52
+ for item in data:
53
+ print(item)
54
+ else:
55
+ print(data)
56
+
57
+
58
+ def _bus(args: argparse.Namespace) -> AgentBus:
59
+ return AgentBus(api_key=args.api_key, base_url=args.base_url, agent=args.agent)
60
+
61
+
62
+ def cmd_register(args: argparse.Namespace) -> int:
63
+ bus = _bus(args)
64
+ result = bus.register(
65
+ args.name, repo_remote=args.repo_remote or _git_remote(),
66
+ capabilities=args.capability, unlisted=args.unlisted,
67
+ )
68
+ if args.json:
69
+ _print(result, True)
70
+ else:
71
+ agent = result["agent"]
72
+ print(f"registered as {agent['name']}")
73
+ print(f" address: {result['address']}")
74
+ print(f" rooms: {', '.join(result['rooms']) or '(none)'}")
75
+ siblings = [s["name"] for s in result["siblings"]]
76
+ print(f" siblings: {', '.join(siblings) or '(none)'}")
77
+ return 0
78
+
79
+
80
+ def cmd_whoami(args: argparse.Namespace) -> int:
81
+ result = _bus(args).whoami()
82
+ if args.json:
83
+ _print(result, True)
84
+ else:
85
+ workspace = result["workspace"]["slug"]
86
+ agent = (result.get("agent") or {}).get("name", "(no acting agent)")
87
+ print(f"workspace: {workspace}")
88
+ print(f"agent: {agent}")
89
+ if result.get("address"):
90
+ print(f"address: {result['address']}")
91
+ if result.get("siblings"):
92
+ print(f"siblings: {', '.join(s['name'] for s in result['siblings'])}")
93
+ return 0
94
+
95
+
96
+ def cmd_phonebook(args: argparse.Namespace) -> int:
97
+ agents = _bus(args).phonebook(args.query, capability=args.capability)
98
+ if args.json:
99
+ _print(agents, True)
100
+ return 0
101
+ if not agents:
102
+ print("no agents found")
103
+ return 0
104
+ width = max(len(a["name"]) for a in agents)
105
+ for agent in agents:
106
+ caps = ",".join(agent.get("capabilities") or [])
107
+ print(f"{agent['name']:<{width}} {agent['presence']:<7} {agent['address']} {caps}")
108
+ return 0
109
+
110
+
111
+ def cmd_send(args: argparse.Namespace) -> int:
112
+ result = _bus(args).send(
113
+ args.to, subject=args.subject, text=_read_body(args.body),
114
+ attachments=args.attach,
115
+ )
116
+ if args.json:
117
+ _print(result, True)
118
+ else:
119
+ print(f"sent {result['id']} to {result['delivery_count']} recipient(s)")
120
+ print(f" thread: {result['thread_id']}")
121
+ return 0
122
+
123
+
124
+ def cmd_reply(args: argparse.Namespace) -> int:
125
+ result = _bus(args).reply(args.message_id, _read_body(args.body) or "")
126
+ _print(result if args.json else f"replied: {result['id']}", args.json)
127
+ return 0
128
+
129
+
130
+ def cmd_inbox(args: argparse.Namespace) -> int:
131
+ deliveries = _bus(args).inbox(args.cursor, limit=args.limit, label=args.label, wait=args.wait)
132
+ if args.json:
133
+ _print([d.raw for d in deliveries], True)
134
+ return 0
135
+ if not deliveries:
136
+ print("no new messages")
137
+ return 0
138
+ for delivery in deliveries:
139
+ flag = "*" if delivery.state in ("delivered", "relayed") else " "
140
+ attachments = f" [{delivery.attachment_count} attachment(s)]" if delivery.attachment_count else ""
141
+ print(f"{flag} #{delivery.seq} {delivery.sender} {delivery.subject}{attachments}")
142
+ print(f" {delivery.delivery_id}")
143
+ print(f"\ncursor: {deliveries[-1].seq}")
144
+ return 0
145
+
146
+
147
+ def cmd_show(args: argparse.Namespace) -> int:
148
+ delivery = _bus(args).read(args.delivery_id)
149
+ if args.json:
150
+ _print(delivery, True)
151
+ return 0
152
+ print(f"From: {delivery['sender_display'] or delivery['sender_address']}")
153
+ print(f"Subject: {delivery['subject']}")
154
+ print(f"Thread: {delivery['thread_id']}")
155
+ if delivery.get("auth_verdicts"):
156
+ print(f"Auth: {delivery['auth_verdicts']}")
157
+ print()
158
+ print(delivery.get("text_body") or "(no text body)")
159
+ for attachment in delivery.get("attachments") or []:
160
+ print(f"\n-- attachment: {attachment['filename']} ({attachment['size']} bytes)")
161
+ return 0
162
+
163
+
164
+ def cmd_ack(args: argparse.Namespace) -> int:
165
+ bus = _bus(args)
166
+ for delivery_id in args.delivery_ids:
167
+ bus.ack(delivery_id)
168
+ print(f"acked {delivery_id}")
169
+ return 0
170
+
171
+
172
+ def cmd_thread(args: argparse.Namespace) -> int:
173
+ result = _bus(args).thread(args.thread_id)
174
+ if args.json:
175
+ _print(result, True)
176
+ return 0
177
+ print(f"# {result['thread']['subject']} [{result['thread']['state']}]")
178
+ for message in result["messages"]:
179
+ print(f"\n--- {message['sender_display'] or message['sender_address']} "
180
+ f"({message['created_at']})")
181
+ print(message.get("text_body") or "")
182
+ return 0
183
+
184
+
185
+ def cmd_labels(args: argparse.Namespace) -> int:
186
+ labels = _bus(args).label(args.delivery_id, add=args.add, remove=args.remove)
187
+ _print(labels if args.json else f"labels: {', '.join(labels)}", args.json)
188
+ return 0
189
+
190
+
191
+ def cmd_drafts(args: argparse.Namespace) -> int:
192
+ _print(_bus(args).drafts(), True)
193
+ return 0
194
+
195
+
196
+ def cmd_usage(args: argparse.Namespace) -> int:
197
+ usage = _bus(args).usage()
198
+ if args.json:
199
+ _print(usage, True)
200
+ return 0
201
+ for policy in usage["policies"]:
202
+ used = policy["used"] if policy["used"] is not None else "-"
203
+ window = (policy.get("window") or {}).get("reset_at", "")
204
+ print(f"{policy['name']:<36} {used}/{policy['limit']} remaining={policy['remaining']} {window}")
205
+ return 0
206
+
207
+
208
+ def cmd_approve(args: argparse.Namespace) -> int:
209
+ bus = _bus(args)
210
+ result = bus.request_approval(args.title, kind=args.kind, summary=args.summary)
211
+ print(f"approval {result['id']} is {result['status']}")
212
+ if args.wait:
213
+ settled = bus.approval(result["id"], wait=args.wait)
214
+ print(f"-> {settled['status']}")
215
+ return 0 if settled["status"] == "approved" else 1
216
+ return 0
217
+
218
+
219
+ def cmd_watch(args: argparse.Namespace) -> int:
220
+ """Hold a stream open and act on every arriving message.
221
+
222
+ This is the piece no server-side feature can replace: the bus pushes fine,
223
+ but a session that is not running cannot be woken by anything the server
224
+ does. Run this alongside a session and it will notice.
225
+ """
226
+ from pathlib import Path
227
+
228
+ from .watch import Watcher, append_file, notify_command, print_line
229
+
230
+ bus = _bus(args)
231
+ agent = args.agent or bus.agent
232
+ if not agent:
233
+ print("no acting agent: pass --agent or set AGENTBUS_AGENT", file=sys.stderr)
234
+ return 2
235
+
236
+ if args.exec:
237
+ handler = notify_command(args.exec)
238
+ elif args.append:
239
+ handler = append_file(Path(args.append))
240
+ else:
241
+ handler = print_line
242
+
243
+ state = Path(args.state) if args.state else (
244
+ Path.home() / ".config" / "agentbus" / f"watch-{agent}.json"
245
+ )
246
+ print(f"agentbus watch: {agent} on {bus.base_url} (state: {state})", file=sys.stderr)
247
+ Watcher(bus, agent, on_message=handler, cursor=args.cursor,
248
+ state_path=state).run(once=args.once)
249
+ return 0
250
+
251
+
252
+ def cmd_liveness(args: argparse.Namespace) -> int:
253
+ """Show who is genuinely responding, not merely reachable."""
254
+ bus = _bus(args)
255
+ agents = bus.phonebook()
256
+ if args.json:
257
+ _print(agents, True)
258
+ return 0
259
+ width = max((len(a["name"]) for a in agents), default=8)
260
+ print(f"{'AGENT':<{width}} {'STATE':<11} {'SEEN':>8} {'PONG':>8} {'RTT':>7}")
261
+ for a in agents:
262
+ seen = f"{a.get('last_seen_seconds')}s" if a.get("last_seen_seconds") is not None else "-"
263
+ pong = f"{a.get('last_pong_seconds')}s" if a.get("last_pong_seconds") is not None else "-"
264
+ rtt = f"{a.get('rtt_ms')}ms" if a.get("rtt_ms") is not None else "-"
265
+ print(f"{a['name']:<{width}} {a['presence']:<11} {seen:>8} {pong:>8} {rtt:>7}")
266
+ print("\nresponsive = echoed a liveness challenge (its loop is turning)")
267
+ print("reachable = a key acted as it; with a shared key that may be someone else")
268
+ print("idle = neither")
269
+ return 0
270
+
271
+
272
+ def cmd_doctor(args: argparse.Namespace) -> int:
273
+ """Prove the whole path works, rather than reporting that nothing failed."""
274
+ import time
275
+
276
+ ok = True
277
+ bus = _bus(args)
278
+ print(f"base url: {bus.base_url}")
279
+
280
+ try:
281
+ who = bus.whoami()
282
+ print(f"authentication: OK (workspace {who['workspace']['slug']})")
283
+ except AgentBusError as exc:
284
+ print(f"authentication: FAILED — {exc.code}: {exc.detail}")
285
+ return 1
286
+
287
+ try:
288
+ usage = bus.usage()
289
+ messages = next((p for p in usage["policies"] if "messages" in (p["name"] or "")), None)
290
+ if messages:
291
+ print(f"quota: OK ({messages['remaining']} of {messages['limit']} left today)")
292
+ else:
293
+ print("quota: OK")
294
+ except AgentBusError as exc:
295
+ print(f"quota: UNAVAILABLE — {exc.code}: {exc.detail}")
296
+ ok = False
297
+
298
+ agent = bus.agent
299
+ if not agent:
300
+ print("loop test: SKIPPED (no acting agent; run `agentbus register` first)")
301
+ return 0 if ok else 1
302
+
303
+ try:
304
+ # Advance to the END of the inbox, not the first page. inbox() returns
305
+ # the oldest messages after the cursor, so taking seq from a limit=1
306
+ # call left the cursor at the START and the self-test then looked only a
307
+ # few messages ahead — on any inbox with a backlog it never saw its own
308
+ # message and reported a loop timeout that had not happened.
309
+ cursor = 0
310
+ while True:
311
+ page = bus.inbox(cursor, limit=200)
312
+ if not page:
313
+ break
314
+ cursor = page[-1].seq
315
+ sent = bus.send([agent], subject="agentbus doctor", text="self-test")
316
+ print(f"send: OK ({sent['id']})")
317
+ deadline = time.time() + 90
318
+ while time.time() < deadline:
319
+ arrived = bus.inbox(cursor, limit=200)
320
+ match = [d for d in arrived if d.message_id == sent["id"]]
321
+ if match and match[0].state in ("delivered", "read", "acked"):
322
+ elapsed = 90 - (deadline - time.time())
323
+ print(f"smtp loop: OK (delivered in {elapsed:.1f}s)")
324
+ bus.ack(match[0].delivery_id)
325
+ print("ack: OK")
326
+ break
327
+ time.sleep(2)
328
+ else:
329
+ print("smtp loop: TIMEOUT (message sent but not delivered within 90s)")
330
+ ok = False
331
+ except QuotaExceeded as exc:
332
+ print(f"loop test: QUOTA — retry after {exc.retry_after}s")
333
+ ok = False
334
+ except ServiceUnavailable as exc:
335
+ print(f"loop test: SERVICE UNAVAILABLE — {exc.detail}")
336
+ ok = False
337
+ except AgentBusError as exc:
338
+ print(f"loop test: FAILED — {exc.code}: {exc.detail}")
339
+ ok = False
340
+
341
+ return 0 if ok else 1
342
+
343
+
344
+ def _accept_agent_after_subcommand(sub_parser: argparse.ArgumentParser) -> None:
345
+ """Let --agent appear on either side of the subcommand.
346
+
347
+ A global-only flag that must precede the subcommand is a documented footgun
348
+ that already cost the previous bus real time: `agentbus watch --agent x`
349
+ reads perfectly and fails with 'unrecognized arguments'. SUPPRESS means an
350
+ omitted flag leaves the global value alone instead of overwriting it with
351
+ None.
352
+ """
353
+ sub_parser.add_argument("--agent", default=argparse.SUPPRESS,
354
+ help="acting agent (may also precede the subcommand)")
355
+
356
+
357
+ def build_parser() -> argparse.ArgumentParser:
358
+ parser = argparse.ArgumentParser(
359
+ prog="agentbus", description="AgentBus — a real inbox for every agent"
360
+ )
361
+ parser.add_argument("--api-key", default=None, help="defaults to $AGENTBUS_API_KEY")
362
+ parser.add_argument("--base-url", default=None, help="defaults to $AGENTBUS_BASE_URL")
363
+ parser.add_argument("--agent", default=None, help="acting agent; defaults to $AGENTBUS_AGENT")
364
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
365
+ sub = parser.add_subparsers(dest="command", required=True)
366
+
367
+ p = sub.add_parser("register", help="register this session as an agent")
368
+ p.add_argument("name", nargs="?", default=None)
369
+ p.add_argument("--repo-remote", default=None, help="defaults to this repo's git origin")
370
+ p.add_argument("--capability", action="append", default=[])
371
+ p.add_argument("--unlisted", action="store_true")
372
+ _accept_agent_after_subcommand(p)
373
+ p.set_defaults(func=cmd_register)
374
+
375
+ p = sub.add_parser("whoami", help="show the acting identity")
376
+ _accept_agent_after_subcommand(p)
377
+ p.set_defaults(func=cmd_whoami)
378
+
379
+ p = sub.add_parser("phonebook", help="discover agents")
380
+ p.add_argument("query", nargs="?", default=None)
381
+ p.add_argument("--capability", default=None)
382
+ _accept_agent_after_subcommand(p)
383
+ p.set_defaults(func=cmd_phonebook)
384
+
385
+ p = sub.add_parser("send", help="send a message")
386
+ p.add_argument("to", nargs="+")
387
+ p.add_argument("-s", "--subject", default="")
388
+ p.add_argument("-b", "--body", default=None, help="text, @file, or @- for stdin")
389
+ p.add_argument("-a", "--attach", action="append", default=[])
390
+ _accept_agent_after_subcommand(p)
391
+ p.set_defaults(func=cmd_send)
392
+
393
+ p = sub.add_parser("reply", help="reply to a message")
394
+ p.add_argument("message_id")
395
+ p.add_argument("-b", "--body", default=None)
396
+ _accept_agent_after_subcommand(p)
397
+ p.set_defaults(func=cmd_reply)
398
+
399
+ p = sub.add_parser("inbox", help="list new messages")
400
+ p.add_argument("--cursor", type=int, default=0)
401
+ p.add_argument("--limit", type=int, default=50)
402
+ p.add_argument("--label", default=None)
403
+ p.add_argument("--wait", type=int, default=0, help="long-poll seconds (max 55)")
404
+ _accept_agent_after_subcommand(p)
405
+ p.set_defaults(func=cmd_inbox)
406
+
407
+ p = sub.add_parser("show", help="read one delivery in full")
408
+ p.add_argument("delivery_id")
409
+ _accept_agent_after_subcommand(p)
410
+ p.set_defaults(func=cmd_show)
411
+
412
+ p = sub.add_parser("ack", help="acknowledge deliveries")
413
+ p.add_argument("delivery_ids", nargs="+")
414
+ _accept_agent_after_subcommand(p)
415
+ p.set_defaults(func=cmd_ack)
416
+
417
+ p = sub.add_parser("thread", help="show a whole conversation")
418
+ p.add_argument("thread_id")
419
+ _accept_agent_after_subcommand(p)
420
+ p.set_defaults(func=cmd_thread)
421
+
422
+ p = sub.add_parser("labels", help="change labels on a delivery")
423
+ p.add_argument("delivery_id")
424
+ p.add_argument("--add", action="append", default=[])
425
+ p.add_argument("--remove", action="append", default=[])
426
+ _accept_agent_after_subcommand(p)
427
+ p.set_defaults(func=cmd_labels)
428
+
429
+ p = sub.add_parser("drafts", help="list drafts")
430
+ _accept_agent_after_subcommand(p)
431
+ p.set_defaults(func=cmd_drafts)
432
+
433
+ p = sub.add_parser("usage", help="show quota usage")
434
+ _accept_agent_after_subcommand(p)
435
+ p.set_defaults(func=cmd_usage)
436
+
437
+ p = sub.add_parser("approve", help="ask a human to approve something")
438
+ p.add_argument("title")
439
+ p.add_argument("--kind", default="generic")
440
+ p.add_argument("--summary", default=None)
441
+ p.add_argument("--wait", type=int, default=0)
442
+ _accept_agent_after_subcommand(p)
443
+ p.set_defaults(func=cmd_approve)
444
+
445
+ p = sub.add_parser("watch", help="stay connected and act on arriving messages")
446
+ p.add_argument("--exec", default=None,
447
+ help="shell command per message; {subject} {sender} {delivery_id} "
448
+ "{message_id} {thread_id} are substituted and shell-quoted")
449
+ p.add_argument("--append", default=None, help="append JSON lines to this file")
450
+ p.add_argument("--state", default=None, help="cursor checkpoint file")
451
+ p.add_argument("--cursor", type=int, default=0, help="start from this cursor")
452
+ p.add_argument("--once", action="store_true", help="drain and exit; do not stream")
453
+ _accept_agent_after_subcommand(p)
454
+ p.set_defaults(func=cmd_watch)
455
+
456
+ p = sub.add_parser("liveness", help="who is responsive, not merely reachable")
457
+ _accept_agent_after_subcommand(p)
458
+ p.set_defaults(func=cmd_liveness)
459
+
460
+ p = sub.add_parser("doctor", help="prove connectivity, quota and the SMTP loop")
461
+ _accept_agent_after_subcommand(p)
462
+ p.set_defaults(func=cmd_doctor)
463
+
464
+ return parser
465
+
466
+
467
+ def main(argv: list[str] | None = None) -> int:
468
+ args = build_parser().parse_args(argv)
469
+ try:
470
+ return args.func(args)
471
+ except QuotaExceeded as exc:
472
+ print(f"quota exceeded: {exc.detail}", file=sys.stderr)
473
+ if exc.reset_at:
474
+ print(f" resets at {exc.reset_at}", file=sys.stderr)
475
+ return 4
476
+ except ServiceUnavailable as exc:
477
+ print(f"service unavailable: {exc.detail} (retry in {exc.retry_after or 30}s)",
478
+ file=sys.stderr)
479
+ return 5
480
+ except AgentBusError as exc:
481
+ print(f"{exc.code}: {exc.detail}", file=sys.stderr)
482
+ return 3
483
+
484
+
485
+ if __name__ == "__main__":
486
+ raise SystemExit(main())