@team-agent/installer 0.2.1 → 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.
@@ -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
 
@@ -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
  RuntimeError,
@@ -9,6 +11,7 @@ from team_agent.messaging.deps import (
9
11
  _tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
10
12
  _tmux_list_panes as _runtime_tmux_list_panes,
11
13
  _tmux_pane_info as _runtime_tmux_pane_info,
14
+ _tmux_inject_text,
12
15
  core_list_targets,
13
16
  datetime,
14
17
  os,
@@ -20,6 +23,8 @@ from team_agent.messaging.deps import (
20
23
  from pathlib import Path
21
24
  from typing import Any
22
25
 
26
+ _AMBIGUOUS_DEBOUNCE_SECONDS = 60
27
+
23
28
  def _resolve_leader_pane(
24
29
  pane: str | None,
25
30
  provider: str,
@@ -208,17 +213,40 @@ def _target_fingerprint(pane_info: dict[str, Any]) -> str:
208
213
  )
209
214
 
210
215
 
216
+ def is_bound_pane_still_valid(state: dict[str, Any], store: Any | None = None) -> dict[str, Any]:
217
+ receiver = dict(state.get("leader_receiver") or {})
218
+ owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
219
+ if owner and owner.get("leader_session_uuid") and not receiver.get("leader_session_uuid"):
220
+ receiver["leader_session_uuid"] = owner["leader_session_uuid"]
221
+ return _validate_leader_receiver(receiver)
222
+
223
+
211
224
  def _rediscover_leader_receiver(
212
225
  receiver: dict[str, Any],
213
226
  event_log: EventLog,
214
227
  owner_identity: dict[str, Any] | None = None,
228
+ invalidation_reason: str | None = None,
229
+ team_id: str | None = None,
215
230
  ) -> dict[str, Any]:
216
231
  provider = str(receiver.get("provider") or "codex")
217
- if provider != "codex":
218
- return {"status": "missing", "reason": "rediscovery_only_for_codex"}
232
+ if provider == "fake":
233
+ return {"status": "missing", "reason": "rediscovery_not_supported_for_fake"}
219
234
  targets = core_list_targets()
220
235
  if not targets.get("ok"):
221
236
  event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
237
+ # Stage 15 CI fix: when the tmux target scan itself fails (no server, no daemon,
238
+ # CI env without tmux), the caller has no way to recover unless we also emit
239
+ # rebind_required. Without this, _refresh_leader_receiver_or_flag_rebind silently
240
+ # returns and report_result queues against the stale pane with zero audit signal.
241
+ event_log.write(
242
+ "leader_receiver.rebind_required",
243
+ old_pane_id=receiver.get("pane_id"),
244
+ reason=invalidation_reason,
245
+ provider=provider,
246
+ team_id=team_id,
247
+ rediscovery_status="failed",
248
+ error=targets.get("error"),
249
+ )
222
250
  return {"status": "failed", "error": targets.get("error")}
223
251
  candidates = [
224
252
  target
@@ -228,16 +256,26 @@ def _rediscover_leader_receiver(
228
256
  if owner_identity:
229
257
  owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
230
258
  if len(owner_candidates) == 1:
231
- return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
259
+ return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity, invalidation_reason)
232
260
  if len(owner_candidates) > 1:
261
+ incident = _broadcast_ambiguous_candidates(
262
+ receiver,
263
+ provider,
264
+ owner_candidates,
265
+ event_log,
266
+ owner_identity,
267
+ team_id,
268
+ )
233
269
  event_log.write(
234
270
  "leader_receiver.rediscover_ambiguous",
235
271
  provider=provider,
236
272
  old_target=receiver.get("pane_id"),
237
273
  candidates=[target.get("pane_id") for target in owner_candidates],
238
274
  owner_identity=owner_identity,
275
+ incident_id=incident.get("incident_id"),
276
+ deduped=incident.get("deduped"),
239
277
  )
240
- return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
278
+ return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity, **incident}
241
279
  event_log.write(
242
280
  "leader_receiver.rediscover_missing",
243
281
  provider=provider,
@@ -245,9 +283,19 @@ def _rediscover_leader_receiver(
245
283
  owner_identity=owner_identity,
246
284
  candidate_count=len(candidates),
247
285
  )
286
+ event_log.write(
287
+ "leader_receiver.rebind_required",
288
+ old_pane_id=receiver.get("pane_id"),
289
+ reason=invalidation_reason,
290
+ provider=provider,
291
+ team_id=team_id,
292
+ uuid_prefix=_uuid_prefix(owner_identity),
293
+ owner_identity=owner_identity,
294
+ recovery_action="open the owning leader pane or run team-agent claim-leader --confirm from a matching pane",
295
+ )
248
296
  return {"status": "missing", "owner_identity": owner_identity}
249
297
  if len(candidates) == 1:
250
- return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
298
+ return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None, invalidation_reason)
251
299
  if len(candidates) > 1:
252
300
  event_log.write(
253
301
  "leader_receiver.rediscover_ambiguous",
@@ -255,12 +303,19 @@ def _rediscover_leader_receiver(
255
303
  old_target=receiver.get("pane_id"),
256
304
  candidates=[target.get("pane_id") for target in candidates],
257
305
  )
306
+ event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="ambiguous")
258
307
  return {"status": "ambiguous", "candidates": candidates}
259
308
  event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
309
+ event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="missing")
260
310
  return {"status": "missing"}
261
311
 
262
312
 
263
313
  def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
314
+ expected_uuid = owner_identity.get("leader_session_uuid")
315
+ if expected_uuid:
316
+ actual_uuid = _target_leader_session_uuid(target)
317
+ if actual_uuid:
318
+ return actual_uuid == expected_uuid
264
319
  env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
265
320
  return (
266
321
  env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
@@ -269,14 +324,31 @@ def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[
269
324
  )
270
325
 
271
326
 
272
- def _rediscovered_receiver(
273
- receiver: dict[str, Any],
274
- provider: str,
275
- target: dict[str, Any],
276
- event_log: EventLog,
277
- owner_identity: dict[str, Any] | None,
278
- ) -> dict[str, Any]:
279
- updated = {
327
+ def _target_leader_session_uuid(target: dict[str, Any]) -> str:
328
+ env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
329
+ return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
330
+
331
+
332
+ def _leader_uuid_for_bound_pane(receiver: dict[str, Any], pane_info: dict[str, Any]) -> str:
333
+ direct = _target_leader_session_uuid(pane_info) or _target_leader_session_uuid(receiver)
334
+ if direct:
335
+ return direct
336
+ targets = core_list_targets()
337
+ if not targets.get("ok"):
338
+ return ""
339
+ pane_id = pane_info.get("pane_id")
340
+ for target in targets.get("targets", []):
341
+ if target.get("pane_id") == pane_id:
342
+ return _target_leader_session_uuid(target)
343
+ return ""
344
+
345
+
346
+ def _uuid_prefix(owner_identity: dict[str, Any] | None) -> str:
347
+ return str((owner_identity or {}).get("leader_session_uuid") or "")[:8]
348
+
349
+
350
+ def _receiver_from_target(target: dict[str, Any], provider: str, leader_uuid: str | None, owner_epoch: int | None = None) -> dict[str, Any]:
351
+ receiver = {
280
352
  "mode": "direct_tmux",
281
353
  "status": "attached",
282
354
  "provider": provider,
@@ -289,8 +361,83 @@ def _rediscovered_receiver(
289
361
  "pane_current_command": target["pane_current_command"],
290
362
  "fingerprint": target.get("fingerprint") or _target_fingerprint(target),
291
363
  "attached_at": datetime.now(timezone.utc).isoformat(),
292
- "discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
293
364
  }
365
+ if leader_uuid:
366
+ receiver["leader_session_uuid"] = leader_uuid
367
+ if owner_epoch is not None:
368
+ receiver["owner_epoch"] = owner_epoch
369
+ return receiver
370
+
371
+
372
+ def _broadcast_ambiguous_candidates(
373
+ receiver: dict[str, Any],
374
+ provider: str,
375
+ candidates: list[dict[str, Any]],
376
+ event_log: EventLog,
377
+ owner_identity: dict[str, Any],
378
+ team_id: str | None,
379
+ ) -> dict[str, Any]:
380
+ candidate_ids = sorted(str(candidate.get("pane_id")) for candidate in candidates)
381
+ bucket = _ambiguous_debounce_bucket()
382
+ incident_id = hashlib.sha256("\0".join([str(team_id or ""), *candidate_ids, bucket]).encode("utf-8")).hexdigest()[:16]
383
+ if any(event.get("event") == "leader_receiver.ambiguous_candidates" and event.get("incident_id") == incident_id for event in event_log.tail(200)):
384
+ return {"incident_id": incident_id, "deduped": True}
385
+ prompt = _ambiguous_candidate_prompt(team_id, len(candidates))
386
+ event_log.write(
387
+ "leader_receiver.ambiguous_candidates",
388
+ incident_id=incident_id,
389
+ old_pane_id=receiver.get("pane_id"),
390
+ candidates=candidate_ids,
391
+ provider=provider,
392
+ team_id=team_id,
393
+ uuid_prefix=_uuid_prefix(owner_identity),
394
+ debounce_bucket=bucket,
395
+ )
396
+ for candidate in candidates:
397
+ pane_id = str(candidate.get("pane_id") or "")
398
+ injected = _tmux_inject_text(
399
+ pane_id,
400
+ prompt,
401
+ "Enter",
402
+ f"team-agent-leader-ambiguous-{incident_id}-{pane_id.strip('%')}",
403
+ provider=provider,
404
+ )
405
+ event_log.write(
406
+ "leader_receiver.ambiguous_candidate_queued",
407
+ incident_id=incident_id,
408
+ pane_id=pane_id,
409
+ ok=bool(injected.get("ok")),
410
+ error=injected.get("error"),
411
+ )
412
+ return {"incident_id": incident_id, "deduped": False}
413
+
414
+
415
+ def _ambiguous_debounce_bucket() -> str:
416
+ now = datetime.now(timezone.utc)
417
+ epoch = int(now.timestamp() // _AMBIGUOUS_DEBOUNCE_SECONDS) * _AMBIGUOUS_DEBOUNCE_SECONDS
418
+ return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
419
+
420
+
421
+ def _ambiguous_candidate_prompt(team_id: str | None, candidate_count: int) -> str:
422
+ others = max(candidate_count - 1, 0)
423
+ return (
424
+ f"Team `{team_id or 'current'}` has no bound leader. This window and {others} other window(s) all qualify. "
425
+ "To claim this window as the team leader, run: `team-agent claim-leader --confirm`. "
426
+ "Only the first such call wins; subsequent calls from other windows will be refused."
427
+ )
428
+
429
+
430
+ def _rediscovered_receiver(
431
+ receiver: dict[str, Any],
432
+ provider: str,
433
+ target: dict[str, Any],
434
+ event_log: EventLog,
435
+ owner_identity: dict[str, Any] | None,
436
+ invalidation_reason: str | None = None,
437
+ ) -> dict[str, Any]:
438
+ leader_uuid = _target_leader_session_uuid(target) or (owner_identity or {}).get("leader_session_uuid") or receiver.get("leader_session_uuid")
439
+ updated = _receiver_from_target(target, provider, leader_uuid)
440
+ updated["discovery"] = "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate"
294
441
  event_log.write(
295
442
  "leader_receiver.rediscovered",
296
443
  provider=provider,
@@ -299,6 +446,14 @@ def _rediscovered_receiver(
299
446
  candidate_count=1,
300
447
  owner_identity=owner_identity,
301
448
  )
449
+ event_log.write(
450
+ "leader_receiver.rebind_applied",
451
+ old_pane_id=receiver.get("pane_id"),
452
+ new_pane_id=updated["pane_id"],
453
+ reason=invalidation_reason,
454
+ owner_identity=owner_identity,
455
+ uuid_prefix=_uuid_prefix(owner_identity),
456
+ )
302
457
  return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
303
458
 
304
459
 
@@ -306,6 +461,26 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
306
461
  pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
307
462
  if not pane_info:
308
463
  return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
464
+ provider = str(receiver.get("provider") or "codex")
465
+ if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
466
+ return {
467
+ "ok": False,
468
+ "reason": "leader_pane_wrong_command",
469
+ "error": f"pane command {pane_info.get('pane_current_command')!r} is not a leader host",
470
+ "pane": pane_info,
471
+ }
472
+ expected_uuid = receiver.get("leader_session_uuid")
473
+ if expected_uuid:
474
+ actual_uuid = _leader_uuid_for_bound_pane(receiver, pane_info)
475
+ if not actual_uuid:
476
+ return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": pane_info}
477
+ if actual_uuid != expected_uuid:
478
+ return {
479
+ "ok": False,
480
+ "reason": "leader_uuid_mismatch",
481
+ "error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner",
482
+ "pane": pane_info,
483
+ }
309
484
  capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
310
485
  if capture.returncode != 0:
311
486
  return {
@@ -314,14 +489,7 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
314
489
  "error": capture.stderr.strip() or "tmux capture-pane failed",
315
490
  "pane": pane_info,
316
491
  }
317
- warning = None
318
- provider = str(receiver.get("provider") or "codex")
319
- if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
320
- warning = (
321
- f"pane command {pane_info.get('pane_current_command')!r} is not a typical {provider} host; "
322
- "continuing because tmux capture works"
323
- )
324
- return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": warning}
492
+ return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": None}
325
493
 
326
494
 
327
495
  def _leader_command_looks_usable(command: str, provider: str) -> bool:
@@ -330,7 +498,9 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
330
498
  command_name = Path(command).name
331
499
  if provider == "codex":
332
500
  return command_name in {"codex", "node", "nodejs"}
333
- return bool(command_name)
501
+ if provider in {"claude", "claude_code"}:
502
+ return command_name in {"claude", "claude.exe"}
503
+ return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
334
504
 
335
505
 
336
506
  def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]: