@team-agent/installer 0.2.0 → 0.2.2

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/team_agent/cli/__init__.py +2 -0
  3. package/src/team_agent/cli/commands.py +22 -3
  4. package/src/team_agent/cli/parser.py +40 -1
  5. package/src/team_agent/coordinator/__main__.py +21 -2
  6. package/src/team_agent/coordinator/lifecycle.py +23 -0
  7. package/src/team_agent/diagnose/orphan_cleanup.py +193 -0
  8. package/src/team_agent/events.py +47 -0
  9. package/src/team_agent/leader/__init__.py +273 -60
  10. package/src/team_agent/lifecycle/agents.py +54 -2
  11. package/src/team_agent/lifecycle/operations.py +86 -9
  12. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
  13. package/src/team_agent/lifecycle/start.py +3 -0
  14. package/src/team_agent/message_store/leader_notification_log.py +132 -0
  15. package/src/team_agent/message_store/result_watchers.py +144 -1
  16. package/src/team_agent/message_store/schema.py +23 -0
  17. package/src/team_agent/messaging/delivery.py +10 -0
  18. package/src/team_agent/messaging/idle_alerts.py +227 -21
  19. package/src/team_agent/messaging/leader.py +166 -6
  20. package/src/team_agent/messaging/leader_panes.py +193 -23
  21. package/src/team_agent/messaging/owner_bypass.py +29 -0
  22. package/src/team_agent/messaging/result_delivery.py +219 -4
  23. package/src/team_agent/messaging/results.py +12 -21
  24. package/src/team_agent/messaging/scheduler.py +22 -2
  25. package/src/team_agent/messaging/send.py +9 -2
  26. package/src/team_agent/messaging/session_drift.py +94 -0
  27. package/src/team_agent/runtime.py +22 -14
  28. package/src/team_agent/rust_core.py +157 -3
  29. package/src/team_agent/state.py +167 -10
  30. package/src/team_agent/status/inbox.py +33 -3
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
3
+ from datetime import datetime, timedelta, timezone
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from team_agent.events import EventLog
8
8
  from team_agent.message_store import MessageStore
9
- from team_agent.messaging.deps import load_spec, save_runtime_state, team_state_key
9
+ from team_agent.messaging.deps import load_runtime_state, load_spec, save_runtime_state, team_state_key
10
10
  from team_agent.messaging.internal_delivery import deliver_stored_message
11
11
 
12
12
 
@@ -23,33 +23,212 @@ _UNDELIVERED_MESSAGE_STATUSES = {
23
23
  }
24
24
 
25
25
 
26
+ STABLE_IDLE_SECONDS = 120
27
+ FIRE_DEBOUNCE_SECONDS = 300
28
+ OBLIGATION_PENDING_MIN_AGE_SECONDS = 60
29
+
30
+ # Event-log progress signal (Gap 32 §"Idle-Detector False Positive Continues Post Phase G hotfix-3"):
31
+ # the team_last_progress_at calculation must also count leader-side sends and worker MCP calls
32
+ # as recent team activity, not only agent_health.last_output_at. Without this, a worker that has
33
+ # called MCP but not yet emitted a visible turn shows up as idle and the idle reminder fires
34
+ # spuriously inside the stable-idle window.
35
+ _PROGRESS_EVENT_TYPES = frozenset({
36
+ "send.deliver_attempt",
37
+ "leader_receiver.deliver_attempt",
38
+ "mcp.report_result",
39
+ "mcp.send_message",
40
+ })
41
+ _PROGRESS_EVENT_PREFIXES = ("mcp.read_",)
42
+ _PROGRESS_EVENT_WINDOW_SECONDS = 300
43
+ _PROGRESS_EVENT_TAIL_LIMIT = 1000
44
+
45
+
46
+ def _parse_iso(text: Any) -> datetime | None:
47
+ if not isinstance(text, str) or not text:
48
+ return None
49
+ try:
50
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
51
+ except ValueError:
52
+ return None
53
+ if dt.tzinfo is None:
54
+ dt = dt.replace(tzinfo=timezone.utc)
55
+ return dt
56
+
57
+
58
+ def record_team_progress(
59
+ state: dict[str, Any],
60
+ now: datetime | None = None,
61
+ *,
62
+ source: str = "",
63
+ owner_team_id: str | None = None,
64
+ ) -> None:
65
+ coordinator = state.setdefault("coordinator", {})
66
+ progress = coordinator.setdefault("team_last_progress_at", {})
67
+ key = owner_team_id or team_state_key(state)
68
+ if not key:
69
+ return
70
+ progress[key] = {
71
+ "at": (now or datetime.now(timezone.utc)).isoformat(),
72
+ "source": source,
73
+ }
74
+
75
+
76
+ def _team_last_progress_at(
77
+ state: dict[str, Any],
78
+ store: MessageStore,
79
+ owner_team_id: str,
80
+ event_log: EventLog | None = None,
81
+ now: datetime | None = None,
82
+ workspace: Path | None = None,
83
+ ) -> tuple[datetime | None, str | None]:
84
+ sources: list[tuple[datetime, str]] = []
85
+ coordinator = state.get("coordinator") or {}
86
+ explicit = (coordinator.get("team_last_progress_at") or {}).get(owner_team_id)
87
+ if isinstance(explicit, dict):
88
+ ts = _parse_iso(explicit.get("at"))
89
+ if ts:
90
+ sources.append((ts, "explicit_marker"))
91
+ elif isinstance(explicit, str):
92
+ ts = _parse_iso(explicit)
93
+ if ts:
94
+ sources.append((ts, "explicit_marker"))
95
+ health = store.agent_health(owner_team_id=owner_team_id)
96
+ for row in health.values():
97
+ ts = _parse_iso(row.get("last_output_at"))
98
+ if ts:
99
+ sources.append((ts, "agent_health.last_output_at"))
100
+ if event_log is not None:
101
+ # Spark MEDIUM #3 (d9f740d): in multi-team workspaces an unscoped progress event in
102
+ # team A's activity must NOT suppress team B's idle_fallback. require_team_scope=True
103
+ # when the workspace has more than one team so unscoped events are ignored. The
104
+ # team-scoped state passed in here does not carry the workspace-level `teams` dict, so
105
+ # we re-read the workspace state from disk to detect multi-team shape.
106
+ require_team_scope = False
107
+ teams = state.get("teams")
108
+ if isinstance(teams, dict) and len(teams) > 1:
109
+ require_team_scope = True
110
+ elif workspace is not None:
111
+ try:
112
+ ws_teams = (load_runtime_state(workspace).get("teams") or {})
113
+ except Exception:
114
+ ws_teams = {}
115
+ if isinstance(ws_teams, dict) and len(ws_teams) > 1:
116
+ require_team_scope = True
117
+ event_ts = _scan_event_progress_signals(
118
+ event_log, owner_team_id, now or datetime.now(timezone.utc),
119
+ require_team_scope=require_team_scope,
120
+ )
121
+ if event_ts:
122
+ sources.append((event_ts, "event_log"))
123
+ if not sources:
124
+ return None, None
125
+ sources.sort(key=lambda item: item[0], reverse=True)
126
+ return sources[0]
127
+
128
+
129
+ # Stage 14 (Gap 36b) — mtime cache per (workspace_path, owner_team_id, require_team_scope).
130
+ # Mac mini 2026-05-26 evidence: _scan_event_progress_signals was a 22% CPU hot path because
131
+ # every 2-second coordinator tick parsed up to 1000 events from a 28 MB events.jsonl. With
132
+ # the cache, the parse only re-runs when the file changes; quiet workspaces pay zero file
133
+ # I/O between writes.
134
+ _PROGRESS_SCAN_CACHE: dict[tuple[str, str, bool], tuple[float, datetime | None]] = {}
135
+
136
+
137
+ def _scan_event_progress_signals(
138
+ event_log: EventLog,
139
+ owner_team_id: str,
140
+ now: datetime,
141
+ *,
142
+ require_team_scope: bool = False,
143
+ ) -> datetime | None:
144
+ cache_key = (str(event_log.path), owner_team_id, require_team_scope)
145
+ try:
146
+ current_mtime = event_log.path.stat().st_mtime
147
+ except FileNotFoundError:
148
+ _PROGRESS_SCAN_CACHE.pop(cache_key, None)
149
+ return None
150
+ cached = _PROGRESS_SCAN_CACHE.get(cache_key)
151
+ if cached is not None and cached[0] == current_mtime:
152
+ return cached[1]
153
+ window_start = now - timedelta(seconds=_PROGRESS_EVENT_WINDOW_SECONDS)
154
+ latest: datetime | None = None
155
+ for event in event_log.tail(_PROGRESS_EVENT_TAIL_LIMIT):
156
+ event_type = str(event.get("event") or "")
157
+ if event_type not in _PROGRESS_EVENT_TYPES and not any(
158
+ event_type.startswith(prefix) for prefix in _PROGRESS_EVENT_PREFIXES
159
+ ):
160
+ continue
161
+ event_team = event.get("team") or event.get("owner_team_id")
162
+ if event_team is None:
163
+ if require_team_scope:
164
+ continue
165
+ elif event_team != owner_team_id:
166
+ continue
167
+ ts = _parse_iso(event.get("ts"))
168
+ if not ts or ts < window_start:
169
+ continue
170
+ if latest is None or ts > latest:
171
+ latest = ts
172
+ _PROGRESS_SCAN_CACHE[cache_key] = (current_mtime, latest)
173
+ return latest
174
+
175
+
176
+ def _reset_progress_scan_cache() -> None:
177
+ """Test-only hook to force re-scan."""
178
+ _PROGRESS_SCAN_CACHE.clear()
179
+
180
+
181
+ def _team_last_idle_fallback_fire_at(state: dict[str, Any], owner_team_id: str) -> datetime | None:
182
+ coordinator = state.get("coordinator") or {}
183
+ fires = coordinator.get("team_last_idle_fallback_fire_at") or {}
184
+ return _parse_iso(fires.get(owner_team_id))
185
+
186
+
187
+ def _record_idle_fallback_fire(state: dict[str, Any], owner_team_id: str, now: datetime) -> None:
188
+ coordinator = state.setdefault("coordinator", {})
189
+ fires = coordinator.setdefault("team_last_idle_fallback_fire_at", {})
190
+ fires[owner_team_id] = now.isoformat()
191
+
192
+
26
193
  def _team_undelivered_obligations(
27
194
  state: dict[str, Any],
28
195
  store: MessageStore,
29
196
  owner_team_id: str,
30
197
  active_task_statuses: set[str],
198
+ *,
199
+ now: datetime | None = None,
31
200
  ) -> list[dict[str, Any]]:
201
+ now = now or datetime.now(timezone.utc)
202
+ min_age = timedelta(seconds=OBLIGATION_PENDING_MIN_AGE_SECONDS)
32
203
  obligations: list[dict[str, Any]] = []
33
204
  for message in store.messages(owner_team_id=owner_team_id):
34
- if message.get("status") in _UNDELIVERED_MESSAGE_STATUSES:
35
- obligations.append(
36
- {
37
- "kind": "undelivered_message",
38
- "message_id": message.get("message_id"),
39
- "recipient": message.get("recipient"),
40
- "status": message.get("status"),
41
- }
42
- )
205
+ if message.get("status") not in _UNDELIVERED_MESSAGE_STATUSES:
206
+ continue
207
+ created_at = _parse_iso(message.get("created_at"))
208
+ if created_at and (now - created_at) < min_age:
209
+ continue
210
+ obligations.append(
211
+ {
212
+ "kind": "undelivered_message",
213
+ "message_id": message.get("message_id"),
214
+ "recipient": message.get("recipient"),
215
+ "status": message.get("status"),
216
+ }
217
+ )
43
218
  for watcher in store.retryable_result_watchers():
44
- if watcher.get("status") in {"pending", "notify_failed"}:
45
- obligations.append(
46
- {
47
- "kind": "pending_result_watcher",
48
- "watcher_id": watcher.get("watcher_id"),
49
- "task_id": watcher.get("task_id"),
50
- "agent_id": watcher.get("agent_id"),
51
- }
52
- )
219
+ if watcher.get("status") not in {"pending", "notify_failed"}:
220
+ continue
221
+ created_at = _parse_iso(watcher.get("created_at"))
222
+ if created_at and (now - created_at) < min_age:
223
+ continue
224
+ obligations.append(
225
+ {
226
+ "kind": "pending_result_watcher",
227
+ "watcher_id": watcher.get("watcher_id"),
228
+ "task_id": watcher.get("task_id"),
229
+ "agent_id": watcher.get("agent_id"),
230
+ }
231
+ )
53
232
  for task in state.get("tasks", []):
54
233
  if task.get("status", "pending") in active_task_statuses and task.get("assignee"):
55
234
  obligations.append(
@@ -118,11 +297,37 @@ def detect_idle_fallbacks(
118
297
  )
119
298
  now = now or datetime.now(timezone.utc)
120
299
  owner_team_id = team_state_key(state)
121
- obligations = _team_undelivered_obligations(state, store, owner_team_id, _ACTIVE_TASK_STATUSES)
300
+ obligations = _team_undelivered_obligations(state, store, owner_team_id, _ACTIVE_TASK_STATUSES, now=now)
122
301
  if not obligations:
123
302
  return []
124
303
  all_idle, idle_workers = _all_workers_idle(state, store, owner_team_id)
125
304
  if not all_idle:
305
+ record_team_progress(state, now, source="all_workers_idle:false", owner_team_id=owner_team_id)
306
+ save_runtime_state(workspace, state)
307
+ return []
308
+ last_progress, progress_source = _team_last_progress_at(
309
+ state, store, owner_team_id, event_log=event_log, now=now, workspace=workspace,
310
+ )
311
+ if last_progress and (now - last_progress) < timedelta(seconds=STABLE_IDLE_SECONDS):
312
+ reason = "recent_team_progress" if progress_source == "event_log" else "stable_idle_window"
313
+ event_log.write(
314
+ "coordinator.idle_fallback_skipped",
315
+ reason=reason,
316
+ team=owner_team_id,
317
+ stable_idle_seconds=STABLE_IDLE_SECONDS,
318
+ elapsed_seconds=int((now - last_progress).total_seconds()),
319
+ progress_source=progress_source,
320
+ )
321
+ return []
322
+ last_fire = _team_last_idle_fallback_fire_at(state, owner_team_id)
323
+ if last_fire and (now - last_fire) < timedelta(seconds=FIRE_DEBOUNCE_SECONDS):
324
+ event_log.write(
325
+ "coordinator.idle_fallback_skipped",
326
+ reason="fire_debounce",
327
+ team=owner_team_id,
328
+ fire_debounce_seconds=FIRE_DEBOUNCE_SECONDS,
329
+ elapsed_seconds=int((now - last_fire).total_seconds()),
330
+ )
126
331
  return []
127
332
  spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
128
333
  spec = load_spec(spec_path) if spec_path.exists() else {}
@@ -137,6 +342,7 @@ def detect_idle_fallbacks(
137
342
  alerts.append({"agent_id": agent_id, "alert_type": "idle_fallback", "obligations": obligations})
138
343
  if not alerts:
139
344
  return []
345
+ _record_idle_fallback_fire(state, owner_team_id, now)
140
346
  save_runtime_state(workspace, state)
141
347
  content = (
142
348
  "There is still unfinished work. Continue coordinating, deliver a result, "
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+
3
5
  from team_agent.messaging.deps import (
4
6
  EventLog,
5
7
  MessageStore,
@@ -10,8 +12,10 @@ from team_agent.messaging.deps import (
10
12
  _validate_leader_receiver,
11
13
  core_render_message,
12
14
  json,
15
+ os,
13
16
  runtime_dir,
14
17
  save_runtime_state,
18
+ team_state_key,
15
19
  time,
16
20
  )
17
21
 
@@ -49,6 +53,19 @@ def _leader_inbox_path(workspace: Path) -> Path:
49
53
  return runtime_dir(workspace) / "leader-inbox.log"
50
54
 
51
55
 
56
+ def _extract_result_id_from_content(content: str) -> str | None:
57
+ """Stage 12: result-notification messages embed a `Result id: <id>` line; the gate
58
+ parses it from content so callers that did NOT plumb the result_id kwarg through
59
+ still consult the dedupe gate. Format mirrors _format_report_result_notification and
60
+ format_result_watcher_notification."""
61
+ if not content:
62
+ return None
63
+ for line in content.splitlines():
64
+ if line.startswith("Result id: "):
65
+ return line.removeprefix("Result id: ").strip() or None
66
+ return None
67
+
68
+
52
69
  def _send_to_leader_receiver(
53
70
  workspace: Path,
54
71
  state: dict[str, Any],
@@ -58,6 +75,8 @@ def _send_to_leader_receiver(
58
75
  sender: str,
59
76
  requires_ack: bool,
60
77
  event_log: EventLog,
78
+ *,
79
+ result_id: str | None = None,
61
80
  ) -> dict[str, Any]:
62
81
  store = MessageStore(workspace)
63
82
  message_id = store.create_message(task_id, sender, leader_id, content, requires_ack=False)
@@ -94,10 +113,30 @@ def _send_to_leader_receiver(
94
113
  error="No direct leader tmux pane is attached. Run team-agent attach-leader.",
95
114
  )
96
115
 
97
- validation = _validate_leader_receiver(receiver)
116
+ owner_identity = state.get("team_owner") or None
117
+ side_pane_refusal = _side_pane_owner_refusal(state, owner_identity)
118
+ if side_pane_refusal:
119
+ event_log.write("leader_receiver.side_pane_refused", **side_pane_refusal)
120
+ return {
121
+ "ok": False,
122
+ "message_id": message_id,
123
+ "status": "refused",
124
+ "to": leader_id,
125
+ "channel": "direct_tmux",
126
+ **side_pane_refusal,
127
+ }
128
+ receiver_for_validation = dict(receiver)
129
+ if owner_identity and owner_identity.get("leader_session_uuid") and not receiver_for_validation.get("leader_session_uuid"):
130
+ receiver_for_validation["leader_session_uuid"] = owner_identity["leader_session_uuid"]
131
+ validation = _validate_leader_receiver(receiver_for_validation)
98
132
  if not validation["ok"]:
99
- owner_identity = state.get("team_owner") or None
100
- rediscovery = _rediscover_leader_receiver(receiver, event_log, owner_identity)
133
+ rediscovery = _rediscover_leader_receiver(
134
+ receiver_for_validation,
135
+ event_log,
136
+ owner_identity,
137
+ invalidation_reason=validation.get("reason"),
138
+ team_id=team_state_key(state),
139
+ )
101
140
  if rediscovery.get("status") == "updated":
102
141
  state["leader_receiver"].update(rediscovery["receiver"])
103
142
  receiver = state["leader_receiver"]
@@ -111,7 +150,7 @@ def _send_to_leader_receiver(
111
150
  payload,
112
151
  event_log,
113
152
  reason="ambiguous",
114
- error="multiple possible leader panes found; rerun team-agent attach-leader --pane <pane_id>",
153
+ error="multiple possible leader panes found; run team-agent claim-leader --confirm from the intended pane",
115
154
  message_status="ambiguous",
116
155
  )
117
156
  if not validation["ok"]:
@@ -128,6 +167,69 @@ def _send_to_leader_receiver(
128
167
  state["leader_receiver"].update(validation["pane"])
129
168
  submit_key, submit_reason = _choose_leader_submit_key(receiver.get("provider", "codex"), validation.get("capture", ""))
130
169
  target = receiver["pane_id"]
170
+ # Stage 12 (Gap 26 ∩ Gap 32 roundtable 2026-05-26) — injection-boundary dedupe gate.
171
+ # Result-notification injections route through claim_leader_notification_delivery; the
172
+ # gate suppresses a second inject for the same (result_id, leader_session_uuid).
173
+ # Non-result messages (peer mirror, idle reminder, ambiguous-prompt) lack a "Result id:"
174
+ # line in their text and bypass the gate.
175
+ effective_result_id = result_id or _extract_result_id_from_content(content)
176
+ leader_uuid_for_gate = str(
177
+ (state.get("team_owner") or {}).get("leader_session_uuid")
178
+ or (state.get("leader_receiver") or {}).get("leader_session_uuid")
179
+ or ""
180
+ )
181
+ if effective_result_id and leader_uuid_for_gate:
182
+ from team_agent.message_store.leader_notification_log import claim_leader_notification_delivery
183
+ envelope_hash = hashlib.sha256(content.encode("utf-8", errors="ignore")).hexdigest()[:16]
184
+ claim = claim_leader_notification_delivery(
185
+ store,
186
+ result_id=effective_result_id,
187
+ leader_session_uuid=leader_uuid_for_gate,
188
+ proposed_message_id=message_id,
189
+ envelope_hash=envelope_hash,
190
+ owner_team_id=team_state_key(state),
191
+ pane_id=target,
192
+ )
193
+ if claim["status"] == "already_notified_by":
194
+ prev_msg = claim.get("notified_message_id")
195
+ prev_hash = claim.get("envelope_content_hash")
196
+ if envelope_hash == prev_hash:
197
+ event_log.write(
198
+ "leader_notification.dedupe_skip",
199
+ result_id=effective_result_id,
200
+ leader_session_uuid=leader_uuid_for_gate,
201
+ prev_message_id=prev_msg,
202
+ this_message_id=message_id,
203
+ prev_ts=claim.get("notified_at"),
204
+ pane_id=target,
205
+ team_id=team_state_key(state),
206
+ )
207
+ else:
208
+ event_log.write(
209
+ "leader_notification.legitimate_duplicate_suspected",
210
+ result_id=effective_result_id,
211
+ leader_session_uuid=leader_uuid_for_gate,
212
+ prev_message_id=prev_msg,
213
+ this_message_id=message_id,
214
+ prev_envelope_hash=prev_hash,
215
+ this_envelope_hash=envelope_hash,
216
+ pane_id=target,
217
+ team_id=team_state_key(state),
218
+ )
219
+ store.mark(message_id, "submitted", "dedupe_suppressed_by_leader_notification_log")
220
+ save_runtime_state(workspace, state)
221
+ return {
222
+ "ok": True,
223
+ "message_id": message_id,
224
+ "status": "submitted",
225
+ "to": leader_id,
226
+ "channel": "direct_tmux",
227
+ "leader_receiver": state["leader_receiver"],
228
+ "visible": False,
229
+ "submitted": False,
230
+ "deduped": True,
231
+ "canonical_message_id": prev_msg,
232
+ }
131
233
  event_log.write(
132
234
  "leader_receiver.deliver_attempt",
133
235
  message_id=message_id,
@@ -139,6 +241,8 @@ def _send_to_leader_receiver(
139
241
  visible_token=rendered.get("token"),
140
242
  payload=payload,
141
243
  warning=validation.get("warning"),
244
+ result_id=effective_result_id,
245
+ leader_session_uuid=leader_uuid_for_gate or None,
142
246
  )
143
247
  injection = _tmux_inject_text(
144
248
  target,
@@ -201,6 +305,64 @@ def _send_to_leader_receiver(
201
305
  )
202
306
 
203
307
 
308
+ def _side_pane_owner_refusal(state: dict[str, Any], owner_identity: dict[str, Any] | None) -> dict[str, Any] | None:
309
+ owner_uuid = str((owner_identity or {}).get("leader_session_uuid") or "")
310
+ caller_uuid = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID") or os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
311
+ if not owner_uuid or not caller_uuid or caller_uuid == owner_uuid:
312
+ return None
313
+ bound_pane = (state.get("leader_receiver") or {}).get("pane_id") or (owner_identity or {}).get("pane_id")
314
+ team_id = team_state_key(state)
315
+ return {
316
+ "reason": "team_owner_mismatch",
317
+ "error": (
318
+ f"This workspace's team `{team_id}` is already bound to pane `{bound_pane}`. "
319
+ "To work in this window either start a new team with a different team_id, operate through the bound pane, "
320
+ "or run `team-agent claim-leader --confirm` only if you intend to forcibly take over."
321
+ ),
322
+ "bound_pane_id": bound_pane,
323
+ "caller_uuid_prefix": caller_uuid[:8],
324
+ "uuid_prefix": owner_uuid[:8],
325
+ "action": "team-agent claim-leader --confirm",
326
+ }
327
+
328
+
329
+ def claim_leader_receiver(
330
+ workspace: Path,
331
+ state: dict[str, Any],
332
+ candidate: dict[str, Any],
333
+ event_log: EventLog,
334
+ *,
335
+ confirm: bool,
336
+ expected_epoch: int | None = None,
337
+ ) -> dict[str, Any]:
338
+ from team_agent.messaging.leader_panes import _leader_command_looks_usable, _receiver_from_target, _target_matches_owner_identity, _uuid_prefix
339
+ if not confirm:
340
+ return {"ok": False, "status": "refused", "reason": "confirm_required", "action": "team-agent claim-leader --confirm"}
341
+ owner = state.setdefault("team_owner", {})
342
+ receiver = state.get("leader_receiver") or {}
343
+ current_epoch = int(owner.get("owner_epoch") or receiver.get("owner_epoch") or 0)
344
+ if expected_epoch is not None and current_epoch != expected_epoch:
345
+ event_log.write("leader_receiver.claim_refused", reason="owner_epoch_advanced", owner_epoch=current_epoch, bound_pane_id=receiver.get("pane_id"))
346
+ return {"ok": False, "status": "refused", "reason": "owner_epoch_advanced", "owner_epoch": current_epoch, "bound_pane_id": receiver.get("pane_id")}
347
+ if receiver.get("pane_id") == candidate.get("pane_id"):
348
+ return {"ok": True, "status": "already_bound", "leader_receiver": receiver, "owner_epoch": current_epoch}
349
+ if not _target_matches_owner_identity(candidate, owner):
350
+ event_log.write("leader_receiver.claim_refused", reason="uuid_mismatch", candidate_pane_id=candidate.get("pane_id"))
351
+ return {"ok": False, "status": "refused", "reason": "uuid_mismatch"}
352
+ provider = str(candidate.get("provider") or receiver.get("provider") or "codex")
353
+ if not _leader_command_looks_usable(str(candidate.get("pane_current_command", "")), provider):
354
+ return {"ok": False, "status": "refused", "reason": "wrong_command", "candidate_pane_id": candidate.get("pane_id")}
355
+ next_epoch = current_epoch + 1
356
+ new_receiver = _receiver_from_target(candidate, provider, owner.get("leader_session_uuid"), next_epoch)
357
+ owner["owner_epoch"] = next_epoch
358
+ state["leader_receiver"] = new_receiver
359
+ from team_agent.runtime import _runtime_lock, save_runtime_state
360
+ with _runtime_lock(workspace, "leader_receiver"):
361
+ save_runtime_state(workspace, state)
362
+ event_log.write("leader_receiver.claimed", pane_id=new_receiver["pane_id"], owner_epoch=next_epoch, uuid_prefix=_uuid_prefix(owner))
363
+ return {"ok": True, "status": "claimed", "leader_receiver": new_receiver, "owner_epoch": next_epoch}
364
+
365
+
204
366
  def _fail_leader_delivery(
205
367
  workspace: Path,
206
368
  state: dict[str, Any],
@@ -310,8 +472,6 @@ def _format_team_agent_message(payload: dict[str, Any]) -> str:
310
472
 
311
473
 
312
474
 
313
-
314
-
315
475
 
316
476
 
317
477