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/config.py CHANGED
@@ -1,4 +1,21 @@
1
- """Load, normalise and validate an Agentainer YAML config."""
1
+ """Load, normalise and validate an Agentainer YAML config.
2
+
3
+ This is the contract anchor every other module imports. It ports v1's proven
4
+ config loader but adapts the schema to the v2 file-based mail model:
5
+
6
+ * the agent's standing instructions are now ``role`` (``first_prompt`` is a
7
+ deprecated alias);
8
+ * the XML-envelope comms/reply-reminder machinery is gone -- v2 is plain
9
+ natural-language mail files read/written by the model;
10
+ * per-agent ``mail_dir`` selects where the four mailbox folders live, with
11
+ automatic namespacing when two agents share one workspace (see ``mail_paths``);
12
+ * ``user``/``system`` are reserved virtual mailboxes, never agent names;
13
+ * new ``type`` <-> ``command`` mismatch detection prevents the silent-deadlock
14
+ footgun carried forward from v1.
15
+
16
+ The orchestrator owns all routing/ACL/state; the model only reads and writes
17
+ natural-language files. See ProjectPlan.md (§4-§16, §24, §29).
18
+ """
2
19
 
3
20
  from __future__ import annotations
4
21
 
@@ -6,6 +23,7 @@ import os
6
23
  import re
7
24
  from dataclasses import dataclass, field
8
25
  from pathlib import Path
26
+ from types import SimpleNamespace
9
27
  from typing import Any
10
28
 
11
29
  try: # pragma: no cover - exercised by whichever branch is installed
@@ -30,6 +48,12 @@ class ConfigError(Exception):
30
48
  pass
31
49
 
32
50
 
51
+ # The four real coding-agent CLIs. A `command` that launches a *different* one
52
+ # than `type` implies will never fire its turn-completion signal and wedges the
53
+ # agent. Used by the mismatch detector below. A mock command (e.g.
54
+ # `bash -c 'while true; do read ...'`) contains none of these tokens and passes.
55
+ CLI_TOKENS = ("claude", "codex", "gemini", "hermes")
56
+
33
57
  # Built-in knowledge about each supported coding agent. `capture` says how we
34
58
  # learn that the agent finished a turn:
35
59
  # hook -- the CLI can call an external program on turn completion
@@ -64,137 +88,8 @@ BUILTIN_AGENT_TYPES: dict[str, dict[str, Any]] = {
64
88
 
65
89
  VALID_CAPTURE = ("hook", "pane", "none", "auto")
66
90
 
67
- VALID_MESSAGE_FORMATS = ("tagged", "plain")
68
-
69
91
  NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
70
92
 
71
- DEFAULT_COMMS_TEMPLATE = """\
72
- ---
73
- ## Inter-agent communication protocol
74
-
75
- You are the agent **{agent}** on the **{swarm}** team.
76
- Agents you are allowed to message: {peers}
77
-
78
- ### Receiving
79
-
80
- Messages arrive in your prompt inside a tag, so you can always tell where one
81
- starts and ends:
82
-
83
- <swarm-message from="SOMEONE" id="m-1a2b3c">
84
- the message body,
85
- which may span many lines
86
- </swarm-message>
87
-
88
- Note the `id`. Quote it as `reply-to` when you answer, so the other agent knows
89
- which of its questions you are answering. Every message is also archived under
90
- `{inbox}` if you need to re-read one.
91
-
92
- ### Sending
93
-
94
- Sending is a deliberate act, not something every turn needs. **Most of your turns
95
- will contain no tag at all** -- you think, run commands, and work in your own
96
- directory in plain text, exactly as you normally would. Write a message block ONLY
97
- when you actually want to hand something to another agent: a question, a result, or
98
- a handoff. When you have nothing to send, send nothing.
99
-
100
- When you do want to send, write this block **in your reply**. It is read as soon as
101
- your turn ends, then delivered. Do not escape or quote the body -- write it
102
- literally, across as many lines as you need:
103
-
104
- <swarm-send to="AGENT_NAME" reply-to="m-1a2b3c">
105
- Your message. Multiple lines, code blocks and quotes are all fine.
106
- </swarm-send>
107
-
108
- To reach everyone you are allowed to talk to, use `<swarm-broadcast>` with no `to`.
109
-
110
- The `reply-to` attribute is optional; drop it when you are starting a new thread.
111
- Quoting it also marks your message as an answer, so the other agent is not
112
- chased for a reply to it. For an announcement that needs no answer, either use
113
- `<swarm-broadcast>` or add `expects-reply="false"`.
114
-
115
- You may also send from your shell, which is useful mid-task rather than at the
116
- end of a turn (but you must then quote the text yourself):
117
-
118
- swarm send --to AGENT_NAME "MESSAGE TEXT"
119
- swarm broadcast "MESSAGE TEXT"
120
-
121
- ### When the other agent is busy
122
-
123
- An agent already working on a task will refuse your message. That is normal. A
124
- tagged `<swarm-send>` is queued for them automatically. From the shell you choose:
125
-
126
- swarm send --to AGENT_NAME --queue "MESSAGE TEXT" # delivered when they are free
127
- swarm send --to AGENT_NAME --wait "MESSAGE TEXT" # block until they are free
128
- swarm queue AGENT_NAME # see what is waiting for them
129
-
130
- Prefer queueing, then get on with other work. Never spin in a retry loop.
131
-
132
- ### Rules
133
-
134
- * `AGENT_NAME`, `MESSAGE TEXT` and `m-1a2b3c` above are placeholders. Replace
135
- them with a real agent from the list and what you actually want to say. A
136
- `<swarm-send>` addressed to a name that is not an agent is discarded.
137
- * Only message the agents listed above. Messaging anyone else will be refused.
138
- * Keep messages self-contained -- the other agent does not see your screen,
139
- your files, or your reasoning.
140
- * Do not message an agent merely to acknowledge. Send when you have a question,
141
- a result, or a handoff.
142
- * Write a full tag block only when you actually want to send. A complete
143
- `<swarm-send to="...">...</swarm-send>` you write is delivered, so do not paste
144
- a filled-in example to illustrate a point -- it would be sent for real. Naming
145
- the tag in passing while you explain your plan is fine.
146
- * A turn with no tag simply sends nothing, which is normal and expected. The one
147
- exception: if another agent asked YOU something and your turn ends with no
148
- block, your answer reaches nobody, so you get a single reminder. Never add a
149
- block just to satisfy the reminder -- only to actually answer.
150
- """
151
-
152
- DEFAULT_REPLY_REMINDER_TEMPLATE = """\
153
- Your last turn sent no message to anyone, and {sender} is waiting on your answer
154
- to message {id}.
155
-
156
- {problems}
157
-
158
- Anything you write outside a message block stays on your own screen. The other
159
- agent cannot see your reasoning, your files, or your reply. To actually send it,
160
- put your answer inside a block exactly like this:
161
-
162
- <swarm-send to="{sender}" reply-to="{id}">
163
- your answer here, over as many lines as you need
164
- </swarm-send>
165
-
166
- Agents you may message: {peers}
167
-
168
- If you already wrote the answer, write it again inside that block. If you
169
- deliberately have nothing to send, ignore this -- you will not be reminded again.
170
- """
171
-
172
- DEFAULT_SEND_FAILED_TEMPLATE = """\
173
- Your last turn tried to send a message, but it could not be delivered.
174
-
175
- {problems}
176
-
177
- Fix it and send again. The block must look exactly like this, closing tag included:
178
-
179
- <swarm-send to="AGENT_NAME">
180
- your message here
181
- </swarm-send>
182
-
183
- Agents you may message: {peers}
184
-
185
- You will not be reminded again.
186
- """
187
-
188
- DEFAULT_TASK_NOTICE_TEMPLATE = """\
189
- ---
190
- ## Standby
191
-
192
- Do not start any work yet. Your actual task will be sent to you in the **next**
193
- prompt. For now, reply with one plain sentence confirming you understood your role
194
- -- no message block and no tags, this confirmation is just for your own screen and
195
- is not sent to anyone -- then wait.
196
- """
197
-
198
93
 
199
94
  @dataclass
200
95
  class Agent:
@@ -205,19 +100,34 @@ class Agent:
205
100
  session: str
206
101
  capture: str
207
102
  boot_delay_ms: int
208
- first_prompt: str
209
- can_talk_to: list[str] = field(default_factory=list)
210
- forward_responses_to: list[str] = field(default_factory=list)
103
+ role: str
104
+ can_talk_to: list[str]
105
+ mail_dir: Path
106
+ periodically_ping_seconds: int = 0
107
+ periodically_ping_message: str = ""
108
+ resume_args: str | None = None
109
+ resume_command: str | None = None
211
110
  env: dict[str, str] = field(default_factory=dict)
212
- append_peers_prompt: bool = True
213
- append_task_notice: bool = False
214
- ready_probe: bool = True
215
111
  create_workdir: bool = True
112
+ ready_probe: bool = True
216
113
  busy_check: bool = True
217
- parse_outbound_tags: bool = True
218
- reply_reminder: bool = True
219
- resume_args: str | None = None
220
- resume_command: str | None = None
114
+
115
+
116
+ @dataclass
117
+ class TelegramConfig:
118
+ """Optional Telegram bridge settings (parsed from a top-level ``telegram:``).
119
+
120
+ Off by default. ``mirror`` is ``"*"`` (mirror every agent's mail) or a list of
121
+ agent names. Mail addressed to ``user`` is mirrored per ``mirror_user`` so the
122
+ human stays reachable even while "away"; ``system`` noise is off by default.
123
+ """
124
+
125
+ enabled: bool = False
126
+ bot_token: str = ""
127
+ chat_id: str = ""
128
+ mirror: Any = "*"
129
+ mirror_user: bool = True
130
+ mirror_system: bool = False
221
131
 
222
132
 
223
133
  @dataclass
@@ -227,45 +137,57 @@ class SwarmConfig:
227
137
  root: Path
228
138
  session_prefix: str
229
139
  agents: list[Agent]
140
+ # Paste-timing knobs consumed by lib/tmux.py (keep in sync with that module).
230
141
  enter_delay_ms: int = 250
231
142
  send_delay_ms: int = 150
232
- max_forward_hops: int = 3
143
+ supervise: bool = True
144
+ supervise_interval_ms: int = 15000
233
145
  ready_timeout_ms: int = 60000
234
146
  busy_timeout_ms: int = 900000
235
- message_format: str = "tagged"
236
- max_reply_reminders: int = 1
237
- resume: bool = False
238
- reply_reminder_template: str = DEFAULT_REPLY_REMINDER_TEMPLATE
239
- send_failed_template: str = DEFAULT_SEND_FAILED_TEMPLATE
147
+ # Resume is ON by default: `up` reattaches each agent to the conversation
148
+ # recorded in .agentainer/sessions.yaml. Opt out with `swarm.resume: false`
149
+ # in the config, or `agentainer up --no-resume`, or `agentainer remove-session`.
150
+ resume: bool = True
151
+ user_available: bool = False
240
152
  pane_idle_ms: int = 2500
241
153
  pane_poll_ms: int = 700
242
154
  pane_scrollback: int = 400
243
155
  tmux_history_limit: int = 50000
244
156
  tmux_mouse: bool = True
245
- supervise: bool = True
246
- supervise_interval_ms: int = 15000
157
+ telegram: "TelegramConfig" = field(default_factory=TelegramConfig)
247
158
  warnings: list[str] = field(default_factory=list)
248
159
 
160
+ def __post_init__(self) -> None:
161
+ # Set of resolved mail_dirs shared by 2+ agents. Computed up front so
162
+ # `mail_paths` can namespace mailbox folders to avoid collisions. Keyed on
163
+ # mail_dir (where the four folders actually live), NOT workdir: two agents
164
+ # can point a shared `mail_dir` at one place while keeping distinct
165
+ # workdirs, and without this they would silently share one `inbox/`.
166
+ counts: dict[Path, int] = {}
167
+ for agent in self.agents:
168
+ key = agent.mail_dir.resolve()
169
+ counts[key] = counts.get(key, 0) + 1
170
+ self._shared: set[Path] = {d for d, n in counts.items() if n > 1}
171
+
249
172
  @property
250
173
  def runtime(self) -> Path:
251
- return self.root / ".swarm"
174
+ """Orchestrator-private state (logs, queue, run, sessions)."""
175
+ return self.root / ".agentainer"
252
176
 
253
177
  @property
254
178
  def log_dir(self) -> Path:
255
179
  return self.runtime / "logs"
256
180
 
257
181
  @property
258
- def inbox_dir(self) -> Path:
259
- return self.runtime / "inbox"
182
+ def queue_dir(self) -> Path:
183
+ # Per-agent pending-message buffer (one-at-a-time release lives here).
184
+ # Not created by the config layer -- just the authoritative path.
185
+ return self.runtime / "queue"
260
186
 
261
187
  @property
262
188
  def run_dir(self) -> Path:
263
189
  return self.runtime / "run"
264
190
 
265
- @property
266
- def bin_dir(self) -> Path:
267
- return self.runtime / "bin"
268
-
269
191
  @property
270
192
  def sessions_file(self) -> Path:
271
193
  """Where each agent's conversation id is recorded, so `up --resume` works."""
@@ -281,6 +203,25 @@ class SwarmConfig:
281
203
  def names(self) -> list[str]:
282
204
  return [a.name for a in self.agents]
283
205
 
206
+ def mail_paths(self, agent: Agent) -> SimpleNamespace:
207
+ """Resolve the five mailbox folders for *agent* (plan §16).
208
+
209
+ Base is ``agent.mail_dir``. When that mail_dir is shared by more than one
210
+ agent (whether because they share a workdir and inherit the default, or
211
+ because they explicitly point at the same directory), every folder is
212
+ prefixed with ``<name>-`` to avoid collisions. The model never sees this
213
+ -- every nudge/first-prompt is handed the exact computed paths.
214
+ """
215
+ base = agent.mail_dir
216
+ prefix = agent.name + "-" if agent.mail_dir.resolve() in self._shared else ""
217
+ return SimpleNamespace(
218
+ inbox=base / (prefix + "inbox"),
219
+ outbox=base / (prefix + "outbox"),
220
+ read=base / (prefix + "read"),
221
+ sent=base / (prefix + "sent"),
222
+ failed=base / (prefix + "failed"),
223
+ )
224
+
284
225
 
285
226
  def _as_list(value: Any, ctx: str) -> list[str]:
286
227
  if value is None:
@@ -308,13 +249,56 @@ def _as_str_map(value: Any, ctx: str) -> dict[str, str]:
308
249
  return {str(k): str(v) for k, v in value.items()}
309
250
 
310
251
 
252
+ def _parse_mirror(value: Any) -> Any:
253
+ """Normalise ``telegram.mirror``: ``*``/``all``/None -> ``"*"``; else a list."""
254
+ if value is None:
255
+ return "*"
256
+ if isinstance(value, str):
257
+ return "*" if value.strip().lower() in ("*", "all") else [value]
258
+ if isinstance(value, list):
259
+ return [str(v) for v in value]
260
+ raise ConfigError("telegram.mirror: expected '*', 'all', or a list of agent names")
261
+
262
+
263
+ def _load_telegram(data: dict) -> "TelegramConfig":
264
+ raw = data.get("telegram") or {}
265
+ if not isinstance(raw, dict):
266
+ raise ConfigError("`telegram:` must be a mapping")
267
+ return TelegramConfig(
268
+ enabled=_as_bool(raw.get("enabled"), False, "telegram.enabled"),
269
+ bot_token=str(raw.get("bot_token") or ""),
270
+ chat_id=str(raw.get("chat_id") or ""),
271
+ mirror=_parse_mirror(raw.get("mirror")),
272
+ mirror_user=_as_bool(raw.get("mirror_user"), True, "telegram.mirror_user"),
273
+ mirror_system=_as_bool(raw.get("mirror_system"), False, "telegram.mirror_system"),
274
+ )
275
+
276
+
277
+ def _expand_path(raw: Any, ctx: str, name: str, root: Path, swarm_name: str,
278
+ atype: str, cfg_parent: Path) -> Path:
279
+ """Expand an optional {name}/{root}/{swarm}/{type} placeholder and resolve
280
+ the result relative to the config file's parent directory."""
281
+ text = str(raw)
282
+ try:
283
+ expanded = text.format(name=name, root=str(root), swarm=swarm_name, type=atype)
284
+ except (KeyError, IndexError) as exc:
285
+ raise ConfigError(
286
+ f"{ctx}: unknown placeholder in {raw!r}: {exc}. "
287
+ "Available: {name} {root} {swarm} {type}"
288
+ ) from exc
289
+ path = Path(os.path.expanduser(expanded))
290
+ if not path.is_absolute():
291
+ path = (cfg_parent / path).resolve()
292
+ return path
293
+
294
+
311
295
  def load(path: str | os.PathLike) -> SwarmConfig:
312
296
  cfg_path = Path(path).expanduser().resolve()
313
297
  if not cfg_path.is_file():
314
298
  raise ConfigError(
315
299
  f"config file not found: {cfg_path}\n"
316
- " Create one with: cp agents.example.yaml agents.yaml\n"
317
- " Or point at it: agentainer -c /path/to/swarm.yaml up"
300
+ " Create one with: cp agentainer.example.yaml agentainer.yaml\n"
301
+ " Or point at it: agentainer -c /path/to/agentainer.yaml up"
318
302
  )
319
303
 
320
304
  try:
@@ -333,12 +317,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
333
317
  if not isinstance(defaults, dict):
334
318
  raise ConfigError("`defaults:` must be a mapping")
335
319
 
336
- templates = data.get("templates") or {}
337
- comms_tpl = templates.get("comms") or DEFAULT_COMMS_TEMPLATE
338
- notice_tpl = templates.get("task_notice") or DEFAULT_TASK_NOTICE_TEMPLATE
339
- reminder_tpl = templates.get("reply_reminder") or DEFAULT_REPLY_REMINDER_TEMPLATE
340
- failed_tpl = templates.get("send_failed") or DEFAULT_SEND_FAILED_TEMPLATE
341
-
342
320
  # Agent type registry: built-ins, overridable and extensible from YAML.
343
321
  types: dict[str, dict[str, Any]] = {
344
322
  k: dict(v) for k, v in BUILTIN_AGENT_TYPES.items()
@@ -357,23 +335,23 @@ def load(path: str | os.PathLike) -> SwarmConfig:
357
335
  swarm_name = str(swarm.get("name") or cfg_path.stem)
358
336
  create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
359
337
 
360
- message_format = str(swarm.get("message_format") or "tagged")
361
- if message_format not in VALID_MESSAGE_FORMATS:
362
- raise ConfigError(
363
- f"swarm.message_format must be one of {', '.join(VALID_MESSAGE_FORMATS)}"
364
- )
365
-
366
338
  raw_agents = data.get("agents")
367
- if not raw_agents:
368
- raise ConfigError("`agents:` must contain at least one agent")
339
+ if raw_agents is None:
340
+ # An empty swarm is valid: `agentainer up` brings up just the orchestrator
341
+ # + UI, and the operator seeds agents from a template / the Settings editor
342
+ # (P4). This also lets the UI remove the last agent without wedging.
343
+ raw_agents = []
369
344
  if not isinstance(raw_agents, list):
370
345
  raise ConfigError("`agents:` must be a list")
371
346
 
347
+ # Default mailbox base, if any (overridable per agent).
348
+ default_mail_dir_raw = defaults.get("mail_dir")
349
+
372
350
  # Pass 1: materialise agents without resolving peer references.
373
351
  agents: list[Agent] = []
374
352
  seen: set[str] = set()
375
- # Warnings collected while resolving each agent (e.g. capture upgrades).
376
- capture_warnings: list[str] = []
353
+ # Warnings collected while resolving each agent (capture upgrades + deprecations).
354
+ agent_warnings: list[str] = []
377
355
  for index, raw in enumerate(raw_agents):
378
356
  if not isinstance(raw, dict):
379
357
  raise ConfigError(f"agents[{index}]: must be a mapping")
@@ -385,6 +363,11 @@ def load(path: str | os.PathLike) -> SwarmConfig:
385
363
  f"agent {name!r}: name must match {NAME_RE.pattern} "
386
364
  "(it is used as a tmux session name and a directory name)"
387
365
  )
366
+ if name in ("user", "system"):
367
+ raise ConfigError(
368
+ f"agent {name!r}: user and system are reserved virtual mailboxes "
369
+ "and cannot be agent names"
370
+ )
388
371
  if name in seen:
389
372
  raise ConfigError(f"duplicate agent name: {name!r}")
390
373
  seen.add(name)
@@ -402,9 +385,23 @@ def load(path: str | os.PathLike) -> SwarmConfig:
402
385
  if not command:
403
386
  raise ConfigError(f"agent {name!r}: no `command` and type {atype!r} has none")
404
387
 
405
- capture = str(
406
- raw.get("capture") or defaults.get("capture") or "auto"
407
- )
388
+ # type <-> command mismatch: a command that launches a DIFFERENT agent
389
+ # CLI than `type` implies will never fire its turn-completion signal and
390
+ # pins the agent "busy" forever. Lenient for key-free mocks (word-boundaries).
391
+ if atype in CLI_TOKENS:
392
+ cmd_lower = str(command).lower()
393
+ for token in CLI_TOKENS:
394
+ if token == atype:
395
+ continue
396
+ if re.search(r"\b" + re.escape(token) + r"\b", cmd_lower):
397
+ raise ConfigError(
398
+ f"agent {name!r}: type: {atype} but command launches "
399
+ f"{token!r} ({command!r}). The command must launch the same "
400
+ f"agent CLI as `type`, or the turn-completion signal will "
401
+ f"never fire and the agent will hang. Fix `command` or `type`."
402
+ )
403
+
404
+ capture = str(raw.get("capture") or defaults.get("capture") or "auto")
408
405
  if capture not in VALID_CAPTURE:
409
406
  raise ConfigError(
410
407
  f"agent {name!r}: capture must be one of {', '.join(VALID_CAPTURE)}"
@@ -414,15 +411,13 @@ def load(path: str | os.PathLike) -> SwarmConfig:
414
411
  # capture: none on a type that HAS a completion hook (claude/codex) removes
415
412
  # the agent's only turn-completion signal and leaves the orchestrator blind
416
413
  # to a silent turn -- which can wedge the whole swarm. Auto-upgrade to the
417
- # type's natural capture so the hook keeps the orchestrator informed (and
418
- # re-enables busy_check / tag parsing / reply reminders, force-disabled for
419
- # capture: none below). gemini/hermes deliberately keep capture: none.
414
+ # type's natural capture so the hook keeps the orchestrator informed.
420
415
  if capture == "none" and str(tconf.get("capture")) == "hook":
421
416
  capture = "hook"
422
- capture_warnings.append(
417
+ agent_warnings.append(
423
418
  f"agent {name!r}: capture: none on a {atype} agent gives the "
424
419
  f"orchestrator no turn-completion signal -- auto-upgraded to "
425
- f"capture: hook. Use capture: pane (gemini/hermes) for deliberate-send."
420
+ f"capture: hook."
426
421
  )
427
422
 
428
423
  boot = raw.get("boot_delay_ms")
@@ -431,38 +426,51 @@ def load(path: str | os.PathLike) -> SwarmConfig:
431
426
  if boot is None:
432
427
  boot = tconf.get("boot_delay_ms", 5000)
433
428
 
434
- first_prompt = raw.get("first_prompt")
429
+ # Standing instructions. `role` is the v2 field; `first_prompt` is a
430
+ # deprecated alias (warn). `first_prompt_file` is likewise deprecated.
431
+ role = raw.get("role")
435
432
  prompt_file = raw.get("first_prompt_file")
433
+ if prompt_file and (role is not None or raw.get("first_prompt") is not None):
434
+ raise ConfigError(
435
+ f"agent {name!r}: set either `role`/`first_prompt` or "
436
+ "`first_prompt_file`, not both"
437
+ )
436
438
  if prompt_file:
437
- if first_prompt:
438
- raise ConfigError(
439
- f"agent {name!r}: set either `first_prompt` or `first_prompt_file`, not both"
440
- )
441
439
  fp = Path(os.path.expanduser(str(prompt_file)))
442
440
  if not fp.is_absolute():
443
441
  fp = cfg_path.parent / fp
444
442
  if not fp.is_file():
445
443
  raise ConfigError(f"agent {name!r}: first_prompt_file not found: {fp}")
446
- first_prompt = fp.read_text()
447
- first_prompt = (first_prompt or "").strip()
444
+ role = fp.read_text()
445
+ if "first_prompt" in raw:
446
+ agent_warnings.append(
447
+ f"agent {name!r}: `first_prompt` is deprecated; use `role`"
448
+ )
449
+ if "first_prompt_file" in raw:
450
+ agent_warnings.append(
451
+ f"agent {name!r}: `first_prompt_file` is deprecated; use `role` "
452
+ "(write the text directly, or read the file yourself)"
453
+ )
454
+ role = (role or raw.get("first_prompt") or "").strip()
448
455
 
449
456
  workdir_raw = raw.get("workdir") or defaults.get("workdir")
450
- if workdir_raw:
451
- # {name}, {root} and {swarm} let one `defaults.workdir` serve every agent.
452
- try:
453
- expanded = str(workdir_raw).format(
454
- name=name, root=str(root), swarm=swarm_name, type=atype
455
- )
456
- except (KeyError, IndexError) as exc:
457
- raise ConfigError(
458
- f"agent {name!r}: unknown placeholder in workdir {workdir_raw!r}: {exc}. "
459
- "Available: {name} {root} {swarm} {type}"
460
- ) from exc
461
- workdir = Path(os.path.expanduser(expanded))
462
- if not workdir.is_absolute():
463
- workdir = (cfg_path.parent / workdir).resolve()
464
- else:
465
- workdir = root / name
457
+ workdir = (
458
+ _expand_path(workdir_raw, f"agent {name}: workdir", name, root,
459
+ swarm_name, atype, cfg_path.parent)
460
+ if workdir_raw
461
+ else root / name
462
+ )
463
+
464
+ # Mailbox base: per-agent mail_dir overrides the global default, both
465
+ # resolved relative to the config file's parent. Default = the workdir,
466
+ # so by default the four folders live inside the workspace.
467
+ mail_dir_raw = raw.get("mail_dir") or default_mail_dir_raw
468
+ mail_dir = (
469
+ _expand_path(mail_dir_raw, f"agent {name}: mail_dir", name, root,
470
+ swarm_name, atype, cfg_path.parent)
471
+ if mail_dir_raw
472
+ else workdir
473
+ )
466
474
 
467
475
  create_workdir = _as_bool(
468
476
  raw.get("create_workdir", defaults.get("create_workdir", create_workdirs)),
@@ -470,6 +478,16 @@ def load(path: str | os.PathLike) -> SwarmConfig:
470
478
  f"agent {name}: create_workdir",
471
479
  )
472
480
 
481
+ ping_seconds = raw.get("periodically_ping_seconds")
482
+ if ping_seconds is None:
483
+ ping_seconds = defaults.get("periodically_ping_seconds")
484
+ ping_seconds = int(ping_seconds or 0)
485
+ ping_message = (
486
+ raw.get("periodically_ping_message")
487
+ or defaults.get("periodically_ping_message")
488
+ or ""
489
+ )
490
+
473
491
  env = dict(_as_str_map(defaults.get("env"), "defaults.env"))
474
492
  env.update(_as_str_map(tconf.get("env"), f"agent_types.{atype}.env"))
475
493
  env.update(_as_str_map(raw.get("env"), f"agent {name}: env"))
@@ -483,109 +501,75 @@ def load(path: str | os.PathLike) -> SwarmConfig:
483
501
  session=f"{prefix}{name}",
484
502
  capture=capture,
485
503
  boot_delay_ms=int(boot),
486
- first_prompt=first_prompt,
504
+ role=role,
487
505
  can_talk_to=_as_list(
488
506
  raw.get("can_talk_to", defaults.get("can_talk_to")),
489
507
  f"agent {name}: can_talk_to",
490
508
  ),
491
- forward_responses_to=_as_list(
492
- raw.get("forward_responses_to", defaults.get("forward_responses_to")),
493
- f"agent {name}: forward_responses_to",
494
- ),
509
+ mail_dir=mail_dir,
510
+ periodically_ping_seconds=ping_seconds,
511
+ periodically_ping_message=str(ping_message),
512
+ resume_args=raw.get("resume_args")
513
+ or defaults.get("resume_args")
514
+ or tconf.get("resume_args"),
515
+ resume_command=raw.get("resume_command")
516
+ or defaults.get("resume_command")
517
+ or tconf.get("resume_command"),
495
518
  env=env,
496
519
  create_workdir=create_workdir,
497
520
  # Busy tracking needs a "turn finished" signal, which only exists
498
521
  # when the agent is captured. capture: none => always accept mail.
499
- busy_check=_as_bool(
500
- raw.get("busy_check", defaults.get("busy_check")),
501
- True,
502
- f"agent {name}: busy_check",
503
- )
504
- and capture != "none",
505
- # Routing <swarm-send> blocks means reading what the agent said,
506
- # which is exactly what capture provides.
507
- parse_outbound_tags=_as_bool(
508
- raw.get("parse_outbound_tags", defaults.get("parse_outbound_tags")),
509
- True,
510
- f"agent {name}: parse_outbound_tags",
511
- )
512
- and capture != "none"
513
- and message_format == "tagged",
514
- # Reminding an agent to use the tags only makes sense if we read
515
- # them back and can see that it did not.
516
- reply_reminder=_as_bool(
517
- raw.get("reply_reminder", defaults.get("reply_reminder")),
518
- True,
519
- f"agent {name}: reply_reminder",
520
- ),
521
- resume_args=raw.get("resume_args") or defaults.get("resume_args") or tconf.get("resume_args"),
522
- resume_command=raw.get("resume_command") or defaults.get("resume_command") or tconf.get("resume_command"),
523
522
  ready_probe=_as_bool(
524
523
  raw.get("ready_probe", defaults.get("ready_probe")),
525
524
  True,
526
525
  f"agent {name}: ready_probe",
527
526
  ),
528
- append_peers_prompt=_as_bool(
529
- raw.get(
530
- "append_agents_that_you_can_talk_to_prompt",
531
- defaults.get("append_agents_that_you_can_talk_to_prompt"),
532
- ),
527
+ busy_check=_as_bool(
528
+ raw.get("busy_check", defaults.get("busy_check")),
533
529
  True,
534
- f"agent {name}: append_agents_that_you_can_talk_to_prompt",
535
- ),
536
- append_task_notice=_as_bool(
537
- raw.get(
538
- "in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
539
- defaults.get(
540
- "in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt"
541
- ),
542
- ),
543
- False,
544
- f"agent {name}: in_first_prompt_append_your_task_will_be_sent_in_the_next_prompt",
545
- ),
530
+ f"agent {name}: busy_check",
531
+ )
532
+ and capture != "none",
546
533
  )
547
534
  )
548
535
 
549
536
  all_names = [a.name for a in agents]
550
537
 
551
- # Pass 2: expand wildcards and validate the communication graph.
538
+ # Pass 2: expand wildcards and validate the communication graph. `user` is a
539
+ # permitted virtual recipient; `system` is orchestrator-only and may never be
540
+ # addressed by an agent; everything else must name a real agent.
552
541
  for agent in agents:
553
542
  if "*" in agent.can_talk_to:
554
- agent.can_talk_to = [n for n in all_names if n != agent.name]
543
+ # `*` means "every other agent". Preserve any other entries listed
544
+ # alongside it (notably `user`, which `*` does not cover) instead of
545
+ # replacing the whole list -- dropping an explicit recipient would
546
+ # deny mail the config plainly granted.
547
+ expanded = [n for n in all_names if n != agent.name]
548
+ extras = [
549
+ p
550
+ for p in agent.can_talk_to
551
+ if p != "*" and p != agent.name and p not in expanded
552
+ ]
553
+ agent.can_talk_to = expanded + extras
555
554
  for peer in agent.can_talk_to:
556
- if peer not in all_names:
555
+ if peer == "system":
557
556
  raise ConfigError(
558
- f"agent {agent.name!r}: can_talk_to references unknown agent {peer!r}"
557
+ f"agent {agent.name!r}: `system` is a reserved orchestrator "
558
+ "mailbox and can never be a recipient -- remove it from "
559
+ "can_talk_to"
559
560
  )
560
- if peer == agent.name:
561
- raise ConfigError(f"agent {agent.name!r}: cannot be in its own can_talk_to")
562
-
563
- if "*" in agent.forward_responses_to:
564
- agent.forward_responses_to = list(agent.can_talk_to)
565
- for peer in agent.forward_responses_to:
561
+ if peer == "user":
562
+ continue
566
563
  if peer not in all_names:
567
564
  raise ConfigError(
568
- f"agent {agent.name!r}: forward_responses_to references unknown agent {peer!r}"
565
+ f"agent {agent.name!r}: can_talk_to references unknown agent "
566
+ f"{peer!r}"
569
567
  )
570
- if peer not in agent.can_talk_to:
571
- raise ConfigError(
572
- f"agent {agent.name!r}: forward_responses_to includes {peer!r}, "
573
- "which is not in its can_talk_to list"
574
- )
575
- if agent.forward_responses_to and agent.capture == "none":
576
- raise ConfigError(
577
- f"agent {agent.name!r}: forward_responses_to needs capture to be enabled "
578
- "(set capture: hook or capture: pane)"
579
- )
568
+ if peer == agent.name:
569
+ raise ConfigError(f"agent {agent.name!r}: cannot be in its own can_talk_to")
580
570
 
581
- # Pass 2b: working directories. They may be auto-created under `root`, or
582
- # point at an existing project -- possibly one shared by several agents.
583
571
  warnings: list[str] = []
584
- warnings.extend(capture_warnings)
585
- for agent in agents:
586
- # No tag parsing means no way to know whether it replied.
587
- if not agent.parse_outbound_tags:
588
- agent.reply_reminder = False
572
+ warnings.extend(agent_warnings)
589
573
 
590
574
  for agent in agents:
591
575
  if agent.workdir.exists() and not agent.workdir.is_dir():
@@ -618,14 +602,12 @@ def load(path: str | os.PathLike) -> SwarmConfig:
618
602
  agents=agents,
619
603
  enter_delay_ms=int(swarm.get("enter_delay_ms", 250)),
620
604
  send_delay_ms=int(swarm.get("send_delay_ms", 150)),
621
- max_forward_hops=int(swarm.get("max_forward_hops", 3)),
622
605
  ready_timeout_ms=int(swarm.get("ready_timeout_ms", 60000)),
623
606
  busy_timeout_ms=int(swarm.get("busy_timeout_ms", 900000)),
624
- message_format=message_format,
625
- max_reply_reminders=int(swarm.get("max_reply_reminders", 1)),
626
- resume=_as_bool(swarm.get("resume"), False, "swarm.resume"),
627
- reply_reminder_template=reminder_tpl,
628
- send_failed_template=failed_tpl,
607
+ resume=_as_bool(swarm.get("resume"), True, "swarm.resume"),
608
+ user_available=_as_bool(
609
+ swarm.get("user_available"), False, "swarm.user_available"
610
+ ),
629
611
  pane_idle_ms=int(swarm.get("pane_idle_ms", 2500)),
630
612
  pane_poll_ms=int(swarm.get("pane_poll_ms", 700)),
631
613
  pane_scrollback=int(swarm.get("pane_scrollback", 400)),
@@ -633,29 +615,6 @@ def load(path: str | os.PathLike) -> SwarmConfig:
633
615
  tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
634
616
  supervise=_as_bool(swarm.get("supervise"), True, "swarm.supervise"),
635
617
  supervise_interval_ms=int(swarm.get("supervise_interval_ms", 15000)),
618
+ telegram=_load_telegram(data),
636
619
  )
637
-
638
- # Pass 3: build the full first prompt for each agent.
639
- for agent in agents:
640
- cfg_get = {
641
- "agent": agent.name,
642
- "swarm": cfg.name,
643
- "prefix": cfg.session_prefix,
644
- "peers": ", ".join(agent.can_talk_to) or "none (you are isolated)",
645
- "inbox": str(cfg.inbox_dir / agent.name),
646
- "workdir": str(agent.workdir),
647
- }
648
- parts = [agent.first_prompt] if agent.first_prompt else []
649
- try:
650
- if agent.append_peers_prompt:
651
- parts.append(comms_tpl.format(**cfg_get).strip())
652
- if agent.append_task_notice:
653
- parts.append(notice_tpl.format(**cfg_get).strip())
654
- except (KeyError, IndexError, ValueError) as exc:
655
- raise ConfigError(
656
- f"agent {agent.name!r}: a template placeholder is not recognised: {exc}. "
657
- f"Available: {', '.join(sorted(cfg_get))}"
658
- ) from exc
659
- agent.first_prompt = "\n\n".join(p.strip() for p in parts if p.strip())
660
-
661
620
  return cfg