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/tmux.py ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the paste/capture layer that talks to a live tmux TUI.
3
+
4
+ These functions type prompts into an agent's tmux pane, score whether the
5
+ paste actually landed, capture the pane, and wait for the agent's input box
6
+ to be live. Everything here is deterministic orchestrator code; the model
7
+ never touches tmux itself. See ``ProjectPlan.md`` §4-§11.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+
22
+ try: # POSIX only, which is fine: tmux is too.
23
+ import fcntl
24
+ except ImportError: # pragma: no cover
25
+ fcntl = None # type: ignore
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ import config as cfgmod # noqa: E402
30
+ from config import Agent, ConfigError, SwarmConfig # noqa: E402
31
+
32
+
33
+ # --------------------------------------------------------------------------
34
+ # small utilities
35
+ # --------------------------------------------------------------------------
36
+
37
+
38
+ class SwarmError(Exception):
39
+ pass
40
+
41
+
42
+ def sleep_ms(ms: int) -> None:
43
+ if ms > 0:
44
+ time.sleep(ms / 1000.0)
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # tmux
49
+ # --------------------------------------------------------------------------
50
+
51
+
52
+ def tmux(*args: str, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
53
+ if not shutil.which("tmux"):
54
+ raise SwarmError("tmux is not installed or not on PATH")
55
+ return subprocess.run(
56
+ ["tmux", *args],
57
+ check=check,
58
+ text=True,
59
+ stdout=subprocess.PIPE if capture else None,
60
+ stderr=subprocess.PIPE if capture else None,
61
+ )
62
+
63
+
64
+ LOCK_TIMEOUT_S = 180
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def file_lock(cfg: SwarmConfig, name: str, what: str = "lock"):
69
+ """An advisory cross-process lock, used to serialise access to one pane/queue.
70
+
71
+ Lock ordering, to keep it deadlock-free: queue -> pane -> turn state.
72
+ """
73
+ if fcntl is None: # pragma: no cover
74
+ yield
75
+ return
76
+
77
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
78
+ handle = open(cfg.run_dir / f"{name}.{what}", "w")
79
+ deadline = time.monotonic() + LOCK_TIMEOUT_S
80
+ locked = False
81
+ try:
82
+ while True:
83
+ try:
84
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
85
+ locked = True
86
+ break
87
+ except OSError:
88
+ if time.monotonic() > deadline:
89
+ warn(f"{name}: timed out waiting for the {what}; proceeding anyway")
90
+ break
91
+ time.sleep(0.05)
92
+ yield
93
+ finally:
94
+ if locked:
95
+ with contextlib.suppress(OSError):
96
+ fcntl.flock(handle, fcntl.LOCK_UN)
97
+ handle.close()
98
+
99
+
100
+ def pane_lock(cfg: SwarmConfig, session: str):
101
+ """Serialise everything that types into one pane.
102
+
103
+ A paste and the Enter that submits it are two separate tmux calls. Without a
104
+ lock, a second sender -- another agent, or one of several subagents running
105
+ in parallel inside the same agent -- can paste in between them, so one Enter
106
+ submits two concatenated messages and the other submits nothing.
107
+
108
+ The busy check and the "mark this agent busy" write also happen under this
109
+ lock, so two concurrent senders cannot both observe an idle agent and both
110
+ deliver to it.
111
+
112
+ The lock is per recipient, so unrelated agents are still messaged in parallel.
113
+ """
114
+ return file_lock(cfg, session, "pane.lock")
115
+
116
+
117
+ def warn(msg: str) -> None: # pragma: no cover - thin stderr helper exercised elsewhere
118
+ print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
119
+
120
+
121
+ def configure_tmux(cfg: SwarmConfig) -> str | None:
122
+ """Set the globals the agentainer's panes inherit, before any agent pane is created.
123
+
124
+ history-limit is only consulted when a pane is spawned, and the default (2000
125
+ lines) is far too small to hold a long multi-agent conversation -- the user
126
+ attaches, tries to scroll up, and the early messages are already gone. mouse
127
+ mode lets the wheel scroll that backlog.
128
+
129
+ Both are global options, but a tmux server with no sessions exits immediately,
130
+ so `set -g` on a cold server (the normal state at `up`) does nothing. We hold
131
+ the server up with a throwaway session while setting them; the agent panes
132
+ created afterwards inherit the values. Returns the holder session name so the
133
+ caller can tear it down once real sessions keep the server alive; None if there
134
+ was nothing to configure. Best effort throughout: never block the agentainer
135
+ coming up.
136
+ """
137
+ if cfg.tmux_history_limit <= 0 and not cfg.tmux_mouse:
138
+ return None
139
+ holder = f"{cfg.session_prefix}agentainer_setup"
140
+ tmux("new-session", "-d", "-s", holder, "sleep 86400", check=False)
141
+ if cfg.tmux_history_limit > 0:
142
+ tmux("set-option", "-g", "history-limit", str(cfg.tmux_history_limit), check=False)
143
+ if cfg.tmux_mouse:
144
+ tmux("set-option", "-g", "mouse", "on", check=False)
145
+ return holder
146
+
147
+
148
+ def session_exists(session: str) -> bool:
149
+ try:
150
+ tmux("has-session", "-t", f"={session}", capture=True)
151
+ return True
152
+ except subprocess.CalledProcessError:
153
+ return False
154
+
155
+
156
+ PASTE_ATTEMPTS = 2
157
+ VERIFY_TIMEOUT_MS = 3000
158
+ VERIFY_SCROLLBACK = 200
159
+ NEEDLE_LEN = 28
160
+ # Both CLIs collapse a long paste into a chip instead of showing the text:
161
+ # claude -> "[Pasted text #1 +36 lines]"
162
+ # codex -> "[Pasted Content 2580 chars]"
163
+ # Whitespace is stripped before matching, so this sees "Pastedtext" / "PastedContent".
164
+ PASTE_CHIP_RE = re.compile(r"pasted(?:text|content)", re.IGNORECASE)
165
+
166
+
167
+ def pane_text(session: str, scrollback: int = 0) -> str:
168
+ args = ["capture-pane", "-p", "-t", session]
169
+ if scrollback:
170
+ args[2:2] = ["-S", f"-{scrollback}"]
171
+ try:
172
+ return tmux(*args, capture=True).stdout or ""
173
+ except subprocess.CalledProcessError:
174
+ return ""
175
+
176
+
177
+ def needle_for(body: str) -> str:
178
+ """The *tail* of the text, which is what stays on screen after a paste.
179
+
180
+ Using the head would be wrong: a long prompt pushes its own first line out
181
+ of the pane, and the cursor -- hence the visible end of the text -- sits at
182
+ the bottom.
183
+ """
184
+ return normalise(body)[-NEEDLE_LEN:]
185
+
186
+
187
+ def paste_score(session: str, needle: str) -> int:
188
+ """How many times the text we are about to send already appears on screen."""
189
+ pane = normalise(pane_text(session, VERIFY_SCROLLBACK))
190
+ return pane.count(needle) + len(PASTE_CHIP_RE.findall(pane))
191
+
192
+
193
+ def send_buffer(session: str, body: str) -> None:
194
+ buf = f"agentainer-{os.getpid()}-{int(time.time() * 1000)}"
195
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fh:
196
+ fh.write(body)
197
+ tmp = fh.name
198
+ try:
199
+ tmux("load-buffer", "-b", buf, tmp)
200
+ tmux("paste-buffer", "-b", buf, "-d", "-p", "-t", session)
201
+ finally:
202
+ os.unlink(tmp)
203
+
204
+
205
+ def paste_into(
206
+ cfg: SwarmConfig, session: str, text: str, enter: bool = True, needle: str | None = None
207
+ ) -> bool:
208
+ """Type *text* into a session's pane, confirm it arrived, then press Enter.
209
+
210
+ Bracketed paste (``paste-buffer -p``) lets a multi-line prompt land in the
211
+ agent's input box as one block instead of being submitted line by line.
212
+
213
+ Confirming matters: a TUI can silently discard keystrokes while it is still
214
+ starting up, and Claude Code does exactly that for several seconds partway
215
+ through boot. So we compare the pane before and after the paste, retry if
216
+ the text never showed up, and only press Enter once it has -- which also
217
+ means a retry cannot submit a half-delivered prompt.
218
+ """
219
+ body = text.rstrip("\n")
220
+ if not body:
221
+ return False
222
+ if not session_exists(session):
223
+ raise SwarmError(f"tmux session {session!r} is not running")
224
+
225
+ with pane_lock(cfg, session):
226
+ return _paste_locked(cfg, session, body, enter, needle)
227
+
228
+
229
+ # Named control keys the UI may send straight into a pane. Restricting to a
230
+ # whitelist keeps `send-keys` from being handed an arbitrary token; these are
231
+ # tmux key names (see `man tmux`, KEY BINDINGS).
232
+ ALLOWED_KEYS = {
233
+ "Escape", "Enter", "Tab", "BSpace", "Space",
234
+ "Up", "Down", "Left", "Right",
235
+ "Home", "End", "PageUp", "PageDown",
236
+ "C-c", "C-d", "C-u", "C-l", "C-a", "C-e", "C-r", "C-z",
237
+ }
238
+
239
+
240
+ def send_key(cfg: SwarmConfig, session: str, key: str) -> bool:
241
+ """Send a single named control key (e.g. ``Escape``, ``C-c``) to a pane.
242
+
243
+ Unlike :func:`paste_into` this does no echo verification -- control keys
244
+ leave nothing in the input box to confirm against -- it just presses the key.
245
+ """
246
+ if key not in ALLOWED_KEYS:
247
+ raise SwarmError(f"key {key!r} is not allowed")
248
+ if not session_exists(session):
249
+ raise SwarmError(f"tmux session {session!r} is not running")
250
+ with pane_lock(cfg, session):
251
+ tmux("send-keys", "-t", session, key)
252
+ return True
253
+
254
+
255
+ def _paste_locked(
256
+ cfg: SwarmConfig, session: str, body: str, enter: bool, needle: str | None = None
257
+ ) -> bool:
258
+ needle = needle or needle_for(body)
259
+ delivered = False
260
+
261
+ for attempt in range(1, PASTE_ATTEMPTS + 1):
262
+ if attempt > 1:
263
+ # Best effort: clear anything a previous attempt may have left behind,
264
+ # so a retry cannot concatenate two copies of the prompt.
265
+ tmux("send-keys", "-t", session, "C-u", check=False)
266
+ sleep_ms(300)
267
+
268
+ before = paste_score(session, needle)
269
+ sleep_ms(cfg.send_delay_ms)
270
+ send_buffer(session, body)
271
+
272
+ deadline = time.monotonic() + VERIFY_TIMEOUT_MS / 1000.0
273
+ while time.monotonic() < deadline:
274
+ sleep_ms(200)
275
+ if paste_score(session, needle) > before:
276
+ delivered = True
277
+ break
278
+ if delivered:
279
+ break
280
+ warn(f"{session}: pasted text never appeared (attempt {attempt}/{PASTE_ATTEMPTS})")
281
+
282
+ if not delivered:
283
+ # Do not press Enter: if the text did arrive and we simply failed to see
284
+ # it, submitting now could send a mangled or duplicated prompt.
285
+ warn(f"{session}: could not confirm the text arrived; NOT pressing Enter")
286
+ return False
287
+
288
+ if enter:
289
+ sleep_ms(cfg.enter_delay_ms)
290
+ tmux("send-keys", "-t", session, "Enter")
291
+ return True
292
+
293
+
294
+ # --------------------------------------------------------------------------
295
+ # readiness probe
296
+ # --------------------------------------------------------------------------
297
+
298
+
299
+ READY_TOKEN = "zqxswarmready"
300
+
301
+
302
+ def normalise(text: str) -> str:
303
+ """Strip all whitespace, so a TUI's line-wrapping cannot hide a needle."""
304
+ return re.sub(r"\s+", "", text)
305
+
306
+
307
+ def visible_pane(session: str) -> str:
308
+ try:
309
+ return tmux("capture-pane", "-p", "-t", session, capture=True).stdout or ""
310
+ except subprocess.CalledProcessError:
311
+ return ""
312
+
313
+
314
+ def clear_token(session: str) -> None:
315
+ """Backspace the readiness token back out of the composer."""
316
+ for _ in range(6):
317
+ count = normalise(visible_pane(session)).count(READY_TOKEN)
318
+ if not count:
319
+ return
320
+ tmux("send-keys", "-t", session, *(["BSpace"] * (count * len(READY_TOKEN))))
321
+ sleep_ms(300)
322
+
323
+
324
+ def wait_until_ready(cfg: SwarmConfig, agent: Agent) -> bool:
325
+ """Block until the agent's TUI is actually accepting keystrokes.
326
+
327
+ A fixed sleep is not enough: Claude Code, for instance, discards input for
328
+ several seconds partway through its startup, and a prompt typed into that
329
+ window is silently lost. So we type a throwaway token until the TUI echoes
330
+ it back -- proof that its input box is live -- then erase it. Nothing is
331
+ ever submitted, because Enter is never sent.
332
+ """
333
+ deadline = time.monotonic() + cfg.ready_timeout_ms / 1000.0
334
+ with pane_lock(cfg, agent.session):
335
+ while time.monotonic() < deadline:
336
+ tmux("send-keys", "-t", agent.session, "-l", READY_TOKEN, check=False)
337
+ sleep_ms(600)
338
+ if READY_TOKEN in normalise(visible_pane(agent.session)):
339
+ clear_token(agent.session)
340
+ sleep_ms(cfg.send_delay_ms)
341
+ return True
342
+ if not session_exists(agent.session):
343
+ return False
344
+ return False
345
+
346
+
347
+ def capture_pane(cfg: SwarmConfig, agent: Agent) -> str:
348
+ try:
349
+ result = tmux(
350
+ "capture-pane", "-p", "-J", "-S", f"-{cfg.pane_scrollback}", "-t", agent.session,
351
+ capture=True,
352
+ )
353
+ except subprocess.CalledProcessError:
354
+ return ""
355
+ return result.stdout or ""
package/lib/turn.py ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- per-agent turn-state machinery (ported from v1 swarm.py).
3
+
4
+ v1 learned that the turn-completion signal is the system clock: when an agent
5
+ stops, the orchestrator sweeps its outbox, releases the next queued message, and
6
+ re-injects the protocol on a nudge. This module is the durable record of "is
7
+ *agent* mid-task right now", persisted as JSON at
8
+ ``cfg.run_dir / f"{agent}.turn.json"`` holding ``{delivered, completed, since, by}``.
9
+
10
+ We port v1's proven turns helpers verbatim and add two v2 entry points:
11
+
12
+ * ``on_turn_finished`` -- the hook/notify entry point the CLI ``hook`` command
13
+ calls; it marks the turn done and returns the new state so the mailroom can
14
+ sweep.
15
+ * ``health_probe`` -- the v2 "silent-but-alive" probe (plan §8 / D17) that
16
+ surfaces agents whose tmux session is alive but whose turn-completion signal
17
+ the orchestrator cannot trust -- something v1's supervisor could not catch.
18
+
19
+ Zero runtime dependencies: stdlib + the bundled lib/ modules only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ try: # POSIX only, which is fine: tmux is too.
30
+ import fcntl # noqa: F401 - kept for parity; lock guards the real path
31
+ except ImportError: # pragma: no cover
32
+ fcntl = None # type: ignore
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+
36
+ import config as cfgmod # noqa: E402
37
+ from config import Agent, SwarmConfig # noqa: E402
38
+ from lock import file_lock # noqa: E402
39
+ from tmux import session_exists, warn # noqa: E402
40
+
41
+
42
+ def turn_state(cfg: SwarmConfig, agent: str) -> dict:
43
+ """Return the persisted turn state for *agent* (a name string).
44
+
45
+ A missing or corrupt file yields the empty default, so every reader sees a
46
+ complete record regardless of whether the agent has ever been messaged.
47
+ """
48
+ try:
49
+ return json.loads((cfg.run_dir / f"{agent}.turn.json").read_text())
50
+ except (OSError, json.JSONDecodeError):
51
+ return {"delivered": 0, "completed": 0, "since": 0, "by": None}
52
+
53
+
54
+ def write_turn_state(cfg: SwarmConfig, agent: str, state: dict) -> None:
55
+ """Persist *state* for *agent* (a name string) as JSON."""
56
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
57
+ (cfg.run_dir / f"{agent}.turn.json").write_text(json.dumps(state))
58
+
59
+
60
+ def busy_info(cfg: SwarmConfig, agent: Agent) -> dict | None:
61
+ """Return the turn state if *agent* is mid-task, else None. Not locked."""
62
+ if not agent.busy_check:
63
+ return None
64
+ state = turn_state(cfg, agent.name)
65
+ if state.get("delivered", 0) <= state.get("completed", 0):
66
+ return None
67
+
68
+ age_ms = (time.time() - state.get("since", 0)) * 1000
69
+ if age_ms > cfg.busy_timeout_ms:
70
+ # The hook never fired -- crashed agent, killed CLI, capture misconfigured.
71
+ # Fail open rather than wedge the swarm forever.
72
+ warn(
73
+ f"{agent.name}: has looked busy for {int(age_ms / 1000)}s "
74
+ f"(over busy_timeout_ms); treating it as idle"
75
+ )
76
+ return None
77
+ state["age_s"] = int(age_ms / 1000)
78
+ return state
79
+
80
+
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
+
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)
96
+
97
+
98
+ def mark_turn_finished(cfg: SwarmConfig, agent: str) -> None:
99
+ """The agent finished a turn: everything submitted so far is consumed.
100
+
101
+ Clamping (rather than incrementing) keeps the counters from drifting when a
102
+ CLI folds a queued message into the turn already running -- codex does this,
103
+ printing "messages to be submitted after next tool call". Drift would leave
104
+ an agent permanently "busy".
105
+ """
106
+ with file_lock(cfg, agent, "turn.lock"):
107
+ state = turn_state(cfg, agent)
108
+ state["completed"] = state.get("delivered", 0)
109
+ write_turn_state(cfg, agent, state)
110
+
111
+
112
+ def busy_message(cfg: SwarmConfig, agent: Agent, state: dict) -> str:
113
+ """Format a "still busy" reply (from the ``system`` mailbox) for *agent*."""
114
+ by = state.get("by") or "someone"
115
+ return (
116
+ f"{agent.name} is busy right now (working for {state['age_s']}s on a task "
117
+ f"from {by}). Please try again after some time, or put your message in the "
118
+ f"queue and wait for the answer:\n"
119
+ f" agentainer send --to {agent.name} --queue \"...\" "
120
+ f"# delivered automatically when {agent.name} is free\n"
121
+ f" agentainer send --to {agent.name} --wait \"...\" "
122
+ f"# block here until {agent.name} is free\n"
123
+ f"Meanwhile you are free to do other work."
124
+ )
125
+
126
+
127
+ def on_turn_finished(cfg: SwarmConfig, agent: str) -> dict:
128
+ """Hook/notify entry point: mark *agent*'s turn done and return its state.
129
+
130
+ The CLI ``hook`` command calls this, then triggers the mailroom sweep. We
131
+ return the resulting turn-state dict so the caller can act on it (e.g. log
132
+ the event) without re-reading it.
133
+ """
134
+ mark_turn_finished(cfg, agent)
135
+ return turn_state(cfg, agent)
136
+
137
+
138
+ def health_probe(cfg: SwarmConfig, agent: Agent) -> dict:
139
+ """The v2 per-agent "silent-but-alive" probe (plan §8 / D17).
140
+
141
+ Returns a snapshot the supervisor can use to surface an agent that is alive
142
+ in tmux yet whose turn-completion signal the orchestrator cannot trust:
143
+
144
+ * ``session_alive`` -- tmux.session_exists(agent.session)
145
+ * ``capture`` -- agent.capture
146
+ * ``busy`` -- busy_info(cfg, agent) is not None
147
+ * ``idle_for_ms`` -- now - since in ms when busy, else 0
148
+ * ``silent_but_alive``-- session_alive AND agent.capture == "none": the
149
+ session is up but we have no reliable turn-completion signal, so we
150
+ cannot tell when (or whether) a turn actually ended.
151
+ """
152
+ session_alive = session_exists(agent.session)
153
+ state = busy_info(cfg, agent)
154
+ busy = state is not None
155
+ if busy:
156
+ idle_for_ms = int((time.time() - state.get("since", 0)) * 1000)
157
+ else:
158
+ idle_for_ms = 0
159
+ capture = agent.capture
160
+ silent_but_alive = session_alive and capture == "none"
161
+ return {
162
+ "session_alive": session_alive,
163
+ "capture": capture,
164
+ "busy": busy,
165
+ "idle_for_ms": idle_for_ms,
166
+ "silent_but_alive": silent_but_alive,
167
+ }