@team-agent/installer 0.1.11 → 0.2.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 (113) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/src/team_agent/approvals/__init__.py +65 -0
  5. package/src/team_agent/approvals/constants.py +6 -0
  6. package/src/team_agent/approvals/parsing.py +176 -0
  7. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  8. package/src/team_agent/approvals/status.py +165 -0
  9. package/src/team_agent/cli/__init__.py +137 -0
  10. package/src/team_agent/cli/commands.py +339 -0
  11. package/src/team_agent/cli/e2e.py +202 -0
  12. package/src/team_agent/cli/helpers.py +137 -0
  13. package/src/team_agent/cli/parser.py +477 -0
  14. package/src/team_agent/compiler.py +98 -33
  15. package/src/team_agent/coordinator/__init__.py +53 -0
  16. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  17. package/src/team_agent/coordinator/lifecycle.py +334 -0
  18. package/src/team_agent/coordinator/metadata.py +61 -0
  19. package/src/team_agent/coordinator/paths.py +17 -0
  20. package/src/team_agent/diagnose/__init__.py +48 -0
  21. package/src/team_agent/diagnose/checks.py +101 -0
  22. package/src/team_agent/diagnose/health.py +241 -0
  23. package/src/team_agent/diagnose/preflight.py +194 -0
  24. package/src/team_agent/diagnose/quick_start.py +233 -0
  25. package/src/team_agent/display/__init__.py +61 -0
  26. package/src/team_agent/display/close.py +147 -0
  27. package/src/team_agent/display/ghostty.py +77 -0
  28. package/src/team_agent/display/worker_window.py +110 -0
  29. package/src/team_agent/display/workspace.py +473 -0
  30. package/src/team_agent/launch/__init__.py +41 -0
  31. package/src/team_agent/launch/bootstrap.py +85 -0
  32. package/src/team_agent/launch/config.py +106 -0
  33. package/src/team_agent/launch/core.py +291 -0
  34. package/src/team_agent/launch/requirements.py +57 -0
  35. package/src/team_agent/leader/__init__.py +320 -0
  36. package/src/team_agent/lifecycle/__init__.py +5 -0
  37. package/src/team_agent/lifecycle/agents.py +226 -0
  38. package/src/team_agent/lifecycle/operations.py +321 -0
  39. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
  40. package/src/team_agent/lifecycle/start.py +363 -0
  41. package/src/team_agent/mcp_server/__init__.py +42 -0
  42. package/src/team_agent/mcp_server/__main__.py +7 -0
  43. package/src/team_agent/mcp_server/contracts.py +148 -0
  44. package/src/team_agent/mcp_server/normalize.py +257 -0
  45. package/src/team_agent/mcp_server/server.py +150 -0
  46. package/src/team_agent/mcp_server/tools.py +205 -0
  47. package/src/team_agent/message_store/__init__.py +23 -0
  48. package/src/team_agent/message_store/agent_health.py +109 -0
  49. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  50. package/src/team_agent/message_store/result_watchers.py +102 -0
  51. package/src/team_agent/message_store/schema.py +266 -0
  52. package/src/team_agent/messaging/__init__.py +1 -0
  53. package/src/team_agent/messaging/activity_detector.py +190 -0
  54. package/src/team_agent/messaging/delivery.py +138 -0
  55. package/src/team_agent/messaging/deps.py +263 -0
  56. package/src/team_agent/messaging/idle_alerts.py +323 -0
  57. package/src/team_agent/messaging/internal_delivery.py +46 -0
  58. package/src/team_agent/messaging/leader.py +317 -0
  59. package/src/team_agent/messaging/leader_panes.py +343 -0
  60. package/src/team_agent/messaging/owner_bypass.py +29 -0
  61. package/src/team_agent/messaging/result_delivery.py +300 -0
  62. package/src/team_agent/messaging/results.py +456 -0
  63. package/src/team_agent/messaging/scheduler.py +428 -0
  64. package/src/team_agent/messaging/send.py +500 -0
  65. package/src/team_agent/messaging/session_drift.py +94 -0
  66. package/src/team_agent/messaging/tmux_io.py +337 -0
  67. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  68. package/src/team_agent/orchestrator/__init__.py +376 -0
  69. package/src/team_agent/orchestrator/plan.py +122 -0
  70. package/src/team_agent/orchestrator/state.py +128 -0
  71. package/src/team_agent/profiles/__init__.py +82 -0
  72. package/src/team_agent/profiles/constants.py +19 -0
  73. package/src/team_agent/profiles/core.py +407 -0
  74. package/src/team_agent/profiles/helpers.py +69 -0
  75. package/src/team_agent/profiles/provider_env.py +188 -0
  76. package/src/team_agent/profiles/smoke.py +201 -0
  77. package/src/team_agent/provider_cli/__init__.py +43 -0
  78. package/src/team_agent/provider_cli/adapter.py +167 -0
  79. package/src/team_agent/provider_cli/base.py +48 -0
  80. package/src/team_agent/provider_cli/claude.py +457 -0
  81. package/src/team_agent/provider_cli/codex.py +319 -0
  82. package/src/team_agent/provider_cli/copilot.py +8 -0
  83. package/src/team_agent/provider_cli/fake.py +39 -0
  84. package/src/team_agent/provider_cli/gemini.py +95 -0
  85. package/src/team_agent/provider_cli/opencode.py +8 -0
  86. package/src/team_agent/provider_cli/prompt.py +62 -0
  87. package/src/team_agent/provider_cli/registry.py +18 -0
  88. package/src/team_agent/provider_cli/unsupported.py +32 -0
  89. package/src/team_agent/providers.py +67 -949
  90. package/src/team_agent/quality_gates.py +104 -0
  91. package/src/team_agent/restart/__init__.py +34 -0
  92. package/src/team_agent/restart/orchestration.py +328 -0
  93. package/src/team_agent/restart/selection.py +89 -0
  94. package/src/team_agent/restart/snapshot.py +70 -0
  95. package/src/team_agent/runtime.py +809 -5892
  96. package/src/team_agent/rust_core.py +22 -5
  97. package/src/team_agent/sessions/__init__.py +25 -0
  98. package/src/team_agent/sessions/capture.py +93 -0
  99. package/src/team_agent/sessions/inventory.py +44 -0
  100. package/src/team_agent/sessions/resume.py +135 -0
  101. package/src/team_agent/spec.py +3 -1
  102. package/src/team_agent/state.py +218 -4
  103. package/src/team_agent/status/__init__.py +63 -0
  104. package/src/team_agent/status/approvals.py +52 -0
  105. package/src/team_agent/status/compact.py +158 -0
  106. package/src/team_agent/status/constants.py +18 -0
  107. package/src/team_agent/status/inbox.py +28 -0
  108. package/src/team_agent/status/peek.py +117 -0
  109. package/src/team_agent/status/queries.py +168 -0
  110. package/src/team_agent/terminal.py +57 -0
  111. package/src/team_agent/cli.py +0 -858
  112. package/src/team_agent/mcp_server.py +0 -579
  113. package/src/team_agent/profiles.py +0 -882
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.events import EventLog
8
+ from team_agent.message_store import MessageStore
9
+ from team_agent.messaging.deps import load_spec, save_runtime_state, team_state_key
10
+ from team_agent.messaging.internal_delivery import deliver_stored_message
11
+
12
+
13
+ _UNDELIVERED_MESSAGE_STATUSES = {
14
+ "pending",
15
+ "accepted",
16
+ "queued_until_idle",
17
+ "queued_until_start",
18
+ "queued_stopped",
19
+ "queued_pane_missing",
20
+ "failed",
21
+ "delivery_blocked",
22
+ "injected_unverified",
23
+ }
24
+
25
+
26
+ STABLE_IDLE_SECONDS = 120
27
+ FIRE_DEBOUNCE_SECONDS = 300
28
+ OBLIGATION_PENDING_MIN_AGE_SECONDS = 60
29
+
30
+
31
+ def _parse_iso(text: Any) -> datetime | None:
32
+ if not isinstance(text, str) or not text:
33
+ return None
34
+ try:
35
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
36
+ except ValueError:
37
+ return None
38
+ if dt.tzinfo is None:
39
+ dt = dt.replace(tzinfo=timezone.utc)
40
+ return dt
41
+
42
+
43
+ def record_team_progress(
44
+ state: dict[str, Any],
45
+ now: datetime | None = None,
46
+ *,
47
+ source: str = "",
48
+ owner_team_id: str | None = None,
49
+ ) -> None:
50
+ coordinator = state.setdefault("coordinator", {})
51
+ progress = coordinator.setdefault("team_last_progress_at", {})
52
+ key = owner_team_id or team_state_key(state)
53
+ if not key:
54
+ return
55
+ progress[key] = {
56
+ "at": (now or datetime.now(timezone.utc)).isoformat(),
57
+ "source": source,
58
+ }
59
+
60
+
61
+ def _team_last_progress_at(
62
+ state: dict[str, Any],
63
+ store: MessageStore,
64
+ owner_team_id: str,
65
+ ) -> datetime | None:
66
+ candidates: list[datetime] = []
67
+ coordinator = state.get("coordinator") or {}
68
+ explicit = (coordinator.get("team_last_progress_at") or {}).get(owner_team_id)
69
+ if isinstance(explicit, dict):
70
+ ts = _parse_iso(explicit.get("at"))
71
+ if ts:
72
+ candidates.append(ts)
73
+ elif isinstance(explicit, str):
74
+ ts = _parse_iso(explicit)
75
+ if ts:
76
+ candidates.append(ts)
77
+ health = store.agent_health(owner_team_id=owner_team_id)
78
+ for row in health.values():
79
+ ts = _parse_iso(row.get("last_output_at"))
80
+ if ts:
81
+ candidates.append(ts)
82
+ return max(candidates) if candidates else None
83
+
84
+
85
+ def _team_last_idle_fallback_fire_at(state: dict[str, Any], owner_team_id: str) -> datetime | None:
86
+ coordinator = state.get("coordinator") or {}
87
+ fires = coordinator.get("team_last_idle_fallback_fire_at") or {}
88
+ return _parse_iso(fires.get(owner_team_id))
89
+
90
+
91
+ def _record_idle_fallback_fire(state: dict[str, Any], owner_team_id: str, now: datetime) -> None:
92
+ coordinator = state.setdefault("coordinator", {})
93
+ fires = coordinator.setdefault("team_last_idle_fallback_fire_at", {})
94
+ fires[owner_team_id] = now.isoformat()
95
+
96
+
97
+ def _team_undelivered_obligations(
98
+ state: dict[str, Any],
99
+ store: MessageStore,
100
+ owner_team_id: str,
101
+ active_task_statuses: set[str],
102
+ *,
103
+ now: datetime | None = None,
104
+ ) -> list[dict[str, Any]]:
105
+ now = now or datetime.now(timezone.utc)
106
+ min_age = timedelta(seconds=OBLIGATION_PENDING_MIN_AGE_SECONDS)
107
+ obligations: list[dict[str, Any]] = []
108
+ for message in store.messages(owner_team_id=owner_team_id):
109
+ if message.get("status") not in _UNDELIVERED_MESSAGE_STATUSES:
110
+ continue
111
+ created_at = _parse_iso(message.get("created_at"))
112
+ if created_at and (now - created_at) < min_age:
113
+ continue
114
+ obligations.append(
115
+ {
116
+ "kind": "undelivered_message",
117
+ "message_id": message.get("message_id"),
118
+ "recipient": message.get("recipient"),
119
+ "status": message.get("status"),
120
+ }
121
+ )
122
+ for watcher in store.retryable_result_watchers():
123
+ if watcher.get("status") not in {"pending", "notify_failed"}:
124
+ continue
125
+ created_at = _parse_iso(watcher.get("created_at"))
126
+ if created_at and (now - created_at) < min_age:
127
+ continue
128
+ obligations.append(
129
+ {
130
+ "kind": "pending_result_watcher",
131
+ "watcher_id": watcher.get("watcher_id"),
132
+ "task_id": watcher.get("task_id"),
133
+ "agent_id": watcher.get("agent_id"),
134
+ }
135
+ )
136
+ for task in state.get("tasks", []):
137
+ if task.get("status", "pending") in active_task_statuses and task.get("assignee"):
138
+ obligations.append(
139
+ {
140
+ "kind": "active_task",
141
+ "task_id": task.get("id"),
142
+ "assignee": task.get("assignee"),
143
+ "status": task.get("status"),
144
+ }
145
+ )
146
+ return obligations
147
+
148
+
149
+ def _all_workers_idle(
150
+ state: dict[str, Any],
151
+ store: MessageStore,
152
+ owner_team_id: str,
153
+ ) -> tuple[bool, list[str]]:
154
+ health = store.agent_health(owner_team_id=owner_team_id)
155
+ worker_ids = list(state.get("agents", {}).keys()) or list(health.keys())
156
+ if not worker_ids:
157
+ return False, []
158
+ idle: list[str] = []
159
+ for agent_id in worker_ids:
160
+ row = health.get(agent_id) or {}
161
+ status = str(row.get("status") or "").lower()
162
+ if status != "idle":
163
+ return False, []
164
+ idle.append(agent_id)
165
+ return True, idle
166
+
167
+
168
+ def _register_unified_alert(
169
+ state: dict[str, Any],
170
+ owner_team_id: str,
171
+ agent_id: str,
172
+ alert_type: str,
173
+ snapshot: dict[str, Any],
174
+ suppressed_by: str,
175
+ now: datetime,
176
+ ) -> dict[str, Any]:
177
+ coordinator = state.setdefault("coordinator", {})
178
+ suppressed = coordinator.setdefault("suppressed_idle_alerts", {})
179
+ team_suppressions = suppressed.setdefault(owner_team_id, {})
180
+ agent_suppressions = team_suppressions.setdefault(agent_id, {})
181
+ entry = {
182
+ "suppressed_at": now.isoformat(),
183
+ "suppressed_by": suppressed_by,
184
+ "snapshot": snapshot,
185
+ }
186
+ agent_suppressions[alert_type] = entry
187
+ return entry
188
+
189
+
190
+ def detect_idle_fallbacks(
191
+ workspace: Path,
192
+ state: dict[str, Any],
193
+ store: MessageStore,
194
+ event_log: EventLog,
195
+ now: datetime | None = None,
196
+ ) -> list[dict[str, Any]]:
197
+ from team_agent.messaging.scheduler import (
198
+ _ACTIVE_TASK_STATUSES,
199
+ _active_alert_suppression,
200
+ _agent_alert_snapshot,
201
+ )
202
+ now = now or datetime.now(timezone.utc)
203
+ owner_team_id = team_state_key(state)
204
+ obligations = _team_undelivered_obligations(state, store, owner_team_id, _ACTIVE_TASK_STATUSES, now=now)
205
+ if not obligations:
206
+ return []
207
+ all_idle, idle_workers = _all_workers_idle(state, store, owner_team_id)
208
+ if not all_idle:
209
+ record_team_progress(state, now, source="all_workers_idle:false", owner_team_id=owner_team_id)
210
+ save_runtime_state(workspace, state)
211
+ return []
212
+ last_progress = _team_last_progress_at(state, store, owner_team_id)
213
+ if last_progress and (now - last_progress) < timedelta(seconds=STABLE_IDLE_SECONDS):
214
+ event_log.write(
215
+ "coordinator.idle_fallback_skipped",
216
+ reason="stable_idle_window",
217
+ team=owner_team_id,
218
+ stable_idle_seconds=STABLE_IDLE_SECONDS,
219
+ elapsed_seconds=int((now - last_progress).total_seconds()),
220
+ )
221
+ return []
222
+ last_fire = _team_last_idle_fallback_fire_at(state, owner_team_id)
223
+ if last_fire and (now - last_fire) < timedelta(seconds=FIRE_DEBOUNCE_SECONDS):
224
+ event_log.write(
225
+ "coordinator.idle_fallback_skipped",
226
+ reason="fire_debounce",
227
+ team=owner_team_id,
228
+ fire_debounce_seconds=FIRE_DEBOUNCE_SECONDS,
229
+ elapsed_seconds=int((now - last_fire).total_seconds()),
230
+ )
231
+ return []
232
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
233
+ spec = load_spec(spec_path) if spec_path.exists() else {}
234
+ leader_id = state.get("leader", {}).get("id") or spec.get("leader", {}).get("id") or "leader"
235
+ alerts: list[dict[str, Any]] = []
236
+ for agent_id in idle_workers:
237
+ suppression = _active_alert_suppression(state, store, event_log, agent_id, "idle_fallback")
238
+ if suppression:
239
+ continue
240
+ snapshot = _agent_alert_snapshot(state, store, agent_id, owner_team_id)
241
+ _register_unified_alert(state, owner_team_id, agent_id, "idle_fallback", snapshot, "coordinator", now)
242
+ alerts.append({"agent_id": agent_id, "alert_type": "idle_fallback", "obligations": obligations})
243
+ if not alerts:
244
+ return []
245
+ _record_idle_fallback_fire(state, owner_team_id, now)
246
+ save_runtime_state(workspace, state)
247
+ content = (
248
+ "There is still unfinished work. Continue coordinating, deliver a result, "
249
+ "or acknowledge that this idle state is intentional via team-agent acknowledge-idle."
250
+ )
251
+ try:
252
+ deliver_stored_message(
253
+ workspace,
254
+ leader_id,
255
+ content,
256
+ sender="coordinator",
257
+ requires_ack=False,
258
+ wait_visible=False,
259
+ team=owner_team_id,
260
+ )
261
+ except Exception as exc:
262
+ event_log.write("coordinator.idle_fallback_push_failed", error=str(exc), team=owner_team_id)
263
+ event_log.write(
264
+ "coordinator.idle_fallback",
265
+ team=owner_team_id,
266
+ idle_workers=idle_workers,
267
+ obligation_count=len(obligations),
268
+ alert_count=len(alerts),
269
+ )
270
+ return alerts
271
+
272
+
273
+ def detect_cross_worker_deadlocks(
274
+ workspace: Path,
275
+ state: dict[str, Any],
276
+ store: MessageStore,
277
+ event_log: EventLog,
278
+ now: datetime | None = None,
279
+ ) -> list[dict[str, Any]]:
280
+ from team_agent.messaging.scheduler import (
281
+ _active_alert_suppression,
282
+ _agent_alert_snapshot,
283
+ )
284
+ now = now or datetime.now(timezone.utc)
285
+ owner_team_id = team_state_key(state)
286
+ health = store.agent_health(owner_team_id=owner_team_id)
287
+ candidate_recipients: dict[str, list[dict[str, Any]]] = {}
288
+ for message in store.messages(owner_team_id=owner_team_id):
289
+ if message.get("status") not in _UNDELIVERED_MESSAGE_STATUSES:
290
+ continue
291
+ recipient = message.get("recipient")
292
+ if not recipient:
293
+ continue
294
+ candidate_recipients.setdefault(str(recipient), []).append(message)
295
+ alerts: list[dict[str, Any]] = []
296
+ for agent_id, messages in candidate_recipients.items():
297
+ row = health.get(agent_id) or {}
298
+ status = str(row.get("status") or "").lower()
299
+ if status != "idle":
300
+ continue
301
+ suppression = _active_alert_suppression(state, store, event_log, agent_id, "cross_worker_deadlock")
302
+ if suppression:
303
+ continue
304
+ snapshot = _agent_alert_snapshot(state, store, agent_id, owner_team_id)
305
+ snapshot["pending_message_ids"] = sorted(str(m.get("message_id")) for m in messages)
306
+ _register_unified_alert(state, owner_team_id, agent_id, "cross_worker_deadlock", snapshot, "coordinator", now)
307
+ alerts.append(
308
+ {
309
+ "agent_id": agent_id,
310
+ "alert_type": "cross_worker_deadlock",
311
+ "pending_messages": snapshot["pending_message_ids"],
312
+ }
313
+ )
314
+ if not alerts:
315
+ return []
316
+ save_runtime_state(workspace, state)
317
+ event_log.write(
318
+ "coordinator.cross_worker_deadlock",
319
+ team=owner_team_id,
320
+ agent_ids=[alert["agent_id"] for alert in alerts],
321
+ alert_count=len(alerts),
322
+ )
323
+ return alerts
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from team_agent.messaging.deps import (
7
+ EventLog,
8
+ _runtime_lock,
9
+ load_spec,
10
+ select_runtime_state,
11
+ team_state_key,
12
+ )
13
+ from team_agent.messaging.send import _send_single_message_unlocked
14
+
15
+
16
+ def deliver_stored_message(
17
+ workspace: Path,
18
+ target: str | None,
19
+ content: str,
20
+ *,
21
+ task_id: str | None = None,
22
+ sender: str = "coordinator",
23
+ requires_ack: bool = False,
24
+ wait_visible: bool = False,
25
+ timeout: float = 30.0,
26
+ team: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ with _runtime_lock(workspace, "send"):
29
+ state = select_runtime_state(workspace, team)
30
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
31
+ spec = load_spec(spec_path)
32
+ return _send_single_message_unlocked(
33
+ workspace,
34
+ state,
35
+ spec,
36
+ EventLog(workspace),
37
+ target,
38
+ content,
39
+ task_id=task_id,
40
+ sender=sender,
41
+ requires_ack=requires_ack,
42
+ wait_visible=wait_visible,
43
+ timeout=timeout,
44
+ route_task_id=False,
45
+ owner_team_id=team_state_key(state),
46
+ )
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.messaging.deps import (
4
+ EventLog,
5
+ MessageStore,
6
+ _choose_leader_submit_key,
7
+ _leader_id,
8
+ _rediscover_leader_receiver,
9
+ _tmux_inject_text,
10
+ _validate_leader_receiver,
11
+ core_render_message,
12
+ json,
13
+ runtime_dir,
14
+ save_runtime_state,
15
+ time,
16
+ )
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ def allow_peer_talk(workspace: Path, agent_a: str, agent_b: str) -> dict[str, Any]:
22
+ MessageStore(workspace).allow_peer(agent_a, agent_b)
23
+ EventLog(workspace).write("communication.peer_allowed", a=agent_a, b=agent_b)
24
+ return {"ok": True, "a": agent_a, "b": agent_b, "status": "compat_noop", "reason": "team_scoped_peer_messages_enabled"}
25
+
26
+
27
+ def _mirror_peer_message_to_leader(
28
+ workspace: Path,
29
+ state: dict[str, Any],
30
+ sender: str,
31
+ target: str,
32
+ content: str,
33
+ task_id: str | None,
34
+ event_log: EventLog,
35
+ ) -> None:
36
+ leader_id = _leader_id(state, {})
37
+ mirror = f"Team Agent peer message from {sender} to {target}"
38
+ if task_id:
39
+ mirror += f" for {task_id}"
40
+ mirror += f":\n\n{content}"
41
+ try:
42
+ result = _send_to_leader_receiver(workspace, state, leader_id, mirror, task_id, sender, False, event_log)
43
+ event_log.write("communication.peer_mirrored", sender=sender, target=target, ok=result.get("ok"))
44
+ except Exception as exc:
45
+ event_log.write("communication.peer_mirror_failed", sender=sender, target=target, error=str(exc))
46
+
47
+
48
+ def _leader_inbox_path(workspace: Path) -> Path:
49
+ return runtime_dir(workspace) / "leader-inbox.log"
50
+
51
+
52
+ def _send_to_leader_receiver(
53
+ workspace: Path,
54
+ state: dict[str, Any],
55
+ leader_id: str,
56
+ content: str,
57
+ task_id: str | None,
58
+ sender: str,
59
+ requires_ack: bool,
60
+ event_log: EventLog,
61
+ ) -> dict[str, Any]:
62
+ store = MessageStore(workspace)
63
+ message_id = store.create_message(task_id, sender, leader_id, content, requires_ack=False)
64
+ if requires_ack:
65
+ event_log.write("leader_receiver.no_ack_forced", message_id=message_id, requested_requires_ack=True)
66
+ row = _message_by_id(store, message_id)
67
+ if not row:
68
+ return {"ok": False, "message_id": message_id, "status": "failed", "to": leader_id, "reason": "message_missing"}
69
+ if not store.claim_for_delivery(message_id):
70
+ current = _message_by_id(store, message_id)
71
+ status = current["status"] if current else "missing"
72
+ event_log.write("leader_receiver.delivery_claim_skipped", message_id=message_id, status=status)
73
+ return {
74
+ "ok": status in {"submitted", "visible", "delivered", "acknowledged"},
75
+ "message_id": message_id,
76
+ "status": status,
77
+ "to": leader_id,
78
+ "channel": "direct_tmux",
79
+ "reason": "message_already_claimed",
80
+ }
81
+ payload = _message_payload(row)
82
+ rendered = core_render_message(payload)
83
+ text = rendered["text"]
84
+ receiver = state.get("leader_receiver", {})
85
+ if not _leader_receiver_is_direct(receiver):
86
+ return _fail_leader_delivery(
87
+ workspace,
88
+ state,
89
+ store,
90
+ message_id,
91
+ payload,
92
+ event_log,
93
+ reason="leader_not_attached",
94
+ error="No direct leader tmux pane is attached. Run team-agent attach-leader.",
95
+ )
96
+
97
+ validation = _validate_leader_receiver(receiver)
98
+ if not validation["ok"]:
99
+ owner_identity = state.get("team_owner") or None
100
+ rediscovery = _rediscover_leader_receiver(receiver, event_log, owner_identity)
101
+ if rediscovery.get("status") == "updated":
102
+ state["leader_receiver"].update(rediscovery["receiver"])
103
+ receiver = state["leader_receiver"]
104
+ validation = _validate_leader_receiver(receiver)
105
+ elif rediscovery.get("status") == "ambiguous":
106
+ return _fail_leader_delivery(
107
+ workspace,
108
+ state,
109
+ store,
110
+ message_id,
111
+ payload,
112
+ event_log,
113
+ reason="ambiguous",
114
+ error="multiple possible leader panes found; rerun team-agent attach-leader --pane <pane_id>",
115
+ message_status="ambiguous",
116
+ )
117
+ if not validation["ok"]:
118
+ return _fail_leader_delivery(
119
+ workspace,
120
+ state,
121
+ store,
122
+ message_id,
123
+ payload,
124
+ event_log,
125
+ reason=validation["reason"],
126
+ error=validation.get("error"),
127
+ )
128
+ state["leader_receiver"].update(validation["pane"])
129
+ submit_key, submit_reason = _choose_leader_submit_key(receiver.get("provider", "codex"), validation.get("capture", ""))
130
+ target = receiver["pane_id"]
131
+ event_log.write(
132
+ "leader_receiver.deliver_attempt",
133
+ message_id=message_id,
134
+ target=target,
135
+ provider=receiver.get("provider"),
136
+ submit_key=submit_key,
137
+ submit_reason=submit_reason,
138
+ render_engine=rendered.get("engine"),
139
+ visible_token=rendered.get("token"),
140
+ payload=payload,
141
+ warning=validation.get("warning"),
142
+ )
143
+ injection = _tmux_inject_text(
144
+ target,
145
+ text,
146
+ submit_key,
147
+ f"team-agent-leader-receiver-{message_id}",
148
+ provider=receiver.get("provider", "codex"),
149
+ )
150
+ if injection["ok"]:
151
+ store.mark(message_id, "submitted")
152
+ event_log.write(
153
+ "leader_receiver.submitted",
154
+ message_id=message_id,
155
+ sender=sender,
156
+ task_id=task_id,
157
+ target=target,
158
+ provider=receiver.get("provider"),
159
+ submit_key=submit_key,
160
+ submit_reason=submit_reason,
161
+ visible=True,
162
+ submitted=True,
163
+ visible_token=rendered.get("token"),
164
+ verification=injection.get("verification"),
165
+ submit_verification=injection.get("submit_verification"),
166
+ turn_verification=injection.get("turn_verification"),
167
+ attempts=injection.get("attempts"),
168
+ submit_attempts=injection.get("submit_attempts"),
169
+ )
170
+ save_runtime_state(workspace, state)
171
+ return {
172
+ "ok": True,
173
+ "message_id": message_id,
174
+ "status": "submitted",
175
+ "to": leader_id,
176
+ "channel": "direct_tmux",
177
+ "leader_receiver": state["leader_receiver"],
178
+ "submit_key": submit_key,
179
+ "visible": True,
180
+ "submitted": True,
181
+ "visible_token": rendered.get("token"),
182
+ "verification": injection.get("verification"),
183
+ "submit_verification": injection.get("submit_verification"),
184
+ "turn_verification": injection.get("turn_verification"),
185
+ "attempts": injection.get("attempts"),
186
+ "submit_attempts": injection.get("submit_attempts"),
187
+ "warning": "leader messages are no-ack; requires_ack was forced false" if requires_ack else None,
188
+ }
189
+ return _fail_leader_delivery(
190
+ workspace,
191
+ state,
192
+ store,
193
+ message_id,
194
+ payload,
195
+ event_log,
196
+ reason="tmux_injection_failed",
197
+ error=injection.get("error"),
198
+ stage=injection.get("stage"),
199
+ attempts=injection.get("attempts"),
200
+ submit_attempts=injection.get("submit_attempts"),
201
+ )
202
+
203
+
204
+ def _fail_leader_delivery(
205
+ workspace: Path,
206
+ state: dict[str, Any],
207
+ store: MessageStore,
208
+ message_id: str,
209
+ payload: dict[str, Any],
210
+ event_log: EventLog,
211
+ reason: str,
212
+ error: str | None = None,
213
+ stage: str | None = None,
214
+ message_status: str = "failed",
215
+ attempts: list[dict[str, Any]] | None = None,
216
+ submit_attempts: list[dict[str, Any]] | None = None,
217
+ ) -> dict[str, Any]:
218
+ store.mark(message_id, message_status, error or reason)
219
+ fallback_path = _write_leader_fallback_audit(workspace, payload, reason, error)
220
+ event_log.write(
221
+ "leader_receiver.delivery_failed",
222
+ message_id=message_id,
223
+ target=state.get("leader_receiver", {}).get("pane_id"),
224
+ reason=reason,
225
+ error=error,
226
+ stage=stage,
227
+ attempts=attempts,
228
+ submit_attempts=submit_attempts,
229
+ fallback_path=str(fallback_path),
230
+ suggestion="Run team-agent attach-leader --workspace . --provider codex, or pass --pane <pane_id>.",
231
+ )
232
+ save_runtime_state(workspace, state)
233
+ return {
234
+ "ok": False,
235
+ "message_id": message_id,
236
+ "status": "fallback",
237
+ "message_status": message_status,
238
+ "to": payload["to"],
239
+ "channel": "fallback_inbox",
240
+ "reason": reason,
241
+ "error": error,
242
+ "attempts": attempts,
243
+ "submit_attempts": submit_attempts,
244
+ "fallback_path": str(fallback_path),
245
+ "suggestion": "Run team-agent attach-leader --workspace . --provider codex, or pass --pane <pane_id>.",
246
+ }
247
+
248
+
249
+ def _write_leader_fallback_audit(workspace: Path, payload: dict[str, Any], reason: str, error: str | None) -> Path:
250
+ inbox_path = _leader_inbox_path(workspace)
251
+ inbox_path.parent.mkdir(parents=True, exist_ok=True)
252
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
253
+ text = core_render_message(payload)["text"]
254
+ with inbox_path.open("a", encoding="utf-8") as inbox:
255
+ inbox.write(f"\n[{stamp}] fallback reason={reason} error={error or '-'}\n{text}\n")
256
+ return inbox_path
257
+
258
+
259
+ def _leader_receiver_is_direct(receiver: dict[str, Any] | None) -> bool:
260
+ return bool(receiver and receiver.get("mode") == "direct_tmux" and receiver.get("pane_id"))
261
+
262
+
263
+ def _message_by_id(store: MessageStore, message_id: str) -> dict[str, Any] | None:
264
+ return next((m for m in store.messages() if m["message_id"] == message_id), None)
265
+
266
+
267
+ def _message_payload(row: dict[str, Any]) -> dict[str, Any]:
268
+ return {
269
+ "message_id": row["message_id"],
270
+ "task_id": row["task_id"],
271
+ "from": row["sender"],
272
+ "to": row["recipient"],
273
+ "reply_to": row["reply_to"],
274
+ "requires_ack": bool(row["requires_ack"]),
275
+ "artifact_refs": json.loads(row["artifact_refs"] or "[]"),
276
+ "content": row["content"],
277
+ }
278
+
279
+
280
+ def _format_team_agent_message(payload: dict[str, Any]) -> str:
281
+ return core_render_message(payload)["text"]
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+
297
+
298
+
299
+
300
+
301
+
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+