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/minyaml.py CHANGED
@@ -37,10 +37,6 @@ _KEY_RE = re.compile(
37
37
 
38
38
  _BLOCK_RE = re.compile(r"^([|>])([+-]?)(\d*)$")
39
39
 
40
- # Only plain base-10 numbers are coerced. Leaving leading-zero ("010"), hex/octal
41
- # ("0x1F"), bare-exponent ("1e3") and underscore ("1_000") tokens as strings keeps
42
- # the builtin parser from silently inventing a value that PyYAML would not -- the
43
- # ambiguous octal/exponent cases are exactly where the two would otherwise disagree.
44
40
  _INT_RE = re.compile(r"^[-+]?(?:0|[1-9][0-9]*)$")
45
41
  _FLOAT_RE = re.compile(r"^[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.[0-9]*)(?:[eE][-+][0-9]+)?$")
46
42
 
@@ -65,13 +61,7 @@ def load(text: str):
65
61
  return value if value is not None else {}
66
62
 
67
63
 
68
- # --------------------------------------------------------------------------
69
- # scalar handling
70
- # --------------------------------------------------------------------------
71
-
72
-
73
64
  def _strip_comment(s: str) -> str:
74
- """Remove a trailing ``# comment``, respecting quotes and brackets."""
75
65
  out = []
76
66
  quote = None
77
67
  prev = ""
@@ -92,7 +82,6 @@ def _strip_comment(s: str) -> str:
92
82
 
93
83
 
94
84
  def _split_flow(body: str) -> list[str]:
95
- """Split ``a, b, [c, d]`` on top-level commas."""
96
85
  parts, depth, quote, cur = [], 0, None, []
97
86
  for ch in body:
98
87
  if quote:
@@ -117,7 +106,6 @@ def _split_flow(body: str) -> list[str]:
117
106
 
118
107
 
119
108
  def _is_balanced(s: str) -> bool:
120
- """True once every ``[``/``{`` opened in *s* has been closed."""
121
109
  depth, quote = 0, None
122
110
  for ch in s:
123
111
  if quote:
@@ -139,12 +127,6 @@ _DQ_ESCAPES = {
139
127
 
140
128
 
141
129
  def _unescape_double(body: str) -> str:
142
- """Resolve backslash escapes in a double-quoted scalar.
143
-
144
- A plain ``bytes.decode("unicode_escape")`` is latin-1 based and mangles any
145
- non-ASCII character (``"café"`` -> ``"café"``), so escapes are expanded by
146
- hand and every other character -- including multi-byte UTF-8 -- is kept as-is.
147
- """
148
130
  out: list[str] = []
149
131
  i, n = 0, len(body)
150
132
  while i < n:
@@ -220,11 +202,6 @@ def _scalar(raw: str):
220
202
  return s
221
203
 
222
204
 
223
- # --------------------------------------------------------------------------
224
- # block parser
225
- # --------------------------------------------------------------------------
226
-
227
-
228
205
  def _indent_of(line: str) -> int:
229
206
  return len(line) - len(line.lstrip(" "))
230
207
 
@@ -239,10 +216,7 @@ class _Parser:
239
216
  self.lines = lines
240
217
  self.i = 0
241
218
 
242
- # -- cursor helpers ----------------------------------------------------
243
-
244
219
  def peek(self):
245
- """Return (index, indent, stripped) of the next meaningful line."""
246
220
  while self.i < len(self.lines) and _is_blank(self.lines[self.i]):
247
221
  self.i += 1
248
222
  if self.i >= len(self.lines):
@@ -256,10 +230,6 @@ class _Parser:
256
230
  raise YAMLError(f"unexpected content at indent {indent}: {text!r}")
257
231
 
258
232
  def finish_flow(self, first: str):
259
- """Parse a flow collection, consuming continuation lines until balanced.
260
-
261
- The cursor must already sit *after* the line ``first`` came from.
262
- """
263
233
  buf = _strip_comment(first)
264
234
  while not _is_balanced(buf):
265
235
  if self.i >= len(self.lines):
@@ -271,8 +241,6 @@ class _Parser:
271
241
  buf += " " + _strip_comment(line.strip())
272
242
  return _scalar(buf)
273
243
 
274
- # -- grammar -----------------------------------------------------------
275
-
276
244
  def parse(self, indent: int | None = None):
277
245
  head = self.peek()
278
246
  if head is None:
@@ -284,14 +252,11 @@ class _Parser:
284
252
  return None
285
253
  if text == "-" or text.startswith("- "):
286
254
  return self.parse_seq(cur_indent)
287
- # Check flow collections before the key regex: `{name: a, type: b}` would
288
- # otherwise look like a block mapping with the key `{name`.
289
255
  if text.startswith(("{", "[")):
290
256
  self.i += 1
291
257
  return self.finish_flow(text)
292
258
  if _KEY_RE.match(text):
293
259
  return self.parse_map(cur_indent)
294
- # A lone scalar (only reachable for sequence item bodies).
295
260
  self.i += 1
296
261
  return _scalar(text)
297
262
 
@@ -325,7 +290,6 @@ class _Parser:
325
290
  if nxt and nxt[1] > indent:
326
291
  out[key] = self.parse(nxt[1])
327
292
  elif nxt and nxt[1] == indent and nxt[2].startswith(("-", "- ")):
328
- # Sequences may sit at the same indent as their key.
329
293
  out[key] = self.parse_seq(indent)
330
294
  else:
331
295
  out[key] = None
@@ -342,8 +306,6 @@ class _Parser:
342
306
  break
343
307
 
344
308
  line = self.lines[idx]
345
- # Rewrite "- foo" into " foo" so the item body is a normal block
346
- # that starts at the column right after the dash.
347
309
  body_first = line[:indent] + " " + line[indent + 1 :]
348
310
  self.i += 1
349
311
 
@@ -402,7 +364,7 @@ class _Parser:
402
364
  folded.append(" ".join(buf))
403
365
  buf = []
404
366
  folded.append("")
405
- elif line.startswith(" "): # more-indented lines keep their breaks
367
+ elif line.startswith(" "):
406
368
  if buf:
407
369
  folded.append(" ".join(buf))
408
370
  buf = []
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- P4 dynamic reconcile (add / remove / edit agents at runtime).
3
+
4
+ This module closes the loop opened by ``up``: it makes the running swarm match
5
+ the config *without* a full teardown. The orchestrator owns authoritative state,
6
+ so reconcile is the only place that starts/stops sessions to match the YAML.
7
+
8
+ Three operations, all driven from the tested core (no new orchestration logic):
9
+
10
+ * ``diff(cfg)`` -- compare configured agents to running tmux sessions.
11
+ * ``reconcile(cfg, ...)`` -- start agents missing from the running set, stop
12
+ sessions that are no longer in the config.
13
+ * ``add_agent`` / ``remove_agent`` / ``edit_agent`` -- mutate the YAML on disk
14
+ (a minimal stdlib emitter, so it works with OR
15
+ without PyYAML), then return a re-loaded config so
16
+ the caller can ``reconcile`` the change into effect.
17
+
18
+ The HTTP UI and the CLI both call these; the UI is the human-facing control
19
+ plane that triggers them.
20
+
21
+ Hard invariants (see CLAUDE.md + ProjectPlan.md §24):
22
+ * Zero runtime deps. The YAML writer below never imports PyYAML -- it only
23
+ *reads* via PyYAML when present and falls back to ``minyaml`` otherwise, but
24
+ it always *writes* with the bundled emitter so the no-PyYAML path stays live.
25
+ * ``can_talk_to`` stays a cooperative ACL -- reconcile never relaxes it.
26
+ * Reconcile is best-effort about tmux: a session that won't start is reported,
27
+ not fatal.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ _LIB = Path(__file__).resolve().parent
38
+ if str(_LIB) not in sys.path:
39
+ sys.path.insert(0, str(_LIB))
40
+
41
+ import config as cfgmod # noqa: E402
42
+ from config import ConfigError # noqa: E402
43
+ import tmux # noqa: E402
44
+ import mail # noqa: E402
45
+ import hooks # noqa: E402
46
+ import turn # noqa: E402
47
+ import log # noqa: E402
48
+
49
+
50
+ # --------------------------------------------------------------------------
51
+ # logging (self-contained; mirrors cli.info/warn so reconcile has no cli dep)
52
+ # --------------------------------------------------------------------------
53
+
54
+
55
+ def info(msg: str) -> None:
56
+ print(f":: {msg}", file=sys.stderr)
57
+
58
+
59
+ def warn(msg: str) -> None:
60
+ print(f"!! {msg}", file=sys.stderr)
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # YAML read / write (write path is stdlib-only, no PyYAML)
65
+ # --------------------------------------------------------------------------
66
+
67
+
68
+ def have_yaml() -> bool:
69
+ """True iff PyYAML is importable (used only for *reading*)."""
70
+ try:
71
+ import yaml # noqa: F401
72
+
73
+ return True
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def load_raw(path) -> dict:
79
+ """Parse *path* into a plain dict using PyYAML if present, else minyaml."""
80
+ text = Path(path).read_text()
81
+ if have_yaml():
82
+ import yaml
83
+
84
+ return yaml.safe_load(text) or {}
85
+ import minyaml
86
+
87
+ return minyaml.load(text) or {}
88
+
89
+
90
+ def _scalar(v) -> str:
91
+ """Render a scalar for the stdlib YAML emitter."""
92
+ if v is None:
93
+ return "null"
94
+ if isinstance(v, bool):
95
+ return "true" if v else "false"
96
+ if isinstance(v, (int, float)):
97
+ return str(v)
98
+ s = str(v)
99
+ if s == "":
100
+ return '""'
101
+ # Quote anything that isn't a safe bare token (so "a,b" stays a string, etc).
102
+ if re.fullmatch(r"[A-Za-z0-9_./@:+-]+", s):
103
+ return s
104
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
105
+
106
+
107
+ def _dump(data, indent: int = 0) -> str:
108
+ """Minimal stdlib YAML serializer for Agentainer configs.
109
+
110
+ Handles exactly the shapes Agentainer configs use: nested mappings, lists of
111
+ scalars, and lists of mappings (the ``agents:`` block). It is intentionally
112
+ small -- the config schema is closed, so we don't need a general emitter.
113
+ """
114
+ pad = " " * indent
115
+ lines: list[str] = []
116
+ if isinstance(data, dict):
117
+ for k, v in data.items():
118
+ if isinstance(v, dict):
119
+ if v:
120
+ lines.append(f"{pad}{k}:")
121
+ lines.append(_dump(v, indent + 1))
122
+ else:
123
+ lines.append(f"{pad}{k}: {{}}")
124
+ elif isinstance(v, list):
125
+ if v:
126
+ lines.append(f"{pad}{k}:")
127
+ lines.append(_dump(v, indent + 1))
128
+ else:
129
+ lines.append(f"{pad}{k}: []")
130
+ else:
131
+ lines.append(f"{pad}{k}: {_scalar(v)}")
132
+ elif isinstance(data, list):
133
+ for item in data:
134
+ if isinstance(item, dict) and item:
135
+ sub = _dump(item, indent + 1).splitlines()
136
+ sub_indent = " " * (indent + 1)
137
+ first = True
138
+ for line in sub:
139
+ if first:
140
+ # "- key: val" at the list level, drop one indent.
141
+ lines.append(pad + "- " + line[len(sub_indent):])
142
+ first = False
143
+ else:
144
+ lines.append(line)
145
+ else:
146
+ lines.append(f"{pad}- {_scalar(item)}")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def write_raw(path, data: dict) -> None:
151
+ """Serialize *data* to *path* with the stdlib emitter (no PyYAML needed)."""
152
+ Path(path).write_text(_dump(data) + "\n")
153
+
154
+
155
+ # --------------------------------------------------------------------------
156
+ # config mutation
157
+ # --------------------------------------------------------------------------
158
+
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
+
183
+ def _coerce_field(key: str, value: str):
184
+ """Turn a CLI string into the right Python value for *key*.
185
+
186
+ ``can_talk_to`` becomes a list (``*`` stays the wildcard string). Numeric
187
+ fields parse to int; ``true``/``false`` to bool; everything else stays str.
188
+ """
189
+ if key == "can_talk_to":
190
+ if value.strip() == "*":
191
+ return "*"
192
+ return [p.strip() for p in value.split(",") if p.strip()]
193
+ low = value.strip().lower()
194
+ if low in ("true", "false"):
195
+ return low == "true"
196
+ if re.fullmatch(r"-?\d+", value.strip()):
197
+ return int(value.strip())
198
+ if re.fullmatch(r"-?\d+\.\d+", value.strip()):
199
+ return float(value.strip())
200
+ return value
201
+
202
+
203
+ def add_agent(cfg, name, type_, command, can_talk_to, role="", workdir=None, **extra) -> "cfgmod.SwarmConfig":
204
+ """Append *name* to the config on disk and return a re-loaded SwarmConfig.
205
+
206
+ Raises ``ValueError`` if the agent already exists. ``can_talk_to`` may be a
207
+ list or the ``"*"`` wildcard string.
208
+ """
209
+ raw = load_raw(cfg.path)
210
+ agents = list(raw.get("agents") or [])
211
+ if any(str(a.get("name")) == name for a in agents):
212
+ raise ValueError(f"agent {name!r} already exists")
213
+
214
+ entry = {
215
+ "name": name,
216
+ "type": type_,
217
+ "command": command,
218
+ "can_talk_to": can_talk_to,
219
+ }
220
+ if role:
221
+ entry["role"] = role
222
+ if workdir:
223
+ entry["workdir"] = str(workdir)
224
+ for k, v in extra.items():
225
+ entry[k] = v
226
+ agents.append(entry)
227
+ raw["agents"] = agents
228
+ return _commit(cfg, raw)
229
+
230
+
231
+ def remove_agent(cfg, name) -> "cfgmod.SwarmConfig":
232
+ """Drop *name* from the config on disk and return a re-loaded SwarmConfig.
233
+
234
+ Raises ``ValueError`` if the agent is not present. The caller is expected to
235
+ reconcile afterwards to tear down the now-orphaned session.
236
+ """
237
+ raw = load_raw(cfg.path)
238
+ agents = list(raw.get("agents") or [])
239
+ kept = [a for a in agents if str(a.get("name")) != name]
240
+ if len(kept) == len(agents):
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]
252
+ raw["agents"] = kept
253
+ return _commit(cfg, raw)
254
+
255
+
256
+ def edit_swarm(cfg, **fields) -> "cfgmod.SwarmConfig":
257
+ """Update swarm-level settings on disk and return a re-loaded SwarmConfig.
258
+
259
+ Values arrive already typed (bools/ints/strings from the JSON control plane),
260
+ so -- unlike ``edit_agent`` -- they are written through verbatim. Writing uses
261
+ the stdlib emitter so the no-PyYAML path stays live.
262
+ """
263
+ raw = load_raw(cfg.path)
264
+ swarm = dict(raw.get("swarm") or {})
265
+ for k, v in fields.items():
266
+ swarm[k] = v
267
+ raw["swarm"] = swarm
268
+ return _commit(cfg, raw)
269
+
270
+
271
+ def edit_telegram(cfg, **fields) -> "cfgmod.SwarmConfig":
272
+ """Update the top-level ``telegram:`` block on disk; return a reloaded config.
273
+
274
+ Values arrive already typed from the JSON control plane, so they are written
275
+ through verbatim (the stdlib emitter keeps the no-PyYAML path live).
276
+ """
277
+ raw = load_raw(cfg.path)
278
+ tg = dict(raw.get("telegram") or {})
279
+ for k, v in fields.items():
280
+ tg[k] = v
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)
300
+ write_raw(cfg.path, raw)
301
+ return [str(a.get("name")) for a in agents if a.get("name")]
302
+
303
+
304
+ def edit_agent(cfg, name, **fields) -> "cfgmod.SwarmConfig":
305
+ """Update *name*'s fields on disk and return a re-loaded SwarmConfig.
306
+
307
+ Field values are coerced by ``_coerce_field`` (so ``--set can_talk_to=a,b``
308
+ becomes a list, ``--set boot_delay_ms=500`` becomes an int). Unknown agents
309
+ raise ``ValueError``.
310
+ """
311
+ raw = load_raw(cfg.path)
312
+ found = False
313
+ for a in raw.get("agents") or []:
314
+ if str(a.get("name")) == name:
315
+ for k, v in fields.items():
316
+ a[k] = _coerce_field(k, str(v))
317
+ found = True
318
+ break
319
+ if not found:
320
+ raise ValueError(f"agent {name!r} not found")
321
+ return _commit(cfg, raw)
322
+
323
+
324
+ # --------------------------------------------------------------------------
325
+ # reconcile (the runtime diff)
326
+ # --------------------------------------------------------------------------
327
+
328
+
329
+ def _running_sessions(prefix: str) -> list[str]:
330
+ """Names of live tmux sessions whose name starts with *prefix*."""
331
+ try:
332
+ out = tmux.tmux(
333
+ "list-sessions", "-F", "#{session_name}", check=False, capture=True
334
+ ).stdout or ""
335
+ except Exception:
336
+ return []
337
+ return [s.strip() for s in out.splitlines() if s.strip().startswith(prefix)]
338
+
339
+
340
+ def _agent_for_session(cfg, session_name: str):
341
+ """Map a running session name back to a configured agent, or None."""
342
+ cand = session_name[len(cfg.session_prefix):]
343
+ return cfg.get(cand) if cand in cfg.names() else None
344
+
345
+
346
+ def diff(cfg) -> dict:
347
+ """Compare configured agents to running tmux sessions.
348
+
349
+ Returns ``{configured, running, missing, extra}`` -- the agent names that
350
+ are configured, currently running, configured-but-not-running (missing), and
351
+ running-but-not-configured (extra sessions to stop).
352
+ """
353
+ running = [a.name for a in cfg.agents if tmux.session_exists(a.session)]
354
+ configured = [a.name for a in cfg.agents]
355
+ extras = [
356
+ s for s in _running_sessions(cfg.session_prefix) if _agent_for_session(cfg, s) is None
357
+ ]
358
+ missing = [n for n in configured if n not in running]
359
+ return {
360
+ "configured": sorted(configured),
361
+ "running": sorted(running),
362
+ "missing": sorted(missing),
363
+ "extra": sorted(extras),
364
+ }
365
+
366
+
367
+ def _start_agent(cfg, agent, start_fn) -> None:
368
+ """Bring *agent* up: ensure dirs, then launch via *start_fn* (launch_agent_full)."""
369
+ for directory in (cfg.runtime, cfg.log_dir, cfg.queue_dir, cfg.run_dir):
370
+ directory.mkdir(parents=True, exist_ok=True)
371
+ mail.init_mailboxes(cfg)
372
+ start_fn(cfg, agent, None)
373
+
374
+
375
+ def reconcile(cfg, *, start_missing: bool = True, stop_extra: bool = True, _start_fn=None) -> dict:
376
+ """Make the running swarm match *cfg*.
377
+
378
+ Starts agents that are configured but not running, and stops tmux sessions
379
+ that are running but no longer configured. Returns a summary dict.
380
+
381
+ ``_start_fn`` is injectable (defaults to ``cli.launch_agent_full``) so tests
382
+ can observe the start path without a real tmux session.
383
+ """
384
+ start_fn = _start_fn
385
+ if start_fn is None:
386
+ import cli as _cli # lazy: avoid an import cycle with cli <-> reconcile
387
+
388
+ start_fn = _cli.launch_agent_full
389
+
390
+ d = diff(cfg)
391
+ started: list[str] = []
392
+ stopped: list[str] = []
393
+
394
+ if start_missing:
395
+ for name in d["missing"]:
396
+ agent = cfg.get(name)
397
+ _start_agent(cfg, agent, start_fn)
398
+ started.append(name)
399
+ info(f"reconcile: started {name}")
400
+
401
+ if stop_extra:
402
+ for session_name in d["extra"]:
403
+ tmux.tmux("kill-session", "-t", f"={session_name}", check=False, capture=True)
404
+ stopped.append(session_name)
405
+ info(f"reconcile: stopped extra session {session_name}")
406
+
407
+ return {
408
+ "started": started,
409
+ "stopped": stopped,
410
+ "running": d["running"],
411
+ "missing": d["missing"],
412
+ "extra": d["extra"],
413
+ }
414
+
415
+
416
+ def start_one(cfg, name: str, *, _start_fn=None) -> bool:
417
+ """Bring a single configured agent up if its tmux session isn't running.
418
+
419
+ Returns True if the agent was (re)launched, False if it was already running.
420
+ Raises ConfigError via ``cfg.get`` for an unknown name.
421
+ """
422
+ agent = cfg.get(name)
423
+ if tmux.session_exists(agent.session):
424
+ return False
425
+ start_fn = _start_fn
426
+ if start_fn is None:
427
+ import cli as _cli # lazy: avoid an import cycle with cli <-> reconcile
428
+
429
+ start_fn = _cli.launch_agent_full
430
+ _start_agent(cfg, agent, start_fn)
431
+ info(f"start_one: started {name}")
432
+ return True
433
+
434
+
435
+ def stop_one(cfg, name: str) -> bool:
436
+ """Kill a single agent's tmux session if it's running (config is untouched).
437
+
438
+ Returns True if a running session was killed, False if it was already down.
439
+ Raises ConfigError via ``cfg.get`` for an unknown name.
440
+ """
441
+ agent = cfg.get(name)
442
+ if not tmux.session_exists(agent.session):
443
+ return False
444
+ tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
445
+ info(f"stop_one: stopped {name}")
446
+ return True
447
+
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
+
473
+ # --------------------------------------------------------------------------
474
+ # CLI handlers
475
+ # --------------------------------------------------------------------------
476
+
477
+
478
+ def _parse_can_talk_to(raw: str):
479
+ raw = (raw or "").strip()
480
+ if raw == "*" or raw == "":
481
+ return raw or []
482
+ return [p.strip() for p in raw.split(",") if p.strip()]
483
+
484
+
485
+ def cmd_add(args) -> int:
486
+ cfg = cfgmod.load(args.config)
487
+ can_talk_to = _parse_can_talk_to(args.can_talk_to)
488
+ new_cfg = add_agent(
489
+ cfg,
490
+ args.name,
491
+ args.type,
492
+ args.command,
493
+ can_talk_to,
494
+ role=args.role or "",
495
+ workdir=args.workdir,
496
+ )
497
+ result = reconcile(new_cfg)
498
+ info(f"added agent {args.name!r}; reconcile started {result['started']} stopped {result['stopped']}")
499
+ return 0
500
+
501
+
502
+ def cmd_remove(args) -> int:
503
+ cfg = cfgmod.load(args.config)
504
+ if args.name not in cfg.names():
505
+ warn(f"agent {args.name!r} not found in config")
506
+ return 1
507
+ # Stop the session first so it isn't orphaned, then drop it from the config.
508
+ agent = cfg.get(args.name)
509
+ if tmux.session_exists(agent.session):
510
+ tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
511
+ info(f"stopped {args.name}")
512
+ new_cfg = remove_agent(cfg, args.name)
513
+ result = reconcile(new_cfg)
514
+ info(f"removed agent {args.name!r}; reconcile stopped {result['stopped']}")
515
+ return 0
516
+
517
+
518
+ def cmd_edit(args) -> int:
519
+ cfg = cfgmod.load(args.config)
520
+ fields = {}
521
+ for pair in args.set or []:
522
+ if "=" not in pair:
523
+ warn(f"--set value {pair!r} is not key=value; skipping")
524
+ continue
525
+ k, v = pair.split("=", 1)
526
+ fields[k.strip()] = v
527
+ if not fields:
528
+ warn("edit: no --set key=value pairs given")
529
+ return 1
530
+ new_cfg = edit_agent(cfg, args.name, **fields)
531
+ result = reconcile(new_cfg)
532
+ info(f"edited {args.name!r}; reconcile started {result['started']} stopped {result['stopped']}")
533
+ return 0
534
+
535
+
536
+ def cmd_reconcile(args) -> int:
537
+ cfg = cfgmod.load(args.config)
538
+ result = reconcile(cfg)
539
+ info(
540
+ f"reconcile: running={result['running']} "
541
+ f"started={result['started']} stopped={result['stopped']} "
542
+ f"missing={result['missing']} extra={result['extra']}"
543
+ )
544
+ return 0