@team-agent/installer 0.2.7 → 0.2.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -100,10 +100,7 @@ def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]
100
100
 
101
101
 
102
102
  def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
103
- from team_agent.messaging.leader_panes import _leader_command_looks_usable, _leader_command_provider
104
- command = pane.get("pane_current_command", "")
105
- if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
106
- return False
103
+ _ = provider
107
104
  if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
108
105
  return False
109
106
  return True
@@ -177,7 +174,7 @@ def _resolve_leader_pane(
177
174
  )
178
175
  raise _RuntimeError(
179
176
  "Team Agent could not locate a tmux-managed leader pane for this workspace. "
180
- "Run quick-start from the visible tmux-managed leader pane, pass --pane explicitly, "
177
+ "Run quick-start from the visible tmux-managed leader pane, "
181
178
  "or use `team-agent codex`/`team-agent claude` as a convenience fallback."
182
179
  + details
183
180
  )
@@ -54,6 +54,8 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
54
54
  health_status = agent_health_status(agent_state)
55
55
  last_output_at = agent_state.get("last_output_at")
56
56
  window = agent_state.get("window", agent_id)
57
+ current_task = current_task_for_agent(state.get("tasks", []), agent_id)
58
+ pane_delta_recent = False
57
59
  scrollback = ""
58
60
  pane_info: dict[str, Any] | None = None
59
61
  if session_name and _tmux_window_exists(session_name, window):
@@ -62,6 +64,7 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
62
64
  scrollback = proc.stdout
63
65
  digest = hashlib.sha256(proc.stdout.encode("utf-8", errors="ignore")).hexdigest()
64
66
  if digest != agent_state.get("last_output_hash"):
67
+ pane_delta_recent = True
65
68
  last_output_at = datetime.now(timezone.utc).isoformat()
66
69
  agent_state["last_output_hash"] = digest
67
70
  agent_state["last_output_at"] = last_output_at
@@ -78,6 +81,8 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
78
81
  last_output_at,
79
82
  pane_info,
80
83
  scrollback,
84
+ active_task=current_task is not None,
85
+ pane_delta_recent=pane_delta_recent,
81
86
  )
82
87
  agent_state["activity"] = {
83
88
  "status": activity.get("status"),
@@ -91,7 +96,6 @@ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStor
91
96
  mapped = mapping.get(raw)
92
97
  if mapped:
93
98
  health_status = mapped
94
- current_task = current_task_for_agent(state.get("tasks", []), agent_id)
95
99
  store.upsert_agent_health(
96
100
  agent_id,
97
101
  health_status,
@@ -219,6 +219,16 @@ def cmd_doctor(args: argparse.Namespace) -> dict[str, Any] | str:
219
219
  gate = getattr(args, "gate", None)
220
220
  if getattr(args, "fix", False) is True and not gate:
221
221
  raise TeamAgentError("--fix requires --gate")
222
+ if getattr(args, "comms", False) is True or gate == "comms":
223
+ from team_agent.diagnose.comms import COMMS_BOUNDARY_TEXT, run_comms_selftest
224
+ result = run_comms_selftest(
225
+ Path(args.workspace).resolve(),
226
+ team=getattr(args, "team", None),
227
+ gate=gate,
228
+ )
229
+ if args.json:
230
+ return result
231
+ return f"{COMMS_BOUNDARY_TEXT}\n{json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)}"
222
232
  if isinstance(gate, str) and gate:
223
233
  from team_agent.diagnose.orphan_cleanup import orphan_gate
224
234
  if gate != "orphans":
@@ -315,10 +315,27 @@ def main(argv: list[str] | None = None) -> None:
315
315
  add_json(p)
316
316
  p.set_defaults(func=cmd_validate_result)
317
317
 
318
- p = sub.add_parser("doctor", help="Check local dependencies, providers, auth hints, tmux, and MCP")
318
+ p = sub.add_parser(
319
+ "doctor",
320
+ help="Check local dependencies, providers, auth hints, tmux, and MCP",
321
+ usage=(
322
+ "team-agent doctor validates live pane binding consistency. Does NOT perform live runtime message "
323
+ "round-trip. comms contract suite deferred to 0.2.9 (test files not shipped). "
324
+ "(zero token, zero pollution) [options]"
325
+ ),
326
+ )
319
327
  p.add_argument("spec", nargs="?")
320
328
  p.add_argument("--workspace", default=".", help="Workspace whose team.db schema should be diagnosed")
321
- p.add_argument("--gate", choices=["orphans"], help="Run a CI-friendly doctor gate")
329
+ p.add_argument("--gate", choices=["orphans", "comms"], help="Run a CI-friendly doctor gate")
330
+ p.add_argument(
331
+ "--comms",
332
+ action="store_true",
333
+ help=(
334
+ "Validate live pane binding consistency. Does NOT perform live runtime message round-trip. "
335
+ "comms contract suite deferred to 0.2.9 (test files not shipped). (zero token, zero pollution)"
336
+ ),
337
+ )
338
+ p.add_argument("--team", help="Explicit team/session target for --comms")
322
339
  p.add_argument("--fix", action="store_true", help="With --gate orphans: apply the gate fix")
323
340
  p.add_argument("--fix-schema", action="store_true", help="Rebuild drifted team.db table layouts after writing a backup")
324
341
  p.add_argument(
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Any, Protocol
7
+
8
+ from team_agent.state import load_runtime_state, select_runtime_state
9
+
10
+
11
+ COMMS_BOUNDARY_TEXT = (
12
+ "validates live pane binding consistency. Does NOT perform live runtime message round-trip. "
13
+ "comms contract suite deferred to 0.2.9 (test files not shipped). (zero token, zero pollution)"
14
+ )
15
+
16
+
17
+ class CommsSelftestDriver(Protocol):
18
+ """Injectable boundary for tests; production reads state only."""
19
+
20
+
21
+ def run_comms_selftest(
22
+ workspace: Path,
23
+ *,
24
+ team: str | None = None,
25
+ gate: str | None = None,
26
+ response_sla_sec: float = 20.0,
27
+ probe_content: str | None = None,
28
+ driver: CommsSelftestDriver | None = None,
29
+ ) -> dict[str, Any]:
30
+ del gate, response_sla_sec, probe_content
31
+ workspace = workspace.resolve()
32
+ driver = driver or _DefaultCommsSelftestDriver()
33
+ run_id = _driver_call(driver, "run_id", default=None) or _driver_value(driver, "run_id", default=None) or uuid.uuid4().hex[:12]
34
+ checks = {
35
+ "receiver_binding": _receiver_binding_check(workspace, team, driver),
36
+ "contract_suite": _contract_suite_check(workspace, driver),
37
+ "provider_sdk_calls": _provider_sdk_calls_check(driver),
38
+ }
39
+ ok = all(_check_pass(check) for check in checks.values())
40
+ return {
41
+ "ok": ok,
42
+ "status": "pass" if ok else "fail",
43
+ "run_id": run_id,
44
+ "scope": "binding_consistency",
45
+ "boundary": COMMS_BOUNDARY_TEXT,
46
+ "checks": checks,
47
+ }
48
+
49
+
50
+ def evaluate_idle_behavior(
51
+ workspace: Path,
52
+ *,
53
+ agent_id: str,
54
+ claimed_status: str,
55
+ response_sla_sec: float = 20.0,
56
+ token: str | None = None,
57
+ driver: CommsSelftestDriver | None = None,
58
+ ) -> dict[str, Any]:
59
+ run_id = uuid.uuid4().hex[:12]
60
+ probe_token = token or f"idle-challenge-{run_id}"
61
+ driver = driver or _DefaultCommsSelftestDriver()
62
+ result = _driver_call(
63
+ driver,
64
+ "evaluate_idle_behavior",
65
+ workspace.resolve(),
66
+ agent_id=agent_id,
67
+ claimed_status=claimed_status,
68
+ response_sla_sec=response_sla_sec,
69
+ token=probe_token,
70
+ default=None,
71
+ )
72
+ if isinstance(result, dict):
73
+ return _normalize_idle_result(result, probe_token)
74
+ idle_execution = _driver_value(driver, "idle_execution", default=None)
75
+ if idle_execution is not None:
76
+ execution = str(idle_execution.get("status") if isinstance(idle_execution, dict) else idle_execution)
77
+ return {
78
+ "ok": execution not in {"timeout", "fail", "failed"},
79
+ "agent_id": agent_id,
80
+ "claimed_status": claimed_status,
81
+ "token": probe_token,
82
+ "status": "pass" if execution not in {"timeout", "fail", "failed"} else "fail",
83
+ "execution_ack": execution,
84
+ "classification_accuracy": "pass" if execution not in {"timeout", "fail", "failed"} else "fail",
85
+ }
86
+ status = str(claimed_status or "").upper()
87
+ return {
88
+ "ok": status in {"IDLE", "WORKING", "RUNNING"},
89
+ "agent_id": agent_id,
90
+ "claimed_status": claimed_status,
91
+ "token": probe_token,
92
+ "status": "not_challenged",
93
+ "execution_ack": "pass" if status in {"IDLE", "WORKING", "RUNNING"} else "timeout",
94
+ }
95
+
96
+
97
+ def _receiver_binding_check(workspace: Path, team: str | None, driver: CommsSelftestDriver) -> dict[str, Any]:
98
+ override = _driver_call(driver, "receiver_binding", workspace, team=team, default=None)
99
+ if isinstance(override, dict):
100
+ out = dict(override)
101
+ out.setdefault("status", "pass" if out.get("ok", True) else "fail")
102
+ out.setdefault("verifies", "binding_consistency")
103
+ out.setdefault("proof", "state_read")
104
+ out.setdefault("state_read_observed", True)
105
+ return out
106
+ state = _selftest_state(workspace, team, driver)
107
+ receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
108
+ owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
109
+ receiver_pane = str(receiver.get("pane_id") or "")
110
+ owner_pane = str(owner.get("pane_id") or "")
111
+ caller_pane = str(_driver_call(driver, "current_pane_id", default=None) or os.environ.get("TMUX_PANE") or "")
112
+ mismatches: list[str] = []
113
+ if owner_pane and receiver_pane and owner_pane != receiver_pane:
114
+ mismatches.append("owner_receiver_pane_mismatch")
115
+ if caller_pane and owner_pane and caller_pane != owner_pane:
116
+ mismatches.append("caller_owner_pane_mismatch")
117
+ if caller_pane and receiver_pane and caller_pane != receiver_pane:
118
+ mismatches.append("caller_receiver_pane_mismatch")
119
+ return {
120
+ "status": "fail" if mismatches else "pass",
121
+ "verifies": "binding_consistency",
122
+ "proof": "state_read",
123
+ "state_read_observed": True,
124
+ "pane_id": receiver_pane,
125
+ "owner_pane_id": owner_pane,
126
+ "caller_pane_id": caller_pane,
127
+ "mismatches": mismatches,
128
+ "configured": bool(receiver_pane),
129
+ }
130
+
131
+
132
+ def _contract_suite_check(workspace: Path, driver: CommsSelftestDriver) -> dict[str, Any]:
133
+ del workspace, driver
134
+ return {
135
+ "status": "deferred",
136
+ "deferred_to": "0.2.9",
137
+ "reason": "contract test files not shipped with package",
138
+ "message": "comms contract verification deferred to 0.2.9; contract test files not shipped with package",
139
+ }
140
+
141
+
142
+ def _provider_sdk_calls_check(driver: CommsSelftestDriver) -> dict[str, Any]:
143
+ calls = _driver_value(driver, "provider_sdk_calls", default=None)
144
+ if not isinstance(calls, dict):
145
+ calls = {"anthropic": 0, "openai": 0, "httpx": 0}
146
+ calls = {name: int(calls.get(name, 0) or 0) for name in ("anthropic", "openai", "httpx")}
147
+ return {
148
+ "status": "fail" if any(calls.values()) else "pass",
149
+ "verifies": "no_provider_sdk_calls",
150
+ "calls": calls,
151
+ }
152
+
153
+
154
+ def _selftest_state(workspace: Path, team: str | None, driver: CommsSelftestDriver) -> dict[str, Any]:
155
+ override = _driver_call(driver, "select_runtime_state", workspace, team=team, default=None)
156
+ if isinstance(override, dict):
157
+ return dict(override)
158
+ override = _driver_call(driver, "load_runtime_state", workspace, default=None)
159
+ if isinstance(override, dict):
160
+ return dict(override)
161
+ override = _driver_value(driver, "state", default=None)
162
+ if isinstance(override, dict):
163
+ return dict(override)
164
+ override = _driver_value(driver, "state_before", default=None)
165
+ if isinstance(override, dict):
166
+ return dict(override)
167
+ return select_runtime_state(workspace, team)
168
+
169
+
170
+ def _check_pass(value: Any) -> bool:
171
+ if not isinstance(value, dict):
172
+ return False
173
+ if value.get("status") == "deferred":
174
+ return True
175
+ return value.get("status") in {"pass", "not_implemented"} and _has_required_evidence(value)
176
+
177
+
178
+ def _has_required_evidence(value: dict[str, Any]) -> bool:
179
+ verifies = value.get("verifies")
180
+ if verifies == "binding_consistency":
181
+ return value.get("proof") == "state_read" and value.get("state_read_observed") is True
182
+ if verifies == "no_provider_sdk_calls":
183
+ calls = value.get("calls") if isinstance(value.get("calls"), dict) else {}
184
+ return all(int(calls.get(name, 0) or 0) == 0 for name in ("anthropic", "openai", "httpx"))
185
+ return value.get("status") == "pass"
186
+
187
+
188
+ def _normalize_idle_result(result: dict[str, Any], token: str) -> dict[str, Any]:
189
+ out = dict(result)
190
+ out.setdefault("token", token)
191
+ if "execution_ack" not in out:
192
+ if out.get("ok") is False or out.get("status") in {"timeout", "busy", "fail"}:
193
+ out["execution_ack"] = "timeout"
194
+ else:
195
+ out["execution_ack"] = "pass"
196
+ return out
197
+
198
+
199
+ def _driver_call(driver: CommsSelftestDriver | None, name: str, *args: Any, default: Any = None, **kwargs: Any) -> Any:
200
+ fn = getattr(driver, name, None)
201
+ if not callable(fn):
202
+ return default
203
+ return fn(*args, **kwargs)
204
+
205
+
206
+ def _driver_value(driver: CommsSelftestDriver | None, name: str, default: Any = None) -> Any:
207
+ if driver is None:
208
+ return default
209
+ return getattr(driver, name, default)
210
+
211
+
212
+ class _DefaultCommsSelftestDriver:
213
+ pass
@@ -245,34 +245,51 @@ def adaptive_blocked(
245
245
  return displays
246
246
 
247
247
 
248
- def close_adaptive_display(state: dict[str, Any], event_log: EventLog) -> None:
248
+ def close_adaptive_display(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
249
249
  displays = [
250
250
  (agent_id, agent_state.get("display") or {})
251
251
  for agent_id, agent_state in state.get("agents", {}).items()
252
252
  if (agent_state.get("display") or {}).get("backend") == "adaptive"
253
253
  ]
254
254
  if not displays:
255
- return
255
+ return {"windows": [], "linked_sessions": [], "orphans_detected": {}}
256
256
  killed_windows: list[str] = []
257
257
  linked_sessions: list[str] = []
258
+ session_name = str(state.get("session_name") or "")
259
+ leader_session = _adaptive_leader_session(state, displays)
260
+ needs_named_fallback = False
258
261
  for _agent_id, display in displays:
259
262
  linked = display.get("linked_session")
260
263
  if linked:
261
264
  linked_sessions.append(str(linked))
265
+ if not linked or not display.get("leader_session") or not (display.get("workspace_window") or display.get("window")):
266
+ needs_named_fallback = True
262
267
  seen_targets: set[str] = set()
263
268
  for _agent_id, display in displays:
264
- leader_session = str(display.get("leader_session") or "")
269
+ display_leader_session = str(display.get("leader_session") or "")
265
270
  window_name = str(display.get("workspace_window") or display.get("window") or "")
266
- if not leader_session or not window_name:
271
+ if not display_leader_session or not window_name:
267
272
  continue
268
- target = f"{leader_session}:{window_name}"
273
+ target = f"{display_leader_session}:{window_name}"
269
274
  if target in seen_targets:
270
275
  continue
271
276
  seen_targets.add(target)
272
277
  if kill_adaptive_window(target):
273
278
  killed_windows.append(target)
274
- linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
275
- event_log.write("display.adaptive_closed", windows=killed_windows, linked_sessions=linked_closed)
279
+ removed_orphans: dict[str, list[str]] = {}
280
+ if needs_named_fallback and leader_session and session_name:
281
+ named_windows = close_adaptive_windows(leader_session, session_name, event_log)
282
+ killed_windows.extend(named_windows)
283
+ linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
284
+ named_closed, named_failed = _kill_adaptive_named_display_sessions(session_name, [agent_id for agent_id, _display in displays])
285
+ linked_closed.extend(named_closed)
286
+ removed_orphans = _adaptive_orphan_summary(named_closed, named_windows)
287
+ else:
288
+ named_failed = []
289
+ linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
290
+ orphans = _adaptive_orphans(session_name, leader_session, [agent_id for agent_id, _display in displays], named_failed) if needs_named_fallback else {}
291
+ event_log.write("display.adaptive_closed", windows=killed_windows, linked_sessions=linked_closed, orphans_detected=orphans, orphans_removed=removed_orphans)
292
+ return {"windows": killed_windows, "linked_sessions": linked_closed, "orphans_detected": orphans, "orphans_removed": removed_orphans}
276
293
 
277
294
 
278
295
  def close_adaptive_windows(leader_session: str, session_name: str, event_log: EventLog | None = None) -> list[str]:
@@ -293,6 +310,75 @@ def close_adaptive_windows(leader_session: str, session_name: str, event_log: Ev
293
310
  return killed
294
311
 
295
312
 
313
+ def _adaptive_leader_session(state: dict[str, Any], displays: list[tuple[str, dict[str, Any]]]) -> str:
314
+ for _agent_id, display in displays:
315
+ if display.get("leader_session"):
316
+ return str(display["leader_session"])
317
+ receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
318
+ return str(receiver.get("session_name") or "")
319
+
320
+
321
+ def _adaptive_named_display_sessions(session_name: str, agent_ids: list[str], fallback_exact: bool = True) -> list[str]:
322
+ from team_agent.runtime import run_cmd
323
+ if not session_name or not agent_ids:
324
+ return []
325
+ exact = [ghostty_display_session_name(session_name, agent_id) for agent_id in agent_ids]
326
+ proc = run_cmd(["tmux", "list-sessions", "-F", "#{session_name}"], timeout=10)
327
+ if proc.returncode != 0:
328
+ return exact if fallback_exact else []
329
+ prefixes = [ghostty_display_session_name(session_name, agent_id).rsplit("__", 1)[0] + "__" for agent_id in agent_ids]
330
+ matched = [name for name in proc.stdout.splitlines() if any(name.startswith(prefix) for prefix in prefixes)]
331
+ return matched or (exact if fallback_exact else [])
332
+
333
+
334
+ def _kill_adaptive_named_display_sessions(session_name: str, agent_ids: list[str]) -> tuple[list[str], list[str]]:
335
+ from team_agent.runtime import run_cmd
336
+ killed: list[str] = []
337
+ failed: list[str] = []
338
+ for display_session in _adaptive_named_display_sessions(session_name, agent_ids):
339
+ proc = run_cmd(["tmux", "kill-session", "-t", display_session], timeout=10)
340
+ if proc.returncode == 0:
341
+ killed.append(display_session)
342
+ else:
343
+ failed.append(display_session)
344
+ return killed, failed
345
+
346
+
347
+ def _adaptive_orphans(session_name: str, leader_session: str, agent_ids: list[str], failed_sessions: list[str]) -> dict[str, list[str]]:
348
+ display_sessions = sorted(set([*_adaptive_named_display_sessions(session_name, agent_ids, fallback_exact=False), *failed_sessions]))
349
+ windows: list[str] = []
350
+ if leader_session and session_name:
351
+ windows = _adaptive_window_orphans(leader_session, session_name)
352
+ if not display_sessions and not windows:
353
+ return {}
354
+ return {
355
+ "adaptive_display_sessions": sorted(set(display_sessions)),
356
+ "adaptive_overview_windows": sorted(set(windows)),
357
+ }
358
+
359
+
360
+ def _adaptive_orphan_summary(display_sessions: list[str], windows: list[str]) -> dict[str, list[str]]:
361
+ if not display_sessions and not windows:
362
+ return {}
363
+ return {
364
+ "adaptive_display_sessions": sorted(set(display_sessions)),
365
+ "adaptive_overview_windows": sorted(set(windows)),
366
+ }
367
+
368
+
369
+ def _adaptive_window_orphans(leader_session: str, session_name: str) -> list[str]:
370
+ from team_agent.runtime import run_cmd
371
+ prefix = f"team-agent:{session_name}:overview"
372
+ proc = run_cmd(["tmux", "list-windows", "-t", leader_session, "-F", "#{window_name}"], timeout=10)
373
+ if proc.returncode != 0:
374
+ return []
375
+ return [
376
+ f"{leader_session}:{window_name}"
377
+ for window_name in proc.stdout.splitlines()
378
+ if window_name == prefix or window_name.startswith(f"{prefix}-")
379
+ ]
380
+
381
+
296
382
  def kill_adaptive_window(target: str) -> bool:
297
383
  from team_agent.runtime import run_cmd
298
384
  proc = run_cmd(["tmux", "kill-window", "-t", target], timeout=10)
@@ -8,9 +8,10 @@ from team_agent.display.ghostty import ghostty_pids_by_title
8
8
  from team_agent.display.workspace import kill_ghostty_workspace_linked_sessions
9
9
 
10
10
 
11
- def close_team_display_backends(state: dict[str, Any], event_log: EventLog) -> None:
12
- close_adaptive_display(state, event_log)
11
+ def close_team_display_backends(state: dict[str, Any], event_log: EventLog) -> dict[str, Any]:
12
+ result = close_adaptive_display(state, event_log)
13
13
  close_ghostty_workspace(state, event_log)
14
+ return result
14
15
 
15
16
 
16
17
  def close_ghostty_display(
@@ -316,7 +316,7 @@ def attach_leader_to_state(
316
316
  if not validation["ok"]:
317
317
  readopt = _try_readopt_leader_pane(workspace, state, receiver, pane_info, targets, owner_record, receiver_provider, source, event_log)
318
318
  if readopt is not None:
319
- return readopt
319
+ return readopt, {"ok": True, "pane": pane_info, "readopted": True, "warning": None}
320
320
  event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
321
321
  raise RuntimeError(_strict_leader_validation_error(validation))
322
322
  if validation.get("warning"):
@@ -346,6 +346,7 @@ def _set_tmux_leader_environment(receiver: dict[str, Any], identity: dict[str, A
346
346
  def _strict_leader_validation_error(validation: dict[str, Any]) -> str:
347
347
  return (
348
348
  f"leader pane validation failed: {validation['reason']}. "
349
+ "tmux leader pane validation could not bind the recorded pane. "
349
350
  "first quick-start uses cwd+command match only; this team already has team_owner "
350
351
  "so strict UUID gate applies; use team-agent takeover --confirm if you intend to take over"
351
352
  )
@@ -500,7 +501,7 @@ def _try_readopt_leader_pane(
500
501
  receiver_provider: str,
501
502
  source: str,
502
503
  event_log: EventLog,
503
- ) -> tuple[dict[str, Any], dict[str, Any]] | None:
504
+ ) -> dict[str, Any] | None:
504
505
  # C4/C11/C12: attach-leader converges on the lease claim. When the strict UUID
505
506
  # gate would refuse, re-adopt the pane instead IF it is a live workspace leader
506
507
  # (real injected uuid + cwd inside the workspace subtree) and the lease is either
@@ -509,20 +510,29 @@ def _try_readopt_leader_pane(
509
510
  from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
510
511
  target_list = targets.get("targets", []) if isinstance(targets, dict) and targets.get("ok") else []
511
512
  pane_target = next((item for item in target_list if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_info.get("pane_id"))), None)
512
- pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info)
513
- if not pane_uuid:
514
- return None
513
+ pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info) or str(owner_record.get("leader_session_uuid") or receiver.get("leader_session_uuid") or "")
515
514
  if not _cwd_inside_workspace(pane_info.get("pane_current_path"), workspace):
516
515
  return None
517
516
  if not _leader_command_looks_usable(str(pane_info.get("pane_current_command", "")), receiver_provider):
518
517
  return None
518
+ owner_pane = str(owner_record.get("pane_id") or "")
519
519
  owner_uuid = str(owner_record.get("leader_session_uuid") or "")
520
- if owner_uuid and owner_uuid != pane_uuid:
520
+ target_uuid = _target_leader_session_uuid(pane_target or {})
521
+ if owner_pane and owner_pane != str(pane_info.get("pane_id") or "") and (not owner_uuid or target_uuid != owner_uuid):
521
522
  return None
522
523
  epoch = _lease_epoch(owner_record, receiver) + (1 if owner_record else 0)
523
- receiver["leader_session_uuid"] = pane_uuid
524
- receiver["owner_epoch"] = epoch
525
- receiver["discovery"] = "attach_readopt"
524
+ receiver.update({
525
+ "pane_id": pane_info["pane_id"],
526
+ "session_name": pane_info.get("session_name"),
527
+ "window_index": pane_info.get("window_index"),
528
+ "window_name": pane_info.get("window_name"),
529
+ "pane_index": pane_info.get("pane_index"),
530
+ "pane_tty": pane_info.get("pane_tty"),
531
+ "pane_current_command": pane_info.get("pane_current_command"),
532
+ "leader_session_uuid": pane_uuid,
533
+ "owner_epoch": epoch,
534
+ "discovery": "attach_readopt",
535
+ })
526
536
  receiver.pop("warning", None)
527
537
  old_pane = owner_record.get("pane_id") or (state.get("leader_receiver") or {}).get("pane_id")
528
538
  state["team_owner"] = {
@@ -540,7 +550,7 @@ def _try_readopt_leader_pane(
540
550
  event_log.write("owner.adopted_on_restart", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
541
551
  event_log.write("leader_receiver.rebind_applied", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
542
552
  event_log.write("leader_receiver.attached", target=pane_info["pane_id"], session_name=pane_info.get("session_name"), provider=receiver.get("provider"), discovery="attach_readopt", source=source, owner_epoch=epoch, uuid_prefix=pane_uuid[:8])
543
- return receiver, {"ok": True, "pane": pane_info, "readopted": True, "warning": None}
553
+ return receiver
544
554
 
545
555
 
546
556
  def _detect_dual_state_divergence(workspace: Path, state: dict[str, Any]) -> dict[str, Any] | None: