agentainer 2.0.0 → 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 (63) hide show
  1. package/examples/academic-coauthor.yaml +123 -0
  2. package/examples/accessibility-audit.yaml +152 -0
  3. package/examples/affiliate-product-reviews.yaml +106 -0
  4. package/examples/api-design.yaml +157 -0
  5. package/examples/app-store-optimization.yaml +108 -0
  6. package/examples/brand-voice-style-guide.yaml +109 -0
  7. package/examples/candidate-screen.yaml +122 -0
  8. package/examples/case-study-writer.yaml +100 -0
  9. package/examples/changelog-release-notes.yaml +114 -0
  10. package/examples/chatbot-builder.yaml +138 -0
  11. package/examples/comparison-guide-writer.yaml +106 -0
  12. package/examples/competitive-intel.yaml +126 -0
  13. package/examples/content-studio.yaml +91 -0
  14. package/examples/course-creator.yaml +133 -0
  15. package/examples/customer-support-triage.yaml +118 -0
  16. package/examples/daily-briefing.yaml +119 -0
  17. package/examples/data-pipeline-builder.yaml +135 -0
  18. package/examples/design-system.yaml +138 -0
  19. package/examples/ebook-generator.yaml +90 -0
  20. package/examples/ecommerce-listing-optimizer.yaml +126 -0
  21. package/examples/email-newsletter.yaml +103 -0
  22. package/examples/faq-knowledge-sync.yaml +107 -0
  23. package/examples/game-design.yaml +122 -0
  24. package/examples/glossary-term-writer.yaml +103 -0
  25. package/examples/knowledge-base.yaml +115 -0
  26. package/examples/landing-page-converter.yaml +103 -0
  27. package/examples/legal-contract-review.yaml +118 -0
  28. package/examples/linkedin-ghostwriter.yaml +93 -0
  29. package/examples/meeting-notes.yaml +111 -0
  30. package/examples/migration-planner.yaml +127 -0
  31. package/examples/onboarding-buddy.yaml +111 -0
  32. package/examples/performance-audit.yaml +123 -0
  33. package/examples/podcast-production.yaml +117 -0
  34. package/examples/postmortem.yaml +119 -0
  35. package/examples/pr-review-gate.yaml +123 -0
  36. package/examples/press-release-wire.yaml +96 -0
  37. package/examples/product-spec.yaml +107 -0
  38. package/examples/prompt-engineering-lab.yaml +109 -0
  39. package/examples/rag-builder.yaml +145 -0
  40. package/examples/refactor-planner.yaml +127 -0
  41. package/examples/resume-tailor.yaml +116 -0
  42. package/examples/rfp-response.yaml +124 -0
  43. package/examples/sales-coach.yaml +123 -0
  44. package/examples/security-audit.yaml +120 -0
  45. package/examples/seo-audit-and-fix.yaml +138 -0
  46. package/examples/seo-content-factory.yaml +103 -0
  47. package/examples/social-media.yaml +103 -0
  48. package/examples/startup-validator.yaml +115 -0
  49. package/examples/technical-documentation.yaml +112 -0
  50. package/examples/test-factory.yaml +114 -0
  51. package/examples/tutorial-howto-creator.yaml +111 -0
  52. package/examples/twitter-x-thread-factory.yaml +91 -0
  53. package/examples/white-paper-research.yaml +96 -0
  54. package/examples/youtube-script-studio.yaml +107 -0
  55. package/lib/cli.py +6 -2
  56. package/lib/config.py +28 -11
  57. package/lib/mail.py +78 -13
  58. package/lib/reconcile.py +80 -9
  59. package/lib/turn.py +14 -6
  60. package/lib/ui.py +212 -13
  61. package/package.json +1 -1
  62. package/ui/app.js +290 -23
  63. package/ui/index.html +58 -2
@@ -0,0 +1,96 @@
1
+ # =============================================================================
2
+ # 📄 White-paper research -- a hub researcher gathers B2B source material, then
3
+ # an analyst synthesizes it, a writer drafts the paper, and a design brief is
4
+ # produced for layout. Turns a fuzzy topic into a publishable white-paper package.
5
+ #
6
+ # cp examples/white-paper-research.yaml my-paper.yaml
7
+ # agentainer up -c my-paper.yaml
8
+ # agentainer send -c my-paper.yaml --to topic_researcher "White paper on zero-trust security for mid-market SaaS."
9
+ # agentainer down -c my-paper.yaml
10
+ #
11
+ # The communication graph is a hub-and-spoke: the researcher is the only agent
12
+ # that talks to the human and the only one that reaches the three specialists,
13
+ # so raw sources are gathered once and fanned out to synthesis, drafting and
14
+ # design instead of each specialist re-researching the topic.
15
+ #
16
+ # user <──> topic_researcher (the hub)
17
+ # / | \
18
+ # / | \
19
+ # analyst draft_writer design_brief_writer
20
+ # ...the three specialists never talk to each other; everything flows
21
+ # through the researcher, who owns the topic and the source material.
22
+ #
23
+ # Key-free: swap each `command` for a mock bash loop and the whole pipeline
24
+ # routes mail with NO API keys. The `command` lines below launch the real CLIs.
25
+ # =============================================================================
26
+
27
+ swarm:
28
+ name: white-paper-research
29
+ root: ./white-paper-research-workspace
30
+
31
+ defaults:
32
+ capture: none # tightened per agent below
33
+ can_talk_to: [] # default ACL is "talk to no one"; set per agent
34
+
35
+ agents:
36
+ - name: topic_researcher
37
+ type: claude
38
+ can_talk_to: [analyst, draft_writer, design_brief_writer, user]
39
+ command: "claude --dangerously-skip-permissions"
40
+ role: |
41
+ You are the TOPIC RESEARCHER and hub for a white-paper project. You take a
42
+ B2B topic from the user, scope the angle and audience, and gather the raw
43
+ source material: market data, competitive landscape, standards, customer
44
+ pain points, and credible citations. You do NOT write the paper -- you own
45
+ the facts and the brief.
46
+ Run it like this: (1) restate the topic as a one-paragraph scope + the
47
+ target reader + 3-5 key questions the paper must answer; (2) collect and
48
+ organize sources and findings into a research pack; (3) send the pack to
49
+ the analyst for synthesis; (4) once the analyst returns a thesis and
50
+ outline, brief the draft_writer to write and the design_brief_writer to
51
+ spec the layout; (5) return the finished package to the user.
52
+ MAILBOX: when a message lands in your inbox/, read it and act; when done,
53
+ move it to read/. To send, write a file into outbox/<name>/ (read
54
+ outbox/<name>/about.md first) and finish your turn. You may message the
55
+ agents in your can_talk_to.
56
+
57
+ - name: analyst
58
+ type: claude
59
+ can_talk_to: [topic_researcher]
60
+ command: "claude --dangerously-skip-permissions"
61
+ role: |
62
+ You are the ANALYST. Given the researcher's source pack, turn raw findings
63
+ into a defensible argument: identify the central thesis, the 3-5 supporting
64
+ points, the counter-arguments to pre-empt, and a section-by-section outline
65
+ for the white paper. Separate what the sources prove from what is
66
+ inference; flag any claim that needs a stronger citation. Write your
67
+ synthesis and outline, then report back to the topic_researcher. If the
68
+ source pack is thin or contradictory, say so and ask for more -- do not
69
+ paper over gaps.
70
+
71
+ - name: draft_writer
72
+ type: codex
73
+ can_talk_to: [topic_researcher]
74
+ command: "codex --yolo"
75
+ role: |
76
+ You are the DRAFT WRITER. Given the analyst's thesis and outline (relayed
77
+ by the researcher) plus the source pack, write the white-paper draft:
78
+ executive summary, body sections following the outline, and a conclusion
79
+ with a clear call to action. Write for a B2B decision-maker -- authoritative,
80
+ concrete, jargon-checked -- and cite sources inline as the analyst mapped
81
+ them. Do not invent statistics; if a section lacks support, ask the
82
+ researcher rather than filling the gap. Send the completed draft back to
83
+ the topic_researcher.
84
+
85
+ - name: design_brief_writer
86
+ type: claude
87
+ can_talk_to: [topic_researcher]
88
+ command: "claude --dangerously-skip-permissions"
89
+ role: |
90
+ You are the DESIGN BRIEF WRITER. Given the outline and draft (relayed by
91
+ the researcher), produce a design brief a layout designer can execute:
92
+ recommended page count and structure, where pull-quotes and callouts go,
93
+ which data points become charts or infographics (and the chart type),
94
+ cover concept, and a tone/visual-style note aligned to the target reader.
95
+ You specify the visual plan; you do not design pixels. Send the brief back
96
+ to the topic_researcher.
@@ -0,0 +1,107 @@
1
+ # =============================================================================
2
+ # 🎬 YouTube script studio -- turn a video topic into a full publish-ready
3
+ # package: script -> title/thumbnail copy -> description + tags + chapters.
4
+ #
5
+ # cp examples/youtube-script-studio.yaml my-studio.yaml
6
+ # agentainer up -c my-studio.yaml
7
+ # agentainer send -c my-studio.yaml --to producer "Make a 10-min video: 'Why your sourdough won't rise'."
8
+ # agentainer down -c my-studio.yaml
9
+ #
10
+ # The producer is the hub: it briefs a researcher (hooks + retention patterns),
11
+ # a scriptwriter (the actual script), and a metadata_writer (title, thumbnail
12
+ # copy, description, tags, chapters). Script and metadata are written as peers so
13
+ # titles/chapters track the real script -- but the producer stays the only agent
14
+ # that talks to you.
15
+ #
16
+ # make a video X
17
+ # user ─────────────▶ producer ◀───────────────────────┐
18
+ # (package) ◀──────┼──────────────┬───────────┐ │
19
+ # ▼ ▼ ▼ │
20
+ # researcher scriptwriter metadata_writer
21
+ # └────peer────┘
22
+ # ...producer <--> everyone; scriptwriter <--> metadata_writer; user via hub only.
23
+ #
24
+ # Key-free / no API keys: every `command` below is a placeholder launch line for a
25
+ # real coding-agent CLI. Swap each for a mock bash loop and the whole studio comes
26
+ # up and routes mail with NO API keys -- the mechanics are identical.
27
+ # =============================================================================
28
+
29
+ swarm:
30
+ name: youtube-script-studio
31
+ root: ./youtube-script-studio-workspace
32
+
33
+ defaults:
34
+ capture: none # tightened per agent (pane for gemini/hermes)
35
+ can_talk_to: [] # tightened per agent below
36
+
37
+ agents:
38
+ - name: producer
39
+ type: claude
40
+ workdir: "{root}/repo"
41
+ can_talk_to: [researcher, scriptwriter, metadata_writer, user]
42
+ command: "claude --dangerously-skip-permissions"
43
+ capture: none
44
+ role: |
45
+ You are the PRODUCER, the hub of a YouTube video studio. You turn the
46
+ user's topic into a publish-ready package and are the ONLY agent who talks
47
+ to the user. You do not write the script yourself; you set the angle,
48
+ audience, target length, and the promise of the video, then coordinate.
49
+ Your team: researcher (hook ideas + retention/pacing patterns for this
50
+ topic), scriptwriter (writes the full script), metadata_writer (title
51
+ options, thumbnail copy, description, tags, chapter markers).
52
+ Run it like this: (1) restate the topic as a one-paragraph brief -- angle,
53
+ audience, length, the single promise -- and send it to the researcher
54
+ first; (2) pass the research to the scriptwriter to draft the script;
55
+ (3) have the metadata_writer build titles/thumbnail/description/tags/
56
+ chapters against the FINAL script; (4) assemble everything and send the
57
+ finished package to the user.
58
+ MAILBOX: when a message lands in your inbox/, read it and act; when done,
59
+ move it to read/. To send, write a file into outbox/<name>/ (read
60
+ outbox/<name>/about.md first to see who they are and whether they are
61
+ available) and finish your turn. You may only message the agents in your
62
+ can_talk_to list.
63
+
64
+ - name: researcher
65
+ type: gemini
66
+ workdir: "{root}/repo"
67
+ can_talk_to: [producer]
68
+ command: "gemini --yolo"
69
+ capture: pane
70
+ role: |
71
+ You are the RESEARCHER. Given the producer's brief, find what makes THIS
72
+ topic watchable: 3-5 strong hook openers, the questions the audience
73
+ actually types, the moments where viewers drop off and how to hold them
74
+ (pattern interrupts, open loops, payoff placement), and any facts or
75
+ angles worth including. You do not write the script -- you hand the
76
+ producer a tight research brief they can pass to the scriptwriter. Report
77
+ only to the producer.
78
+
79
+ - name: scriptwriter
80
+ type: claude
81
+ workdir: "{root}/repo"
82
+ can_talk_to: [producer, metadata_writer]
83
+ command: "claude --dangerously-skip-permissions"
84
+ capture: none
85
+ role: |
86
+ You are the SCRIPTWRITER. Given the producer's brief and the researcher's
87
+ findings, write the full video SCRIPT.md: a cold-open hook in the first 15
88
+ seconds, a clear through-line, spoken-word narration (not bullet points),
89
+ B-roll / on-screen cues in brackets, and a call to action. Match the
90
+ target length and audience. When the script is solid, send a copy straight
91
+ to the metadata_writer so titles and chapters track the real script, and
92
+ report the draft to the producer.
93
+
94
+ - name: metadata_writer
95
+ type: codex
96
+ workdir: "{root}/repo"
97
+ can_talk_to: [producer, scriptwriter]
98
+ command: "codex --yolo"
99
+ capture: none
100
+ role: |
101
+ You are the METADATA_WRITER. Working from the FINAL script, produce the
102
+ packaging that gets the video found and clicked: 5-8 title options (front-
103
+ load the keyword, keep them honest), thumbnail copy (2-4 punchy words),
104
+ an SEO description with the payoff in the first two lines, a tag list, and
105
+ timestamped chapter markers derived from the script's sections. If the
106
+ script is missing a section you need for a chapter, ask the scriptwriter
107
+ directly; deliver the finished packaging to the producer.
package/lib/cli.py CHANGED
@@ -555,8 +555,7 @@ def cmd_queue(args) -> int:
555
555
  dropped += 1
556
556
  info(f"{agent.name}: dropped {dropped} queued message(s)")
557
557
  return 0
558
- q = cfg.queue_dir / agent.name
559
- items = sorted(f for f in q.iterdir() if f.is_file()) if q.is_dir() else []
558
+ items = mail.queued_files(cfg, agent.name)
560
559
  state = turn.busy_info(cfg, agent)
561
560
  status = f"busy for {state['age_s']}s (task from {state['by']})" if state else "idle"
562
561
  print(f"{agent.name}: {status}, {len(items)} message(s) queued")
@@ -574,6 +573,11 @@ def cmd_idle(args) -> int:
574
573
  info(f"{agent.name}: marked idle")
575
574
  if not args.no_drain:
576
575
  mail.process_read_folder(cfg, agent.name)
576
+ # process_read_folder only archives an over-presented message; pair the
577
+ # release of the next queued message with a nudge, exactly as the
578
+ # supervisor tick does, so the escape hatch actually re-announces mail.
579
+ if mail.release_next(cfg, agent.name):
580
+ mail.nudge(cfg, agent.name)
577
581
  return 0
578
582
 
579
583
 
package/lib/config.py CHANGED
@@ -158,11 +158,14 @@ class SwarmConfig:
158
158
  warnings: list[str] = field(default_factory=list)
159
159
 
160
160
  def __post_init__(self) -> None:
161
- # Set of resolved workdirs shared by 2+ agents. Computed up front so
162
- # `mail_paths` can namespace mailbox folders to avoid collisions.
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/`.
163
166
  counts: dict[Path, int] = {}
164
167
  for agent in self.agents:
165
- key = agent.workdir.resolve()
168
+ key = agent.mail_dir.resolve()
166
169
  counts[key] = counts.get(key, 0) + 1
167
170
  self._shared: set[Path] = {d for d, n in counts.items() if n > 1}
168
171
 
@@ -203,13 +206,14 @@ class SwarmConfig:
203
206
  def mail_paths(self, agent: Agent) -> SimpleNamespace:
204
207
  """Resolve the five mailbox folders for *agent* (plan §16).
205
208
 
206
- Base is ``agent.mail_dir``. When the agent's workdir is shared by more
207
- than one agent, every folder is prefixed with ``<name>-`` to avoid
208
- collisions. The model never sees this -- every nudge/first-prompt is
209
- handed the exact computed paths.
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.
210
214
  """
211
215
  base = agent.mail_dir
212
- prefix = agent.name + "-" if agent.workdir.resolve() in self._shared else ""
216
+ prefix = agent.name + "-" if agent.mail_dir.resolve() in self._shared else ""
213
217
  return SimpleNamespace(
214
218
  inbox=base / (prefix + "inbox"),
215
219
  outbox=base / (prefix + "outbox"),
@@ -332,8 +336,11 @@ def load(path: str | os.PathLike) -> SwarmConfig:
332
336
  create_workdirs = _as_bool(swarm.get("create_workdirs"), True, "swarm.create_workdirs")
333
337
 
334
338
  raw_agents = data.get("agents")
335
- if not raw_agents:
336
- 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 = []
337
344
  if not isinstance(raw_agents, list):
338
345
  raise ConfigError("`agents:` must be a list")
339
346
 
@@ -533,7 +540,17 @@ def load(path: str | os.PathLike) -> SwarmConfig:
533
540
  # addressed by an agent; everything else must name a real agent.
534
541
  for agent in agents:
535
542
  if "*" in agent.can_talk_to:
536
- 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
537
554
  for peer in agent.can_talk_to:
538
555
  if peer == "system":
539
556
  raise ConfigError(
package/lib/mail.py CHANGED
@@ -29,6 +29,7 @@ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
29
29
  from __future__ import annotations
30
30
 
31
31
  import json
32
+ import os
32
33
  import re
33
34
  import shutil
34
35
  import time
@@ -148,11 +149,23 @@ def enqueue(cfg: SwarmConfig, recipient: str, text: str, msg_id: str) -> None:
148
149
  After the message is durably queued, best-effort mirror it to Telegram (a
149
150
  no-op unless configured). Mirroring runs AFTER the write and can never raise
150
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.
151
160
  """
152
161
  with lock.file_lock(cfg, recipient, "mail"):
153
162
  q = cfg.queue_dir / recipient
154
163
  q.mkdir(parents=True, exist_ok=True)
155
- (q / f"{msg_id}.txt").write_text(text)
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))
156
169
  import telegram # lazy: keeps mail's import graph free of the bridge
157
170
 
158
171
  telegram.on_enqueued(cfg, recipient, text, msg_id)
@@ -299,9 +312,35 @@ def standby_prompt(cfg: SwarmConfig, agent) -> str:
299
312
  )
300
313
 
301
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
+
302
338
  def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
303
339
  """Release the oldest queued message into *agent_name*'s inbox (one-at-a-time).
304
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
+
305
344
  Returns False if the inbox already holds a message (one-at-a-time) or the
306
345
  queue is empty; True if a message was moved into the inbox. Each release
307
346
  bumps the presentation counter used by the auto-archive fallback.
@@ -328,10 +367,7 @@ def release_next(cfg: SwarmConfig, agent_name: str) -> bool:
328
367
  # One-at-a-time: a message is already presented; count it as a presentation.
329
368
  _bump_presentations(cfg, agent_name, existing[0].name)
330
369
  return False
331
- q = cfg.queue_dir / agent_name
332
- # q may not exist yet for an agent that has never received mail;
333
- # iterdir() would raise, so treat a missing queue as empty.
334
- files = sorted(f for f in q.iterdir() if f.is_file()) if q.exists() else []
370
+ files = queued_files(cfg, agent_name)
335
371
  if not files:
336
372
  return False
337
373
  oldest = files[0]
@@ -373,13 +409,30 @@ def nudge(cfg: SwarmConfig, agent_name: str) -> bool:
373
409
  f"You can message: {allowed}."
374
410
  )
375
411
  try:
376
- return tmux.paste_into(cfg, agent.session, nudge_text)
412
+ pasted = tmux.paste_into(cfg, agent.session, nudge_text)
377
413
  except tmux.SwarmError:
378
414
  # Best-effort: if the agent isn't up (or tmux is unavailable) the mail
379
415
  # still sits in the queue and gets released on the next sweep / when the
380
416
  # agent starts. Never crash a send because a session is missing -- a
381
417
  # paste failure just means we retry on the next tick.
382
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
383
436
 
384
437
 
385
438
  def route_outbound(cfg: SwarmConfig, sender: str, recipient: str, body: str) -> str:
@@ -443,9 +496,10 @@ def on_stop(cfg: SwarmConfig, agent_name: str) -> dict:
443
496
  if not sub.is_dir():
444
497
  continue
445
498
  recipient = sub.name
446
- for f in sorted(sub.iterdir()):
447
- if not f.is_file():
448
- continue
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):
449
503
  # about.md is the orchestrator-maintained contact card, not
450
504
  # an outbound message -- never route or delete it.
451
505
  if f.name == "about.md":
@@ -513,21 +567,32 @@ def process_read_folder(cfg: SwarmConfig, agent_name: str) -> int:
513
567
  _save_run_json(cfg, f"{agent_name}.read.json", state)
514
568
 
515
569
  # Auto-archive fallback (plan §7): a single message presented >= N times
516
- # without being handled is moved to the archive and the queue advances, so a
517
- # forgetful model can never wedge the swarm.
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.
518
579
  inbox = mp.inbox
519
580
  if inbox.exists():
520
581
  msgs = sorted(f for f in inbox.iterdir() if f.is_file())
521
582
  if len(msgs) == 1:
522
583
  f = msgs[0]
523
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")
524
590
  if (
525
591
  pres.get("msg_id") == f.name
526
592
  and pres.get("count", 0) >= AUTO_ARCHIVE_PRESENTATIONS
527
- and f.name not in processed
593
+ and inbox_id not in processed
528
594
  ):
529
595
  log.archive_message(cfg, agent_name, f)
530
- release_next(cfg, agent_name)
531
596
 
532
597
  return count
533
598
 
package/lib/reconcile.py CHANGED
@@ -157,6 +157,29 @@ def write_raw(path, data: dict) -> None:
157
157
  # --------------------------------------------------------------------------
158
158
 
159
159
 
160
+ def _commit(cfg, raw: dict) -> "cfgmod.SwarmConfig":
161
+ """Persist *raw* to the config path, then validate by reloading it.
162
+
163
+ If the reload fails -- the edit produced an invalid config (e.g. a
164
+ can_talk_to reference to an agent that no longer exists) -- restore the
165
+ previous file and re-raise, so a REJECTED edit never leaves an unloadable
166
+ config on disk. Without this, every mutator wrote first and validated
167
+ second, and a rejected edit corrupted agentainer.yaml, breaking every later
168
+ command and the UI.
169
+ """
170
+ path = Path(cfg.path)
171
+ prev = path.read_text() if path.exists() else None
172
+ write_raw(path, raw)
173
+ try:
174
+ return cfgmod.load(path)
175
+ except Exception:
176
+ if prev is None: # pragma: no cover - a mutator always starts from an existing config
177
+ path.unlink(missing_ok=True)
178
+ else:
179
+ path.write_text(prev)
180
+ raise
181
+
182
+
160
183
  def _coerce_field(key: str, value: str):
161
184
  """Turn a CLI string into the right Python value for *key*.
162
185
 
@@ -202,8 +225,7 @@ def add_agent(cfg, name, type_, command, can_talk_to, role="", workdir=None, **e
202
225
  entry[k] = v
203
226
  agents.append(entry)
204
227
  raw["agents"] = agents
205
- write_raw(cfg.path, raw)
206
- return cfgmod.load(cfg.path)
228
+ return _commit(cfg, raw)
207
229
 
208
230
 
209
231
  def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
@@ -217,9 +239,18 @@ def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
217
239
  kept = [a for a in agents if str(a.get("name")) != name]
218
240
  if len(kept) == len(agents):
219
241
  raise ValueError(f"agent {name!r} not found")
242
+ # Strip the removed agent from every remaining agent's can_talk_to. Config
243
+ # load validates that every peer exists (config.py), so a lingering
244
+ # reference would make the reload below raise -- after we've already written
245
+ # the file -- leaving an unloadable config on disk that breaks every later
246
+ # command and the UI. A "*" wildcard needs no cleanup: load re-expands it to
247
+ # whatever agents remain.
248
+ for a in kept:
249
+ talk = a.get("can_talk_to")
250
+ if isinstance(talk, list):
251
+ a["can_talk_to"] = [p for p in talk if str(p) != name]
220
252
  raw["agents"] = kept
221
- write_raw(cfg.path, raw)
222
- return cfgmod.load(cfg.path)
253
+ return _commit(cfg, raw)
223
254
 
224
255
 
225
256
  def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
@@ -234,8 +265,7 @@ def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
234
265
  for k, v in fields.items():
235
266
  swarm[k] = v
236
267
  raw["swarm"] = swarm
237
- write_raw(cfg.path, raw)
238
- return cfgmod.load(cfg.path)
268
+ return _commit(cfg, raw)
239
269
 
240
270
 
241
271
  def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
@@ -249,8 +279,26 @@ def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
249
279
  for k, v in fields.items():
250
280
  tg[k] = v
251
281
  raw["telegram"] = tg
282
+ return _commit(cfg, raw)
283
+
284
+
285
+ def apply_template(cfg, agents, defaults=None) -> list:
286
+ """Seed an EMPTY config on disk with a template's ``agents`` (+ ``defaults``).
287
+
288
+ Onboarding helper: copies an example swarm's agent list into the current
289
+ ``agentainer.yaml`` when it has no agents yet, and pulls in the template's
290
+ ``defaults`` block if the target has none. Returns the added agent names.
291
+ Raises ``ValueError`` if the config already has agents (templates seed a
292
+ fresh swarm rather than merging into a live one).
293
+ """
294
+ raw = load_raw(cfg.path)
295
+ if raw.get("agents"):
296
+ raise ValueError("swarm already has agents")
297
+ if defaults and not raw.get("defaults"):
298
+ raw["defaults"] = dict(defaults)
299
+ raw["agents"] = list(agents)
252
300
  write_raw(cfg.path, raw)
253
- return cfgmod.load(cfg.path)
301
+ return [str(a.get("name")) for a in agents if a.get("name")]
254
302
 
255
303
 
256
304
  def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
@@ -270,8 +318,7 @@ def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
270
318
  break
271
319
  if not found:
272
320
  raise ValueError(f"agent {name!r} not found")
273
- write_raw(cfg.path, raw)
274
- return cfgmod.load(cfg.path)
321
+ return _commit(cfg, raw)
275
322
 
276
323
 
277
324
  # --------------------------------------------------------------------------
@@ -399,6 +446,30 @@ def stop_one(cfg, name: str) -> bool:
399
446
  return True
400
447
 
401
448
 
449
+ def start_all(cfg, *, _start_fn=None) -> list:
450
+ """Start every configured-but-not-running agent; return the started names.
451
+
452
+ A thin wrapper over ``reconcile`` (start-only) so the UI's "Start all" button
453
+ has one authoritative launch path. ``_start_fn`` is injectable for tests.
454
+ """
455
+ return reconcile(cfg, start_missing=True, stop_extra=False, _start_fn=_start_fn)["started"]
456
+
457
+
458
+ def stop_all(cfg) -> list:
459
+ """Kill every running agent session (config untouched); return stopped names.
460
+
461
+ Mirrors ``stop_one`` across the whole swarm for the UI's "Stop all" button;
462
+ agents that are already down are skipped.
463
+ """
464
+ stopped: list[str] = []
465
+ for a in cfg.agents:
466
+ if tmux.session_exists(a.session):
467
+ tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
468
+ stopped.append(a.name)
469
+ info(f"stop_all: stopped {a.name}")
470
+ return stopped
471
+
472
+
402
473
  # --------------------------------------------------------------------------
403
474
  # CLI handlers
404
475
  # --------------------------------------------------------------------------
package/lib/turn.py CHANGED
@@ -79,12 +79,20 @@ def busy_info(cfg: SwarmConfig, agent: Agent) -> dict | None:
79
79
 
80
80
 
81
81
  def mark_turn_started(cfg: SwarmConfig, agent: str, sender: str) -> None:
82
- """Record that another message (from *sender*) was just delivered to *agent*."""
83
- state = turn_state(cfg, agent)
84
- state["delivered"] = state.get("delivered", 0) + 1
85
- state["since"] = time.time()
86
- state["by"] = sender
87
- write_turn_state(cfg, agent, state)
82
+ """Record that another message (from *sender*) was just delivered to *agent*.
83
+
84
+ Locked with the same ``turn.lock`` as :func:`mark_turn_finished`: this is a
85
+ read-modify-write of ``delivered``, and the completion hook (a separate
86
+ process) clamps ``completed`` concurrently. Without the shared lock the two
87
+ can interleave and lose an update, leaving an agent wedged "busy" or -- worse
88
+ -- looking idle while a message is still in flight.
89
+ """
90
+ with file_lock(cfg, agent, "turn.lock"):
91
+ state = turn_state(cfg, agent)
92
+ state["delivered"] = state.get("delivered", 0) + 1
93
+ state["since"] = time.time()
94
+ state["by"] = sender
95
+ write_turn_state(cfg, agent, state)
88
96
 
89
97
 
90
98
  def mark_turn_finished(cfg: SwarmConfig, agent: str) -> None: