@team-agent/installer 0.2.4 → 0.2.6

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.
@@ -70,7 +70,7 @@ from team_agent.leader import (
70
70
  LEADER_OWNERSHIP_LOCK,
71
71
  attach_leader,
72
72
  attach_leader_to_state as _attach_leader_to_state,
73
- claim_leader,
73
+ claim_leader as _legacy_claim_leader,
74
74
  leader_identity,
75
75
  leader_session_name as _leader_session_name,
76
76
  leader_start_plan,
@@ -137,7 +137,7 @@ from team_agent.diagnose import (
137
137
  preflight_next_actions as _preflight_next_actions,
138
138
  profile_checks_for_agents as _profile_checks_for_agents,
139
139
  profile_smoke_checks_for_agents as _profile_smoke_checks_for_agents,
140
- quick_start,
140
+ quick_start as _legacy_quick_start,
141
141
  repair_state,
142
142
  settle,
143
143
  start,
@@ -491,6 +491,11 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
491
491
  if gate:
492
492
  return gate
493
493
  session_name = state.get("session_name")
494
+ resolved_team_id = (
495
+ team
496
+ or state.get("active_team_key")
497
+ or (team_state_key(state) if state.get("session_name") else None)
498
+ )
494
499
  event_log = EventLog(workspace)
495
500
  captured: list[str] = []
496
501
  closed_displays: set[str] = set()
@@ -561,7 +566,72 @@ def shutdown(workspace: Path, keep_logs: bool = True, team: str | None = None) -
561
566
  agent_state["status"] = "stopped"
562
567
  save_team_scoped_state(workspace, state)
563
568
  _save_team_runtime_snapshot(workspace, state)
564
- return {"ok": True, "session_name": session_name, "logs": captured, "coordinator": coordinator}
569
+ # 0.2.6 Family B (C10/C11/C12): atomically unregister the team and
570
+ # archive its runtime snapshot directory. Both branches of --keep-logs
571
+ # still drop the team from state.teams; logs survive inside the
572
+ # archived directory.
573
+ archive_path, teams_remaining, new_active = _commit_shutdown_cleanup(
574
+ workspace, str(resolved_team_id or ""), session_name, event_log
575
+ )
576
+ return {
577
+ "ok": True,
578
+ "session_name": session_name,
579
+ "team": resolved_team_id,
580
+ "logs": captured,
581
+ "coordinator": coordinator,
582
+ "archive_path": archive_path,
583
+ "teams_remaining": teams_remaining,
584
+ "new_active_team_key": new_active,
585
+ "cleanup_mode": "synchronous_committed",
586
+ }
587
+
588
+
589
+ def _commit_shutdown_cleanup(
590
+ workspace: Path,
591
+ team_key: str,
592
+ session_name: str | None,
593
+ event_log: EventLog,
594
+ ) -> tuple[str | None, list[str], str | None]:
595
+ import shutil as _shutil
596
+ from datetime import datetime as _dt, timezone as _tz
597
+ workspace_state = load_runtime_state(workspace)
598
+ teams = workspace_state.get("teams") if isinstance(workspace_state.get("teams"), dict) else {}
599
+ if team_key and team_key in teams:
600
+ teams.pop(team_key, None)
601
+ if workspace_state.get("active_team_key") == team_key:
602
+ workspace_state["active_team_key"] = None
603
+ workspace_state["teams"] = teams
604
+ archive_dest: Path | None = None
605
+ if session_name:
606
+ runtime_teams_dir = runtime_dir(workspace) / "teams"
607
+ from team_agent.restart.snapshot import safe_snapshot_name as _safe
608
+ snapshot_name = _safe(str(session_name))
609
+ snapshot_dir = runtime_teams_dir / snapshot_name
610
+ if snapshot_dir.exists():
611
+ ts = _dt.now(_tz.utc).strftime("%Y%m%dT%H%M%SZ")
612
+ archive_dest = runtime_teams_dir / f".archived-{snapshot_name}-{ts}"
613
+ try:
614
+ _shutil.move(str(snapshot_dir), str(archive_dest))
615
+ except OSError as exc:
616
+ event_log.write(
617
+ "team.shutdown_blocked",
618
+ reason="archive_move_failed",
619
+ team_key=team_key,
620
+ error=str(exc),
621
+ hint="check filesystem permissions on .team/runtime/teams and rerun shutdown",
622
+ )
623
+ return None, sorted(teams.keys()), workspace_state.get("active_team_key")
624
+ save_runtime_state(workspace, workspace_state)
625
+ archive_path_str = str(archive_dest) if archive_dest is not None else None
626
+ new_active = workspace_state.get("active_team_key")
627
+ event_log.write(
628
+ "team.shutdown_completed",
629
+ team_key=team_key,
630
+ archive_path=archive_path_str,
631
+ teams_remaining=sorted(teams.keys()),
632
+ new_active_team_key=new_active,
633
+ )
634
+ return archive_path_str, sorted(teams.keys()), new_active
565
635
 
566
636
 
567
637
 
@@ -602,7 +672,44 @@ def acknowledge_idle(workspace: Path, agent_id: str | None = None, *, team: str
602
672
  EventLog(workspace).write("coordinator.idle_acknowledged", agent_id=agent_id, team=owner_team_id, acknowledged_at=now, expires_at=expires_at, ttl_seconds=ttl_seconds)
603
673
  return {"ok": True, "team": owner_team_id, "agent_id": agent_id, "acknowledged_at": now, "expires_at": expires_at, "ttl_seconds": ttl_seconds}
604
674
 
675
+ _OWNER_IDENTITY_FIELDS = (
676
+ "pane_id",
677
+ "leader_session_uuid",
678
+ "machine_fingerprint",
679
+ "provider",
680
+ "os_user",
681
+ )
682
+
683
+
684
+ def _owner_identity_matches(existing: dict[str, Any], candidate: dict[str, Any]) -> bool:
685
+ for field in _OWNER_IDENTITY_FIELDS:
686
+ if str(existing.get(field) or "") != str(candidate.get(field) or ""):
687
+ return False
688
+ return True
689
+
690
+
691
+ def _resolve_owner_team_id(state: dict[str, Any], team: str | None) -> str | None:
692
+ if team:
693
+ return str(team)
694
+ active = state.get("active_team_key")
695
+ if active:
696
+ return str(active)
697
+ teams = state.get("teams") or {}
698
+ if isinstance(teams, dict) and len(teams) == 1:
699
+ return next(iter(teams))
700
+ return None
701
+
702
+
605
703
  def takeover(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
704
+ """0.2.6 Family A: positive-source ownership rebind.
705
+
706
+ Identity is sourced exclusively from ``bind_owner_from_caller_pane``
707
+ (``$TMUX_PANE`` + one targeted ``tmux display-message``). The new
708
+ owner record force-writes every identity field into
709
+ ``state.teams[<team_id>].team_owner``; old fields are not merged,
710
+ migrated, or setdefaulted. Idempotent: re-running with the same
711
+ caller identity returns success without mutating state.
712
+ """
606
713
  if not confirm:
607
714
  return {
608
715
  "ok": False,
@@ -610,91 +717,151 @@ def takeover(workspace: Path, team: str | None = None, confirm: bool = False) ->
610
717
  "reason": "confirm_required",
611
718
  "action": "rerun with --confirm to claim ownership of this team",
612
719
  }
613
- pane_id = os.environ.get("TEAM_AGENT_LEADER_PANE_ID")
614
- if not pane_id:
615
- return {
616
- "ok": False,
617
- "status": "refused",
618
- "reason": "no_caller_identity",
619
- "action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT or run from a tmux pane",
620
- }
720
+ from team_agent.leader_binding import (
721
+ bind_owner_from_caller_pane,
722
+ emit_owner_bound_event,
723
+ )
621
724
  with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
622
- try:
623
- team_state = select_runtime_state(workspace, team)
624
- except RuntimeError as exc:
725
+ state = load_runtime_state(workspace)
726
+ team_id = _resolve_owner_team_id(state, team)
727
+ if not team_id:
625
728
  return {
626
729
  "ok": False,
627
730
  "status": "refused",
628
731
  "reason": "team_target_unresolved",
629
732
  "team": team,
630
- "error": str(exc),
733
+ "hint": "pass --team <name> or run quick-start first to register an active team.",
734
+ }
735
+ bind = bind_owner_from_caller_pane(workspace, team_id)
736
+ if not bind.get("ok"):
737
+ return {"ok": False, "status": "refused", **bind}
738
+ new_owner = bind["owner"]
739
+ teams = state.setdefault("teams", {})
740
+ team_entry = teams.get(team_id) or {}
741
+ existing_owner = team_entry.get("team_owner") if isinstance(team_entry.get("team_owner"), dict) else {}
742
+ if existing_owner and _owner_identity_matches(existing_owner, new_owner):
743
+ return {
744
+ "ok": True,
745
+ "status": "claimed",
746
+ "team": team_id,
747
+ "team_owner": existing_owner,
748
+ "idempotent": True,
631
749
  }
632
- previous_owner = team_state.get("team_owner") if isinstance(team_state.get("team_owner"), dict) else {}
633
- previous_receiver = team_state.get("leader_receiver") if isinstance(team_state.get("leader_receiver"), dict) else {}
634
- from team_agent.leader import _lease_epoch, _receiver_from_claim_target
635
- next_epoch = _lease_epoch(previous_owner, previous_receiver) + 1
636
- leader_uuid = str(previous_owner.get("leader_session_uuid") or "")
637
- new_owner = {
638
- "pane_id": pane_id,
639
- "provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER", ""),
640
- "machine_fingerprint": os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT", ""),
641
- "owner_epoch": next_epoch,
642
- "claimed_at": datetime.now(timezone.utc).isoformat(),
643
- "claimed_via": "takeover",
750
+ team_entry["team_owner"] = new_owner
751
+ teams[team_id] = team_entry
752
+ save_runtime_state(workspace, state)
753
+ emit_owner_bound_event(
754
+ workspace,
755
+ caller_pane_id=bind.get("caller_pane_id", ""),
756
+ caller_current_command=bind.get("caller_current_command", ""),
757
+ derived_leader_session_uuid=new_owner["leader_session_uuid"],
758
+ team_id=team_id,
759
+ old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
760
+ )
761
+ return {
762
+ "ok": True,
763
+ "status": "claimed",
764
+ "team": team_id,
765
+ "team_owner": new_owner,
766
+ "previous_owner": existing_owner or None,
644
767
  }
645
- if leader_uuid:
646
- new_owner["leader_session_uuid"] = leader_uuid
647
- team_state["team_owner"] = new_owner
648
- # C11/C17: takeover converges on the same lease mutation as claim-leader.
649
- # Rebind the leader receiver to the caller pane and write owner + receiver
650
- # to both state locations together, so takeover never leaves the receiver
651
- # pointing at the old (often dead) pane.
652
- targets_result = core_list_targets()
653
- targets = targets_result.get("targets", []) if isinstance(targets_result, dict) and targets_result.get("ok") else []
654
- caller_target = next((item for item in targets if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_id)), None)
655
- new_receiver = None
656
- if caller_target:
657
- new_receiver = _receiver_from_claim_target(
658
- caller_target,
659
- previous_receiver,
660
- leader_uuid or None,
661
- next_epoch,
662
- )
663
- new_receiver["discovery"] = "takeover"
664
- team_state["leader_receiver"] = new_receiver
665
- from team_agent.leader import _write_lease_dual_state
666
- _write_lease_dual_state(workspace, team_state)
667
- # C11: takeover converges on the same lease audit events as claim-leader
668
- # instead of a divergent legacy team_owner.takeover record.
669
- event_log = EventLog(workspace)
670
- uuid_prefix = leader_uuid[:8]
671
- old_pane_id = previous_receiver.get("pane_id") or (previous_owner or {}).get("pane_id")
672
- if new_receiver is not None:
673
- event_log.write(
674
- "leader_receiver.rebind_applied",
675
- reason="takeover_confirmed",
676
- old_pane_id=old_pane_id,
677
- new_pane_id=pane_id,
678
- owner_epoch=next_epoch,
679
- uuid_prefix=uuid_prefix,
680
- team_id=team,
681
- )
682
- event_log.write(
683
- "owner_epoch_advanced",
684
- reason="takeover_confirmed",
685
- old_pane_id=old_pane_id,
686
- new_pane_id=pane_id,
687
- owner_epoch=next_epoch,
688
- uuid_prefix=uuid_prefix,
689
- team_id=team,
690
- previous_owner=previous_owner or None,
691
- new_owner=new_owner,
692
- receiver_rebound=bool(new_receiver),
768
+
769
+
770
+ def claim_leader(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
771
+ """0.2.6 Family A: positive-source claim-leader.
772
+
773
+ Calls :func:`bind_owner_from_caller_pane` to confirm the caller is in
774
+ a leader-shaped tmux pane, then delegates to the legacy multi-
775
+ candidate lease arbiter for residual handling. The bind step is the
776
+ only source of caller identity; the legacy lease path no longer
777
+ re-derives it.
778
+ """
779
+ from team_agent.leader_binding import bind_owner_from_caller_pane
780
+ state = load_runtime_state(workspace)
781
+ team_id = _resolve_owner_team_id(state, team) or team_state_key(state)
782
+ bind = bind_owner_from_caller_pane(workspace, team_id)
783
+ if not bind.get("ok"):
784
+ return {"ok": False, "status": "refused", **bind}
785
+ return _legacy_claim_leader(workspace, team=team, confirm=confirm)
786
+
787
+
788
+ def quick_start(
789
+ agents_dir: Path,
790
+ name: str | None = None,
791
+ yes: bool = False,
792
+ fresh: bool = False,
793
+ team_id: str | None = None,
794
+ ) -> dict[str, Any]:
795
+ """0.2.6 Family A: positive-source quick-start.
796
+
797
+ The caller-pane shape gate is owned by
798
+ :func:`bind_owner_from_caller_pane`. Quick-start binds the caller
799
+ pane BEFORE any team setup runs; ``$TMUX_PANE`` missing or the
800
+ caller pane not running a leader host short-circuits to a refusal
801
+ (no fallback to legacy reverse-scan). On success, the legacy
802
+ bootstrap brings up the workspace and the bind-derived
803
+ ``team_owner`` is force-written into
804
+ ``state.teams[team_id].team_owner`` so the runtime owner identity
805
+ matches the caller pane verbatim.
806
+ """
807
+ from team_agent.leader_binding import (
808
+ bind_owner_from_caller_pane,
809
+ emit_owner_bound_event,
810
+ )
811
+ from team_agent.diagnose.quick_start import prepare_quick_start_team
812
+
813
+ # Pre-resolve team_dir + workspace so the caller-pane bind can write
814
+ # its audit event before any worker is spawned. ``prepare_quick_start_team``
815
+ # is idempotent (mkdir + shutil.copy2 of role docs) and used inside
816
+ # ``_legacy_quick_start`` as the very first step anyway.
817
+ team_dir = prepare_quick_start_team(
818
+ Path(agents_dir).resolve(), Path.cwd().resolve(), name, team_id=team_id
819
+ )
820
+ workspace = team_workspace(team_dir)
821
+ ensure_workspace_dirs(workspace)
822
+ # Spark MED 1 (b1b17b1 review): the on-disk team_dir already passed
823
+ # through ``_safe_snapshot_name``; reusing ``team_dir.name`` here
824
+ # keeps the on-disk path and the state.teams key aligned. Using the
825
+ # raw ``team_id`` would have split the two writes whenever the
826
+ # caller-supplied id contained spaces or shell-unsafe characters.
827
+ resolved_team_id = team_dir.name or "current"
828
+ bind = bind_owner_from_caller_pane(workspace, resolved_team_id)
829
+ if not bind.get("ok"):
830
+ return {"ok": False, "status": "refused", **bind}
831
+ new_owner = bind["owner"]
832
+ result = _legacy_quick_start(
833
+ Path(agents_dir).resolve(), name=name, yes=yes, fresh=fresh, team_id=team_id
834
+ )
835
+ # Spark MED 2 (b1b17b1 review): only commit the owner force-write
836
+ # and emit ``owner.bound_from_caller_pane`` when the legacy bootstrap
837
+ # actually succeeded. Otherwise pass the refusal envelope back
838
+ # verbatim — ``existing_runtime_state`` / ``preflight`` failures
839
+ # must not leave a "team owner claimed" side effect behind.
840
+ if not result.get("ok"):
841
+ return result
842
+ state = load_runtime_state(workspace)
843
+ teams = state.setdefault("teams", {})
844
+ team_entry = teams.get(resolved_team_id) or {}
845
+ existing_owner = (
846
+ team_entry.get("team_owner")
847
+ if isinstance(team_entry.get("team_owner"), dict)
848
+ else {}
849
+ )
850
+ if not (existing_owner and _owner_identity_matches(existing_owner, new_owner)):
851
+ team_entry["team_owner"] = new_owner
852
+ teams[resolved_team_id] = team_entry
853
+ if not state.get("active_team_key"):
854
+ state["active_team_key"] = resolved_team_id
855
+ save_runtime_state(workspace, state)
856
+ emit_owner_bound_event(
857
+ workspace,
858
+ caller_pane_id=bind.get("caller_pane_id", ""),
859
+ caller_current_command=bind.get("caller_current_command", ""),
860
+ derived_leader_session_uuid=new_owner["leader_session_uuid"],
861
+ team_id=resolved_team_id,
862
+ old_leader_session_uuid=str(existing_owner.get("leader_session_uuid") or ""),
693
863
  )
694
- response = {"ok": True, "status": "claimed", "team": team, "team_owner": new_owner, "previous_owner": previous_owner or None, "owner_epoch": next_epoch}
695
- if new_receiver is not None:
696
- response["leader_receiver"] = new_receiver
697
- return response
864
+ return result
698
865
 
699
866
 
700
867
  def _running_agent_state(workspace: Path, agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
@@ -51,14 +51,37 @@ def normalize_agent_session_state(state: dict[str, Any]) -> None:
51
51
  def load_runtime_state(workspace: Path) -> dict[str, Any]:
52
52
  path = runtime_state_path(workspace)
53
53
  if not path.exists():
54
- return {"agents": {}, "tasks": [], "session_name": None}
54
+ return {"agents": {}, "tasks": [], "session_name": None, "active_team_key": None}
55
55
  state = json.loads(path.read_text(encoding="utf-8"))
56
56
  normalize_agent_session_state(state)
57
- if _migrate_state_identity(state, workspace):
57
+ changed = _migrate_state_identity(state, workspace)
58
+ if _migrate_active_team_key(state):
59
+ changed = True
60
+ if changed:
58
61
  save_runtime_state(workspace, state)
59
62
  return state
60
63
 
61
64
 
65
+ def _migrate_active_team_key(state: dict[str, Any]) -> bool:
66
+ """0.2.6 Family B (C6): legacy states with a top-level ``session_name``
67
+ but no ``active_team_key`` get the active pointer seeded once. After
68
+ this, ``active_team_key`` is the single explicit source of truth and
69
+ callers mutate it through CLI verbs (claim-leader / takeover /
70
+ shutdown / restart)."""
71
+ if "active_team_key" in state:
72
+ return False
73
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
74
+ if state.get("session_name"):
75
+ seed = team_state_key(state)
76
+ state["active_team_key"] = seed if seed in teams or not teams else seed
77
+ return True
78
+ if isinstance(teams, dict) and len(teams) == 1:
79
+ state["active_team_key"] = next(iter(teams))
80
+ return True
81
+ state["active_team_key"] = None
82
+ return True
83
+
84
+
62
85
  def team_state_key(state: dict[str, Any]) -> str:
63
86
  for field in ("team_dir", "spec_path"):
64
87
  value = state.get(field)
@@ -98,58 +121,110 @@ def merge_workspace_team_state(existing: dict[str, Any], launched: dict[str, Any
98
121
 
99
122
 
100
123
  def team_state_candidates(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
101
- candidates: dict[str, dict[str, Any]] = {}
102
- teams = state.get("teams")
103
- if isinstance(teams, dict):
104
- for key, value in teams.items():
105
- if isinstance(value, dict):
106
- candidates[str(key)] = value
107
- if state.get("session_name"):
108
- candidates.setdefault(team_state_key(state), compact_team_state(state))
109
- return candidates
124
+ """0.2.6 Family B (C7): the only candidate source is ``state.teams``
125
+ filtered by ``status == "alive"``. Top-level ``session_name`` /
126
+ ``team_dir`` are a derived view of the active team and never count as
127
+ an independent candidate. Shutdown/legacy entries with non-alive
128
+ status are excluded."""
129
+ out: dict[str, dict[str, Any]] = {}
130
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
131
+ for key, value in teams.items():
132
+ if not isinstance(value, dict):
133
+ continue
134
+ if str(value.get("status") or "alive").lower() != "alive":
135
+ continue
136
+ out[str(key)] = value
137
+ return out
110
138
 
111
139
 
112
- def format_team_candidates(candidates: dict[str, dict[str, Any]]) -> str:
113
- if not candidates:
140
+ def format_team_candidates(team_states: dict[str, dict[str, Any]]) -> str:
141
+ if not team_states:
114
142
  return "No team state was found."
115
143
  parts = []
116
- for key, state in sorted(candidates.items()):
117
- agents = ",".join(sorted(state.get("agents", {}).keys())) or "-"
118
- parts.append(f"{key} session={state.get('session_name') or '-'} agents={agents}")
144
+ for key in sorted(team_states):
145
+ st = team_states[key]
146
+ agents = ",".join(sorted(st.get("agents", {}).keys())) or "-"
147
+ parts.append(f"{key} session={st.get('session_name') or '-'} agents={agents}")
119
148
  return "Candidates: " + "; ".join(parts)
120
149
 
121
150
 
151
+ def _team_entry_from_state(state: dict[str, Any], team_key: str) -> dict[str, Any] | None:
152
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
153
+ entry = teams.get(team_key)
154
+ if not isinstance(entry, dict):
155
+ return None
156
+ return entry
157
+
158
+
159
+ def _project_top_level_view(state: dict[str, Any], team_key: str) -> dict[str, Any]:
160
+ """0.2.6 Family B (C8): when picking a team for use, the top-level
161
+ keys (``session_name`` / ``team_dir`` / ``agents`` / ``tasks``) are a
162
+ derived view of ``teams[team_key]``. We copy the team entry into a
163
+ flat dict and preserve any auxiliary state (``team_owner`` /
164
+ ``leader_receiver`` / ``coordinator`` already pinned to the team)."""
165
+ entry = _team_entry_from_state(state, team_key) or {}
166
+ projection = copy.deepcopy(entry)
167
+ projection.setdefault("session_name", entry.get("session_name"))
168
+ projection.setdefault("team_dir", entry.get("team_dir"))
169
+ projection["active_team_key"] = team_key
170
+ # Preserve the full teams dict so consumers can introspect siblings.
171
+ projection["teams"] = copy.deepcopy(state.get("teams") or {})
172
+ if "team_owner" in entry:
173
+ projection["team_owner"] = copy.deepcopy(entry["team_owner"])
174
+ elif state.get("team_owner") is not None:
175
+ projection["team_owner"] = copy.deepcopy(state["team_owner"])
176
+ if "leader_receiver" in entry:
177
+ projection["leader_receiver"] = copy.deepcopy(entry["leader_receiver"])
178
+ elif state.get("leader_receiver") is not None:
179
+ projection["leader_receiver"] = copy.deepcopy(state["leader_receiver"])
180
+ if "coordinator" in state:
181
+ projection.setdefault("coordinator", copy.deepcopy(state["coordinator"]))
182
+ return projection
183
+
184
+
122
185
  def select_runtime_state(workspace: Path, team: str | None = None) -> dict[str, Any]:
123
186
  state = load_runtime_state(workspace)
124
- candidates = team_state_candidates(state)
187
+ alive = team_state_candidates(state)
125
188
  if team:
126
189
  matches = [
127
- value
128
- for key, value in candidates.items()
190
+ (key, value)
191
+ for key, value in alive.items()
129
192
  if team in {key, str(value.get("session_name") or ""), str(value.get("team_dir") or "")}
130
193
  ]
131
194
  if len(matches) == 1:
132
- return copy.deepcopy(matches[0])
195
+ return _project_top_level_view(state, matches[0][0])
133
196
  from team_agent.errors import RuntimeError
134
197
  if len(matches) > 1:
135
- raise RuntimeError("team selector is ambiguous. " + format_team_candidates(candidates))
136
- raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(candidates))
137
- if len(candidates) > 1:
138
- from team_agent.errors import RuntimeError
139
- raise RuntimeError("multiple teams found in this workspace; pass --team <team> to choose. " + format_team_candidates(candidates))
140
- return copy.deepcopy(state)
198
+ raise RuntimeError("team selector is ambiguous. " + format_team_candidates(alive))
199
+ raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(alive))
200
+ active = state.get("active_team_key")
201
+ if active and active in alive:
202
+ return _project_top_level_view(state, str(active))
203
+ if len(alive) == 1:
204
+ return _project_top_level_view(state, next(iter(alive)))
205
+ if not alive:
206
+ return copy.deepcopy(state)
207
+ from team_agent.errors import RuntimeError
208
+ raise RuntimeError(
209
+ "multiple teams found in this workspace; pass --team <team> to choose. "
210
+ + format_team_candidates(alive)
211
+ )
141
212
 
142
213
 
143
214
  def ambiguous_team_target_result(state: dict[str, Any]) -> dict[str, Any] | None:
144
- candidates = team_state_candidates(state)
145
- if len(candidates) <= 1:
215
+ alive = team_state_candidates(state)
216
+ active = state.get("active_team_key")
217
+ if active and active in alive:
218
+ return None
219
+ if len(alive) <= 1:
146
220
  return None
147
221
  return {
148
222
  "ok": False,
149
223
  "status": "refused",
150
224
  "reason": "team_target_ambiguous",
151
- "candidates": sorted(candidates),
152
- "message": "multiple teams found in this workspace; pass --team <team> to choose. " + format_team_candidates(candidates),
225
+ "candidates": sorted(alive.keys()),
226
+ "message": "multiple teams found in this workspace; pass --team <team> to choose. "
227
+ + format_team_candidates(alive),
153
228
  }
154
229
 
155
230