agentainer 0.1.7 → 2.0.1

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 (98) hide show
  1. package/README.md +248 -677
  2. package/agentainer +16 -18
  3. package/agentainer.example.yaml +86 -0
  4. package/bin/agentainer.js +9 -8
  5. package/examples/academic-coauthor.yaml +123 -0
  6. package/examples/accessibility-audit.yaml +152 -0
  7. package/examples/affiliate-product-reviews.yaml +106 -0
  8. package/examples/api-design.yaml +157 -0
  9. package/examples/app-store-optimization.yaml +108 -0
  10. package/examples/brainstorm.yaml +27 -128
  11. package/examples/brand-voice-style-guide.yaml +109 -0
  12. package/examples/bug-hunt.yaml +51 -96
  13. package/examples/candidate-screen.yaml +122 -0
  14. package/examples/case-study-writer.yaml +100 -0
  15. package/examples/changelog-release-notes.yaml +114 -0
  16. package/examples/chatbot-builder.yaml +138 -0
  17. package/examples/code-review.yaml +73 -0
  18. package/examples/comparison-guide-writer.yaml +106 -0
  19. package/examples/competitive-intel.yaml +126 -0
  20. package/examples/content-studio.yaml +91 -0
  21. package/examples/course-creator.yaml +133 -0
  22. package/examples/customer-support-triage.yaml +118 -0
  23. package/examples/daily-briefing.yaml +119 -0
  24. package/examples/data-pipeline-builder.yaml +135 -0
  25. package/examples/debate.yaml +16 -90
  26. package/examples/design-system.yaml +138 -0
  27. package/examples/ebook-generator.yaml +90 -0
  28. package/examples/ecommerce-listing-optimizer.yaml +126 -0
  29. package/examples/email-newsletter.yaml +103 -0
  30. package/examples/faq-knowledge-sync.yaml +107 -0
  31. package/examples/game-design.yaml +122 -0
  32. package/examples/glossary-term-writer.yaml +103 -0
  33. package/examples/incident-response.yaml +52 -109
  34. package/examples/knowledge-base.yaml +115 -0
  35. package/examples/landing-page-converter.yaml +103 -0
  36. package/examples/legal-contract-review.yaml +118 -0
  37. package/examples/linkedin-ghostwriter.yaml +93 -0
  38. package/examples/localization.yaml +56 -123
  39. package/examples/meeting-notes.yaml +111 -0
  40. package/examples/migration-planner.yaml +127 -0
  41. package/examples/onboarding-buddy.yaml +111 -0
  42. package/examples/performance-audit.yaml +123 -0
  43. package/examples/podcast-production.yaml +117 -0
  44. package/examples/postmortem.yaml +119 -0
  45. package/examples/pr-review-gate.yaml +123 -0
  46. package/examples/press-release-wire.yaml +96 -0
  47. package/examples/product-spec.yaml +107 -0
  48. package/examples/prompt-engineering-lab.yaml +109 -0
  49. package/examples/quickstart.yaml +48 -0
  50. package/examples/rag-builder.yaml +145 -0
  51. package/examples/refactor-planner.yaml +127 -0
  52. package/examples/research.yaml +25 -0
  53. package/examples/resume-tailor.yaml +116 -0
  54. package/examples/rfp-response.yaml +124 -0
  55. package/examples/sales-coach.yaml +123 -0
  56. package/examples/security-audit.yaml +120 -0
  57. package/examples/seo-audit-and-fix.yaml +138 -0
  58. package/examples/seo-content-factory.yaml +103 -0
  59. package/examples/social-media.yaml +103 -0
  60. package/examples/software-company.yaml +71 -128
  61. package/examples/startup-validator.yaml +115 -0
  62. package/examples/tdd-pingpong.yaml +36 -68
  63. package/examples/technical-documentation.yaml +112 -0
  64. package/examples/test-factory.yaml +114 -0
  65. package/examples/tutorial-howto-creator.yaml +111 -0
  66. package/examples/twitter-x-thread-factory.yaml +91 -0
  67. package/examples/white-paper-research.yaml +96 -0
  68. package/examples/writers-room.yaml +49 -111
  69. package/examples/youtube-script-studio.yaml +107 -0
  70. package/hooks/claude_stop.sh +5 -3
  71. package/hooks/codex_notify.sh +4 -3
  72. package/lib/cli.py +933 -0
  73. package/lib/config.py +267 -308
  74. package/lib/hooks.py +246 -0
  75. package/lib/lock.py +75 -0
  76. package/lib/log.py +64 -0
  77. package/lib/mail.py +699 -0
  78. package/lib/minyaml.py +1 -39
  79. package/lib/reconcile.py +544 -0
  80. package/lib/sessions.py +223 -0
  81. package/lib/supervisor.py +216 -0
  82. package/lib/telegram.py +372 -0
  83. package/lib/tmux.py +355 -0
  84. package/lib/turn.py +167 -0
  85. package/lib/ui.py +1219 -0
  86. package/llms.txt +145 -429
  87. package/package.json +9 -7
  88. package/scripts/check-deps.js +18 -61
  89. package/ui/app.js +1136 -0
  90. package/ui/index.html +404 -0
  91. package/agents.example.yaml +0 -257
  92. package/examples/code-review-broadcast.yaml +0 -109
  93. package/examples/existing-repo.yaml +0 -74
  94. package/examples/multi-language-broadcast.yaml +0 -127
  95. package/examples/ping-pong.yaml +0 -89
  96. package/examples/red-team.yaml +0 -117
  97. package/examples/research-swarm.yaml +0 -129
  98. package/lib/swarm.py +0 -2461
package/lib/mail.py ADDED
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the file-based MAILROOM (the heart of v2).
3
+
4
+ This module replaces v1's XML-envelope-in-prose messaging. The agent's entire
5
+ world is two verbs (read a file, write a file) and four folders (inbox /
6
+ outbox / read / sent). The model only reads and writes natural-language files;
7
+ everything hard -- routing, ACL, message IDs, threading, read-state, queueing,
8
+ retries, availability, the durable log -- is deterministic orchestrator code in
9
+ this file. See ``ProjectPlan.md`` (§4-§14, §24).
10
+
11
+ Design, in one screen:
12
+
13
+ * ``inbox/`` holds EXACTLY ONE message at a time (one-at-a-time release). The
14
+ rest wait in ``cfg.queue_dir/<agent>/``.
15
+ * ``outbox/<name>/about.md`` is the orchestrator-maintained contact card; its
16
+ mere presence IS the ACL (only peers in ``can_talk_to`` get a folder).
17
+ * Read-state is orchestrator-owned; moving a message to ``read/`` is a
18
+ best-effort receipt; an auto-archive fallback guarantees liveness so a
19
+ forgetful model can never wedge the swarm.
20
+ * ``system`` = orchestrator voice (no folder; just enqueue + log). ``user`` =
21
+ virtual human mailbox with an ACL gate + an availability toggle (default OFF)
22
+ that HOLDS mail (never bounces) + sends a ``system`` ack.
23
+
24
+ Zero runtime dependencies: Python stdlib + the bundled lib/ modules only.
25
+
26
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import time
36
+ import uuid
37
+ from pathlib import Path
38
+
39
+ # The lib modules are imported bare (config, log, ...) because the test harness
40
+ # and the CLI both put ``lib/`` on ``sys.path``. Keep these imports in sync with
41
+ # the other lib modules.
42
+ import config as cfgmod # noqa: E402
43
+ from config import Agent, SwarmConfig # noqa: E402
44
+
45
+ import log # noqa: E402
46
+ import lock # noqa: E402
47
+ import turn # noqa: E402
48
+ import tmux # noqa: E402
49
+
50
+
51
+ # --------------------------------------------------------------------------
52
+ # module constants
53
+ # --------------------------------------------------------------------------
54
+
55
+ # A message presented this many times without being handled is auto-archived
56
+ # (plan §7) so a forgetful model can never wedge the swarm.
57
+ AUTO_ARCHIVE_PRESENTATIONS = 5
58
+
59
+ # Runaway-loop cap: at most this many messages between any pair of agents within
60
+ # the sliding window below, else we drop (rate-limit) further ones. Cheap
61
+ # insurance against A<->B "thanks!/you're welcome!" loops.
62
+ RUNAWAY_CAP = 20
63
+ RUNAWAY_WINDOW_S = 60
64
+
65
+ # Sentinel prefix marking a periodic-ping message, so maybe_ping can detect a
66
+ # still-unhandled ping and avoid piling them up.
67
+ PING_MARKER = "ping-"
68
+
69
+
70
+ # --------------------------------------------------------------------------
71
+ # run-dir small-JSON helpers (presentation counts, read receipts, pings)
72
+ # --------------------------------------------------------------------------
73
+
74
+
75
+ def _load_run_json(cfg: SwarmConfig, name: str) -> dict:
76
+ """Load a small JSON state file from ``cfg.run_dir``, returning {} if absent."""
77
+ p = cfg.run_dir / name
78
+ if p.exists():
79
+ try:
80
+ return json.loads(p.read_text())
81
+ except (json.JSONDecodeError, OSError):
82
+ return {}
83
+ return {}
84
+
85
+
86
+ def _save_run_json(cfg: SwarmConfig, name: str, data: dict) -> None:
87
+ """Persist a small JSON state file into ``cfg.run_dir``."""
88
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
89
+ (cfg.run_dir / name).write_text(json.dumps(data))
90
+
91
+
92
+ def _get_presentations(cfg: SwarmConfig, agent_name: str) -> dict:
93
+ return _load_run_json(cfg, f"{agent_name}.presentations.json")
94
+
95
+
96
+ def _set_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str, count: int) -> None:
97
+ _save_run_json(cfg, f"{agent_name}.presentations.json", {"msg_id": msg_id, "count": count})
98
+
99
+
100
+ def _bump_presentations(cfg: SwarmConfig, agent_name: str, msg_id: str) -> None:
101
+ """Record one more presentation of *msg_id* for *agent_name* (liveness)."""
102
+ cur = _get_presentations(cfg, agent_name)
103
+ if cur.get("msg_id") == msg_id:
104
+ cur["count"] = cur.get("count", 0) + 1
105
+ else:
106
+ cur = {"msg_id": msg_id, "count": 1}
107
+ _save_run_json(cfg, f"{agent_name}.presentations.json", cur)
108
+
109
+
110
+ # --------------------------------------------------------------------------
111
+ # HELPERS (private)
112
+ # --------------------------------------------------------------------------
113
+
114
+
115
+ def new_message_id() -> str:
116
+ """Return a fresh message id, e.g. ``m-1a2b3c4d``."""
117
+ return "m-" + uuid.uuid4().hex[:8]
118
+
119
+
120
+ def format_header(from_, to, msg_id, time, re_=None) -> str:
121
+ """Build the From/To/Id/Time/(Re) header block. The model never writes this."""
122
+ lines = [f"From: {from_}", f"To: {to}", f"Id: {msg_id}", f"Time: {time}"]
123
+ if re_ is not None:
124
+ lines.append(f"Re: {re_}")
125
+ return "\n".join(lines)
126
+
127
+
128
+ def stamp_message(body, from_, to, msg_id, re_=None) -> str:
129
+ """Header + blank line + body. The orchestrator stamps every message."""
130
+ return format_header(from_, to, msg_id, log.now_iso(), re_) + "\n\n" + body
131
+
132
+
133
+ def mark_read(cfg: SwarmConfig, sender: str, msg_id: str) -> None:
134
+ """Record a read receipt for *msg_id* (originally from *sender*).
135
+
136
+ Best-effort: the authoritative read-state lives in the recipient's own
137
+ ``read/`` folder + ``<agent>.read.json`` processed list, so a duplicate
138
+ receipt here can never wedge anything. We log it so the event is durable.
139
+ """
140
+ data = _load_run_json(cfg, f"{sender}.readreceipts.json")
141
+ data[msg_id] = log.now_iso()
142
+ _save_run_json(cfg, f"{sender}.readreceipts.json", data)
143
+ log.log_event(cfg, sender, "read-receipt", id=msg_id)
144
+
145
+
146
+ def enqueue(cfg: SwarmConfig, recipient: str, text: str, msg_id: str) -> None:
147
+ """Write *text* into ``cfg.queue_dir/<recipient>/<msg_id>.txt`` (the queue).
148
+
149
+ After the message is durably queued, best-effort mirror it to Telegram (a
150
+ no-op unless configured). Mirroring runs AFTER the write and can never raise
151
+ -- correctness never depends on the network (see lib/telegram.py).
152
+
153
+ We stamp the queue file with a strictly-increasing modification time (>= every
154
+ file already queued for this recipient) so :func:`queued_files` gives true
155
+ FIFO. Filesystem mtime granularity can't be relied on: some filesystems hand
156
+ identical mtimes to writes made in the same coarse tick, which would let
157
+ release order fall back to the random message-id name and starve a message
158
+ whose id happens to sort late. Enqueues for one recipient are serialised by
159
+ this same lock, so max-existing+1 is a safe monotonic clock per queue.
160
+ """
161
+ with lock.file_lock(cfg, recipient, "mail"):
162
+ q = cfg.queue_dir / recipient
163
+ q.mkdir(parents=True, exist_ok=True)
164
+ path = q / f"{msg_id}.txt"
165
+ path.write_text(text)
166
+ prior = [f.stat().st_mtime_ns for f in q.iterdir() if f.is_file() and f != path]
167
+ stamp = max([time.time_ns()] + [p + 1 for p in prior])
168
+ os.utime(path, ns=(stamp, stamp))
169
+ import telegram # lazy: keeps mail's import graph free of the bridge
170
+
171
+ telegram.on_enqueued(cfg, recipient, text, msg_id)
172
+
173
+
174
+ def rate_limited(cfg: SwarmConfig, a: str, b: str) -> bool:
175
+ """Per-pair sliding-window runaway-loop cap.
176
+
177
+ Returns True if the pair (a, b) has already exchanged >= ``RUNAWAY_CAP``
178
+ messages in the last ``RUNAWAY_WINDOW_S`` seconds; otherwise records this
179
+ one and returns False.
180
+ """
181
+ key = "-".join(sorted([a, b]))
182
+ path = cfg.run_dir / f"{key}.loop.json"
183
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
184
+ now = time.time()
185
+ try:
186
+ data = json.loads(path.read_text()) if path.exists() else []
187
+ except (json.JSONDecodeError, OSError):
188
+ data = []
189
+ cutoff = now - RUNAWAY_WINDOW_S
190
+ data = [t for t in data if t >= cutoff]
191
+ data.append(now)
192
+ path.write_text(json.dumps(data))
193
+ return len(data) > RUNAWAY_CAP
194
+
195
+
196
+ def _take_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str):
197
+ """Find the outbox message file for (sender -> recipient) whose content matches *body*.
198
+
199
+ Never returns the ``about.md`` contact card.
200
+ """
201
+ mp = cfg.mail_paths(cfg.get(sender))
202
+ d = mp.outbox / recipient
203
+ if not d.exists():
204
+ return None
205
+ files = [f for f in sorted(d.iterdir()) if f.is_file() and f.name != "about.md"]
206
+ for f in files:
207
+ try:
208
+ if f.read_text() == body:
209
+ return f
210
+ except OSError: # pragma: no cover - defensive only
211
+ continue
212
+ return files[0] if files else None
213
+
214
+
215
+ def _move_outbox_file(cfg: SwarmConfig, sender: str, recipient: str, body: str, dest: Path) -> None:
216
+ """Move the matching outbox file for (sender -> recipient) into *dest*."""
217
+ f = _take_outbox_file(cfg, sender, recipient, body)
218
+ if f is None:
219
+ return
220
+ dest.mkdir(parents=True, exist_ok=True)
221
+ shutil.move(str(f), str(dest / f.name))
222
+
223
+
224
+ def _parse_header_field(text: str, field: str):
225
+ m = re.search(rf"^{re.escape(field)}:\s*(.+)$", text, re.MULTILINE)
226
+ return m.group(1).strip() if m else None
227
+
228
+
229
+ # --------------------------------------------------------------------------
230
+ # PUBLIC API
231
+ # --------------------------------------------------------------------------
232
+
233
+
234
+ def init_mailboxes(cfg: SwarmConfig) -> None:
235
+ """Create every agent's five mailbox folders + per-agent queue + outbox ACL folders."""
236
+ with lock.file_lock(cfg, "init", "mail"):
237
+ for agent in cfg.agents:
238
+ mp = cfg.mail_paths(agent)
239
+ for d in (mp.inbox, mp.outbox, mp.read, mp.sent, mp.failed):
240
+ d.mkdir(parents=True, exist_ok=True)
241
+ (cfg.queue_dir / agent.name).mkdir(parents=True, exist_ok=True)
242
+ for peer in agent.can_talk_to:
243
+ (mp.outbox / peer).mkdir(parents=True, exist_ok=True)
244
+ write_contact_cards(cfg)
245
+
246
+
247
+ def write_contact_cards(cfg: SwarmConfig) -> None:
248
+ """(Re)write every ``outbox/<peer>/about.md`` contact card.
249
+
250
+ The mere presence of a peer's folder+card IS the ACL: only agents listed in
251
+ ``can_talk_to`` get one. For ``user`` the card's Status reflects
252
+ ``cfg.user_available`` (available / away).
253
+ """
254
+ for a in cfg.agents:
255
+ for b in cfg.agents:
256
+ if a.name in b.can_talk_to:
257
+ mp = cfg.mail_paths(b)
258
+ card = mp.outbox / a.name / "about.md"
259
+ card.parent.mkdir(parents=True, exist_ok=True)
260
+ card.write_text(
261
+ f"Name: {a.name}\nRole: {a.role}\nStatus: available\n"
262
+ )
263
+ status = "available" if cfg.user_available else "away"
264
+ for b in cfg.agents:
265
+ if "user" in b.can_talk_to:
266
+ mp = cfg.mail_paths(b)
267
+ card = mp.outbox / "user" / "about.md"
268
+ card.parent.mkdir(parents=True, exist_ok=True)
269
+ card.write_text(
270
+ f"Name: user\nRole: human operator\nStatus: {status}\n"
271
+ )
272
+
273
+
274
+ def standby_prompt(cfg: SwarmConfig, agent) -> str:
275
+ """Build the FIRST message an agent receives at ``up`` (its initialization).
276
+
277
+ The agent's standing ``role`` (its identity + how to use the mailbox) is
278
+ delivered up front, wrapped with an explicit STANDBY notice: no task has been
279
+ assigned yet, so the agent must NOT initiate any mail. This stops a proactive
280
+ model from spinning up at startup and mailing its peers before any real task
281
+ exists -- the human delivers the first task via ``agentainer send``, and the
282
+ normal nudge is what notifies the agent when that task lands.
283
+
284
+ Principle 3: the model is always told its exact mailbox paths, so the standby
285
+ states them rather than assuming the agent knows them.
286
+ """
287
+ mp = cfg.mail_paths(agent)
288
+ allowed = ", ".join(p for p in agent.can_talk_to if p != "system") or "(no one yet)"
289
+ notice = (
290
+ "\n---\n"
291
+ "This is your initialization message. No task has been assigned to you yet.\n\n"
292
+ "Do NOT write any file to your outbox/ and do NOT send any message. "
293
+ "You will be notified (a new message will appear in your inbox) when your "
294
+ "first real task arrives. Until then, simply wait and take no action.\n\n"
295
+ "Your mailbox (for when a task arrives):\n"
296
+ f" inbox: {mp.inbox}\n"
297
+ f" outbox: {mp.outbox} (write a file into outbox/<name>/ to send)\n"
298
+ f" read: {mp.read} (move a handled message here)\n"
299
+ f"You can message: {allowed}.\n\n"
300
+ "HOW TO SEND: write your message as a file into outbox/<name>/ (one file per "
301
+ "recipient; read outbox/<name>/about.md first). The moment you have written "
302
+ "your outgoing mail, your TURN IS DONE -- stop and wait. The orchestrator "
303
+ "delivers it and will notify you (a fresh message appears in your inbox and "
304
+ "you'll be nudged) when the recipient replies. Never poll your inbox or run a "
305
+ "loop waiting for a reply; doing so just delays delivery and wedges the swarm."
306
+ )
307
+ if agent.role:
308
+ return agent.role.rstrip() + notice
309
+ return (
310
+ "You are an agent in a multi-agent swarm, but your standing role has not "
311
+ "been set.\n" + notice
312
+ )
313
+
314
+
315
+ def _queue_order(path: Path):
316
+ """FIFO sort key for a queued message: enqueue time first, name as tie-break.
317
+
318
+ Message filenames are random ids (``m-<uuid8>.txt``), so sorting the queue
319
+ by *name* is a random order -- a message whose id happens to sort late gets
320
+ starved indefinitely as lower-sorting ids keep cutting in line. Queue files
321
+ are written once by ``enqueue`` and never modified, so ``st_mtime_ns`` is
322
+ the moment the message was enqueued: order by that to get true FIFO.
323
+ """
324
+ return (path.stat().st_mtime_ns, path.name)
325
+
326
+
327
+ def queued_files(cfg: SwarmConfig, agent_name: str) -> list:
328
+ """Return *agent_name*'s queued message files in FIFO (enqueue-time) order.
329
+
330
+ A missing queue dir (agent never received mail) is treated as empty.
331
+ """
332
+ q = cfg.queue_dir / agent_name
333
+ if not q.exists():
334
+ return []
335
+ return sorted((f for f in q.iterdir() if f.is_file()), key=_queue_order)
336
+
337
+
338
+ def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
339
+ """Release the oldest queued message into *agent_name*'s inbox (one-at-a-time).
340
+
341
+ "Oldest" is by enqueue time (see :func:`queued_files`), not filename -- the
342
+ ids are random, so filename order would starve a late-sorting message.
343
+
344
+ Returns False if the inbox already holds a message (one-at-a-time) or the
345
+ queue is empty; True if a message was moved into the inbox. Each release
346
+ bumps the presentation counter used by the auto-archive fallback.
347
+ """
348
+ agent = cfg.get(agent_name)
349
+ mp = cfg.mail_paths(agent)
350
+ inbox = mp.inbox
351
+ released_id = None
352
+ # The whole read-decide-move runs under ONE per-recipient lock. It used to
353
+ # check the inbox / pick files[0] OUTSIDE the lock (only the move was
354
+ # guarded), so two concurrent release_next(B) calls -- e.g. two agents
355
+ # both stop and release into B, or a hook firing during a supervisor
356
+ # tick -- could each observe an empty inbox, each pick files[0], and
357
+ # either land two messages in one inbox (one-at-a-time breached) or
358
+ # crash with FileNotFoundError when the other already renamed the file
359
+ # away. Serialising the decision under the lock removes the TOCTOU.
360
+ # route_outbound's enqueue already takes this same per-recipient lock,
361
+ # so there is no new lock-ordering edge (see config/supervisor on the
362
+ # queue -> pane -> turn-state discipline).
363
+ with lock.file_lock(cfg, agent_name, "mail"):
364
+ inbox.mkdir(parents=True, exist_ok=True)
365
+ existing = sorted(inbox.iterdir())
366
+ if existing:
367
+ # One-at-a-time: a message is already presented; count it as a presentation.
368
+ _bump_presentations(cfg, agent_name, existing[0].name)
369
+ return False
370
+ files = queued_files(cfg, agent_name)
371
+ if not files:
372
+ return False
373
+ oldest = files[0]
374
+ try:
375
+ shutil.move(str(oldest), str(inbox / oldest.name))
376
+ except FileNotFoundError:
377
+ # Another process already released this exact file (we lost the
378
+ # race). The inbox is populated / the file is gone -- there is
379
+ # nothing for us to release, so treat it as "already presented".
380
+ # Swallowing this keeps a lost race from crashing the caller,
381
+ # which -- for the supervisor tick -- would otherwise kill the
382
+ # liveness heartbeat.
383
+ return False
384
+ released_id = oldest.name
385
+ log.log_event(cfg, agent_name, "delivered", id=released_id)
386
+ _set_presentations(cfg, agent_name, released_id, 1)
387
+ return True
388
+
389
+
390
+ def nudge(cfg: SwarmConfig, agent_name: str) -> bool:
391
+ """Re-inject the protocol into *agent_name*'s pane; return ``paste_into``'s result.
392
+
393
+ The nudge states the agent's EXACT mailbox paths (the model never assumes
394
+ them) and lists the recipients it is allowed to message. The caller ensures
395
+ the agent is idle first; a paste failure just means we retry on the next tick.
396
+ """
397
+ agent = cfg.get(agent_name)
398
+ mp = cfg.mail_paths(agent)
399
+ allowed = ", ".join(p for p in agent.can_talk_to if p != "system")
400
+ nudge_text = (
401
+ f"You have a new message in {mp.inbox}. Read it and do what it asks.\n"
402
+ f"When you're done, move that file to {mp.read}.\n"
403
+ f"To send a message, write a file into {mp.outbox}/<name>/ "
404
+ f"(read {mp.outbox}/<name>/about.md to see who they are and whether "
405
+ f"they're available). The moment you've written your outgoing mail, your "
406
+ f"TURN IS DONE -- stop and wait; you'll be notified (a new message + nudge) "
407
+ f"when the recipient replies. Do not poll your inbox or wait for the reply "
408
+ f"yourself; that only delays delivery.\n"
409
+ f"You can message: {allowed}."
410
+ )
411
+ try:
412
+ pasted = tmux.paste_into(cfg, agent.session, nudge_text)
413
+ except tmux.SwarmError:
414
+ # Best-effort: if the agent isn't up (or tmux is unavailable) the mail
415
+ # still sits in the queue and gets released on the next sweep / when the
416
+ # agent starts. Never crash a send because a session is missing -- a
417
+ # paste failure just means we retry on the next tick.
418
+ return False
419
+ if pasted:
420
+ # The agent was actually poked about a freshly delivered message, so it
421
+ # is now mid-turn. Mark the turn STARTED so busy-detection is accurate:
422
+ # otherwise (delivered stayed == completed after the first prompt) the
423
+ # supervisor treats a long but legitimate turn as idle, bumps the inbox
424
+ # message's presentation count every tick, and auto-archives it mid-turn
425
+ # -- yanking the task and corrupting the turn. The inbox holds exactly
426
+ # one message (one-at-a-time); its From is the task's origin.
427
+ sender = None
428
+ try:
429
+ msgs = sorted(f for f in mp.inbox.iterdir() if f.is_file())
430
+ if msgs:
431
+ sender = _parse_header_field(msgs[0].read_text(), "From")
432
+ except OSError: # pragma: no cover - defensive only
433
+ sender = None
434
+ turn.mark_turn_started(cfg, agent_name, sender or "system")
435
+ return pasted
436
+
437
+
438
+ def route_outbound(cfg: SwarmConfig, sender: str, recipient: str, body: str) -> str:
439
+ """Route one outbound message from *sender* to *recipient*. Returns one of
440
+ ``delivered`` / ``bounce`` / ``rate-limited`` / ``user-held``.
441
+
442
+ The orchestrator owns all routing/ACL/state; the model only wrote the file.
443
+ """
444
+ sender_agent = cfg.get(sender)
445
+ mp = cfg.mail_paths(sender_agent)
446
+
447
+ if recipient == "system":
448
+ # system is never a recipient -- bounce back as mail + drop to failed/.
449
+ system_mail(cfg, sender, "system is not a valid recipient -- your message was not delivered.")
450
+ _move_outbox_file(cfg, sender, "system", body, mp.failed)
451
+ log.log_event(cfg, sender, "bounce", to="system", reason="system-recipient")
452
+ return "bounce"
453
+
454
+ if recipient == "user":
455
+ return deliver_to_user(cfg, sender, body)
456
+
457
+ if recipient not in cfg.get(sender).can_talk_to:
458
+ allowed = ", ".join(x for x in cfg.get(sender).can_talk_to if x != "system")
459
+ system_mail(
460
+ cfg, sender,
461
+ f"Your message to {recipient} couldn't be sent -- you can message: {allowed}.",
462
+ )
463
+ _move_outbox_file(cfg, sender, recipient, body, mp.failed)
464
+ log.log_event(cfg, sender, "bounce", to=recipient, reason="acl")
465
+ return "bounce"
466
+
467
+ if rate_limited(cfg, sender, recipient):
468
+ _move_outbox_file(cfg, sender, recipient, body, mp.failed)
469
+ log.log_event(cfg, sender, "rate-limited", to=recipient)
470
+ return "rate-limited"
471
+
472
+ msg_id = new_message_id()
473
+ text = stamp_message(body, sender, recipient, msg_id)
474
+ enqueue(cfg, recipient, text, msg_id)
475
+ _move_outbox_file(cfg, sender, recipient, body, mp.sent)
476
+ log.log_event(cfg, sender, "route", from_=sender, to=recipient, id=msg_id)
477
+ return "delivered"
478
+
479
+
480
+ def on_stop(cfg: SwarmConfig, agent_name: str) -> dict:
481
+ """THE CORE: an agent stopped. Sweep its outbox, route every message, then
482
+ release+nudge every recipient that received mail. Returns a summary dict.
483
+
484
+ The orchestrator owns authoritative state, so even a forgetful model that
485
+ never moves mail to ``read/`` can't wedge the swarm.
486
+ """
487
+ agent = cfg.get(agent_name)
488
+ mp = cfg.mail_paths(agent)
489
+
490
+ # 1) Snapshot the outbox under a lock, then route OUTSIDE the lock so
491
+ # enqueue() (which takes its own per-recipient lock) can't deadlock.
492
+ pending = []
493
+ with lock.file_lock(cfg, agent_name, "mail"):
494
+ if mp.outbox.exists():
495
+ for sub in sorted(mp.outbox.iterdir()):
496
+ if not sub.is_dir():
497
+ continue
498
+ recipient = sub.name
499
+ # Route in the order the agent WROTE the files (enqueue/delivery
500
+ # order follows this), not by the model's arbitrary filenames --
501
+ # otherwise two messages to the same peer can arrive out of order.
502
+ for f in sorted((c for c in sub.iterdir() if c.is_file()), key=_queue_order):
503
+ # about.md is the orchestrator-maintained contact card, not
504
+ # an outbound message -- never route or delete it.
505
+ if f.name == "about.md":
506
+ continue
507
+ pending.append((recipient, f.read_text(), f))
508
+
509
+ delivered = bounced = rate_limited_count = 0
510
+ recipients: set[str] = set()
511
+
512
+ for recipient, body, f in pending:
513
+ result = route_outbound(cfg, agent_name, recipient, body)
514
+ # route_outbound already moved the file to sent/ or failed/; remove any
515
+ # leftover original so it is never double-routed.
516
+ if f.exists():
517
+ f.unlink()
518
+ if result == "delivered":
519
+ delivered += 1
520
+ if recipient != "user":
521
+ recipients.add(recipient)
522
+ elif result == "user-held":
523
+ delivered += 1
524
+ elif result == "bounce":
525
+ bounced += 1
526
+ recipients.add(agent_name) # the sender gets the bounce as mail
527
+ elif result == "rate-limited":
528
+ rate_limited_count += 1
529
+
530
+ # 2) The turn is finished (clamps busy counters) before we release mail.
531
+ turn.on_turn_finished(cfg, agent_name)
532
+
533
+ # 3) Deliver mail to everyone who received/queued some (including the sender,
534
+ # who may have just been bounced an error). release_next is one-at-a-time
535
+ # and nudge only fires when a message actually landed.
536
+ for r in sorted(recipients):
537
+ if release_next(cfg, r):
538
+ nudge(cfg, r)
539
+
540
+ return {"delivered": delivered, "bounced": bounced, "rate_limited": rate_limited_count}
541
+
542
+
543
+ def process_read_folder(cfg: SwarmConfig, agent_name: str) -> int:
544
+ """Process *agent_name*'s ``read/`` folder: emit read receipts and run the
545
+ auto-archive fallback. Returns the number of new read receipts emitted."""
546
+ agent = cfg.get(agent_name)
547
+ mp = cfg.mail_paths(agent)
548
+ read_dir = mp.read
549
+ state = _load_run_json(cfg, f"{agent_name}.read.json")
550
+ processed = set(state.get("processed", []))
551
+ count = 0
552
+
553
+ if read_dir.exists():
554
+ for f in sorted(read_dir.iterdir()):
555
+ if not f.is_file():
556
+ continue
557
+ msg_id = _parse_header_field(f.read_text(), "Id")
558
+ if msg_id is None or msg_id in processed:
559
+ continue
560
+ sender = _parse_header_field(f.read_text(), "From") or "system"
561
+ mark_read(cfg, sender, msg_id)
562
+ processed.add(msg_id)
563
+ log.log_event(cfg, agent_name, "read", id=msg_id)
564
+ count += 1
565
+
566
+ state["processed"] = sorted(processed)
567
+ _save_run_json(cfg, f"{agent_name}.read.json", state)
568
+
569
+ # Auto-archive fallback (plan §7): a single message presented >= N times
570
+ # without being handled is moved to the archive so a forgetful model can
571
+ # never wedge the swarm. We only ARCHIVE here (empty the inbox); we do NOT
572
+ # release the next message, because the release must be paired with a nudge
573
+ # -- and every caller (supervisor tick, cmd_idle) runs release_next + nudge
574
+ # right after us. Releasing here (un-nudged) meant the freshly delivered
575
+ # message was never announced to the agent, so it silently climbed to its
576
+ # own auto-archive threshold and was discarded unread -- draining the whole
577
+ # queue into the archive. Leaving the inbox empty lets the caller's
578
+ # release+nudge deliver-and-announce the next message like any other.
579
+ inbox = mp.inbox
580
+ if inbox.exists():
581
+ msgs = sorted(f for f in inbox.iterdir() if f.is_file())
582
+ if len(msgs) == 1:
583
+ f = msgs[0]
584
+ pres = _get_presentations(cfg, agent_name)
585
+ # `processed` holds message CONTENT ids ("m-arc2"), so the
586
+ # "already handled" guard must compare the inbox message's own
587
+ # content id -- NOT f.name ("m-arc2.txt"), which never matches and
588
+ # would let a handled message be archived anyway.
589
+ inbox_id = _parse_header_field(f.read_text(), "Id")
590
+ if (
591
+ pres.get("msg_id") == f.name
592
+ and pres.get("count", 0) >= AUTO_ARCHIVE_PRESENTATIONS
593
+ and inbox_id not in processed
594
+ ):
595
+ log.archive_message(cfg, agent_name, f)
596
+
597
+ return count
598
+
599
+
600
+ def maybe_ping(cfg: SwarmConfig, agent_name: str) -> bool:
601
+ """Inject a periodic ``system`` ping into *agent_name*'s queue, respecting
602
+ the three §10 guards. Returns True if a ping was injected.
603
+
604
+ Guards: (a) idle-only -- skip if busy; (b) no-pile-up -- skip if an unhandled
605
+ ping marker already sits in the queue/inbox; (c) cadence-is-minimum -- skip
606
+ unless ``periodically_ping_seconds`` has elapsed since the last ping.
607
+ """
608
+ agent = cfg.get(agent_name)
609
+ if agent.periodically_ping_seconds <= 0:
610
+ return False
611
+ if turn.busy_info(cfg, agent) is not None:
612
+ return False
613
+
614
+ # (b) no-pile-up: a still-unhandled ping is a file whose name starts with the marker.
615
+ queue = cfg.queue_dir / agent_name
616
+ inbox = cfg.mail_paths(agent).inbox
617
+ for d in (queue, inbox):
618
+ if d.exists():
619
+ for f in d.iterdir():
620
+ if f.name.startswith(PING_MARKER):
621
+ return False
622
+
623
+ # (c) cadence-is-minimum.
624
+ state = _load_run_json(cfg, f"{agent_name}.ping.json")
625
+ last = state.get("last_ping", 0.0)
626
+ now = time.time()
627
+ if now - last < agent.periodically_ping_seconds:
628
+ return False
629
+
630
+ msg_id = PING_MARKER + uuid.uuid4().hex[:8]
631
+ text = stamp_message(agent.periodically_ping_message, "system", agent_name, msg_id)
632
+ enqueue(cfg, agent_name, text, msg_id)
633
+ state["last_ping"] = now
634
+ _save_run_json(cfg, f"{agent_name}.ping.json", state)
635
+ log.log_event(cfg, agent_name, "ping")
636
+ return True
637
+
638
+
639
+ def deliver_to_user(cfg: SwarmConfig, sender: str, body: str) -> str:
640
+ """Deliver *sender*'s message into the virtual ``user`` mailbox.
641
+
642
+ Returns ``delivered`` (user available), ``user-held`` (user away -- mail is
643
+ held, never bounced, and a ``system`` ack is dropped into the sender), or
644
+ ``bounce`` (sender not allowed to message the user). The sender's outbox
645
+ file is moved to ``sent/`` -- the send itself always succeeds; only the
646
+ human's reply is deferred.
647
+ """
648
+ mp = cfg.mail_paths(cfg.get(sender))
649
+ if "user" not in cfg.get(sender).can_talk_to:
650
+ system_mail(cfg, sender, "You can't message the user -- they're not in your can_talk_to.")
651
+ _move_outbox_file(cfg, sender, "user", body, mp.failed)
652
+ log.log_event(cfg, sender, "bounce", to="user", reason="acl")
653
+ return "bounce"
654
+
655
+ msg_id = new_message_id()
656
+ text = stamp_message(body, sender, "user", msg_id)
657
+ enqueue(cfg, "user", text, msg_id)
658
+ _move_outbox_file(cfg, sender, "user", body, mp.sent)
659
+ if cfg.user_available:
660
+ log.log_event(cfg, sender, "delivered", to="user", id=msg_id)
661
+ return "delivered"
662
+ system_mail(cfg, sender, "Delivered -- the user is away and may respond later.")
663
+ log.log_event(cfg, sender, "user-held", to="user", id=msg_id)
664
+ return "user-held"
665
+
666
+
667
+ def send_as_user(cfg: SwarmConfig, to_agent: str, body: str) -> None:
668
+ """The human/UI sends a message FROM ``user`` to *to_agent*."""
669
+ msg_id = new_message_id()
670
+ text = stamp_message(body, "user", to_agent, msg_id)
671
+ enqueue(cfg, to_agent, text, msg_id)
672
+ log.log_event(cfg, to_agent, "user-send", from_="user", id=msg_id)
673
+ if release_next(cfg, to_agent):
674
+ nudge(cfg, to_agent)
675
+
676
+
677
+ def system_mail(cfg: SwarmConfig, to_agent: str, body: str, *, kind: str = "system") -> None:
678
+ """Enqueue a ``system`` message (From: system) into *to_agent*'s queue.
679
+
680
+ Used for bounces, acks, and periodic pings -- errors come back as mail so
681
+ the model self-corrects in-band.
682
+ """
683
+ msg_id = new_message_id()
684
+ text = stamp_message(body, "system", to_agent, msg_id)
685
+ enqueue(cfg, to_agent, text, msg_id)
686
+ log.log_event(cfg, to_agent, kind, from_="system")
687
+
688
+
689
+ def set_user_available(cfg: SwarmConfig, available: bool) -> None:
690
+ """Set the user's availability toggle and rewrite the live ``user`` contact cards."""
691
+ cfg.user_available = available
692
+ for b in cfg.agents:
693
+ if "user" in b.can_talk_to:
694
+ mp = cfg.mail_paths(b)
695
+ card = mp.outbox / "user" / "about.md"
696
+ card.parent.mkdir(parents=True, exist_ok=True)
697
+ status = "available" if available else "away"
698
+ card.write_text(f"Name: user\nRole: human operator\nStatus: {status}\n")
699
+ log.log_event(cfg, "user", "user-available" if available else "user-away")