@team-agent/installer 0.2.5 → 0.2.7

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.
@@ -4,6 +4,7 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import copy
7
+ import subprocess
7
8
  import uuid
8
9
  from datetime import datetime, timezone
9
10
  from pathlib import Path
@@ -51,14 +52,37 @@ def normalize_agent_session_state(state: dict[str, Any]) -> None:
51
52
  def load_runtime_state(workspace: Path) -> dict[str, Any]:
52
53
  path = runtime_state_path(workspace)
53
54
  if not path.exists():
54
- return {"agents": {}, "tasks": [], "session_name": None}
55
+ return {"agents": {}, "tasks": [], "session_name": None, "active_team_key": None}
55
56
  state = json.loads(path.read_text(encoding="utf-8"))
56
57
  normalize_agent_session_state(state)
57
- if _migrate_state_identity(state, workspace):
58
+ changed = _migrate_state_identity(state, workspace)
59
+ if _migrate_active_team_key(state):
60
+ changed = True
61
+ if changed:
58
62
  save_runtime_state(workspace, state)
59
63
  return state
60
64
 
61
65
 
66
+ def _migrate_active_team_key(state: dict[str, Any]) -> bool:
67
+ """0.2.6 Family B (C6): legacy states with a top-level ``session_name``
68
+ but no ``active_team_key`` get the active pointer seeded once. After
69
+ this, ``active_team_key`` is the single explicit source of truth and
70
+ callers mutate it through CLI verbs (claim-leader / takeover /
71
+ shutdown / restart)."""
72
+ if "active_team_key" in state:
73
+ return False
74
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
75
+ if state.get("session_name"):
76
+ seed = team_state_key(state)
77
+ state["active_team_key"] = seed if seed in teams or not teams else seed
78
+ return True
79
+ if isinstance(teams, dict) and len(teams) == 1:
80
+ state["active_team_key"] = next(iter(teams))
81
+ return True
82
+ state["active_team_key"] = None
83
+ return True
84
+
85
+
62
86
  def team_state_key(state: dict[str, Any]) -> str:
63
87
  for field in ("team_dir", "spec_path"):
64
88
  value = state.get(field)
@@ -98,58 +122,110 @@ def merge_workspace_team_state(existing: dict[str, Any], launched: dict[str, Any
98
122
 
99
123
 
100
124
  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
125
+ """0.2.6 Family B (C7): the only candidate source is ``state.teams``
126
+ filtered by ``status == "alive"``. Top-level ``session_name`` /
127
+ ``team_dir`` are a derived view of the active team and never count as
128
+ an independent candidate. Shutdown/legacy entries with non-alive
129
+ status are excluded."""
130
+ out: dict[str, dict[str, Any]] = {}
131
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
132
+ for key, value in teams.items():
133
+ if not isinstance(value, dict):
134
+ continue
135
+ if str(value.get("status") or "alive").lower() != "alive":
136
+ continue
137
+ out[str(key)] = value
138
+ return out
110
139
 
111
140
 
112
- def format_team_candidates(candidates: dict[str, dict[str, Any]]) -> str:
113
- if not candidates:
141
+ def format_team_candidates(team_states: dict[str, dict[str, Any]]) -> str:
142
+ if not team_states:
114
143
  return "No team state was found."
115
144
  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}")
145
+ for key in sorted(team_states):
146
+ st = team_states[key]
147
+ agents = ",".join(sorted(st.get("agents", {}).keys())) or "-"
148
+ parts.append(f"{key} session={st.get('session_name') or '-'} agents={agents}")
119
149
  return "Candidates: " + "; ".join(parts)
120
150
 
121
151
 
152
+ def _team_entry_from_state(state: dict[str, Any], team_key: str) -> dict[str, Any] | None:
153
+ teams = state.get("teams") if isinstance(state.get("teams"), dict) else {}
154
+ entry = teams.get(team_key)
155
+ if not isinstance(entry, dict):
156
+ return None
157
+ return entry
158
+
159
+
160
+ def _project_top_level_view(state: dict[str, Any], team_key: str) -> dict[str, Any]:
161
+ """0.2.6 Family B (C8): when picking a team for use, the top-level
162
+ keys (``session_name`` / ``team_dir`` / ``agents`` / ``tasks``) are a
163
+ derived view of ``teams[team_key]``. We copy the team entry into a
164
+ flat dict and preserve any auxiliary state (``team_owner`` /
165
+ ``leader_receiver`` / ``coordinator`` already pinned to the team)."""
166
+ entry = _team_entry_from_state(state, team_key) or {}
167
+ projection = copy.deepcopy(entry)
168
+ projection.setdefault("session_name", entry.get("session_name"))
169
+ projection.setdefault("team_dir", entry.get("team_dir"))
170
+ projection["active_team_key"] = team_key
171
+ # Preserve the full teams dict so consumers can introspect siblings.
172
+ projection["teams"] = copy.deepcopy(state.get("teams") or {})
173
+ if "team_owner" in entry:
174
+ projection["team_owner"] = copy.deepcopy(entry["team_owner"])
175
+ elif state.get("team_owner") is not None:
176
+ projection["team_owner"] = copy.deepcopy(state["team_owner"])
177
+ if "leader_receiver" in entry:
178
+ projection["leader_receiver"] = copy.deepcopy(entry["leader_receiver"])
179
+ elif state.get("leader_receiver") is not None:
180
+ projection["leader_receiver"] = copy.deepcopy(state["leader_receiver"])
181
+ if "coordinator" in state:
182
+ projection.setdefault("coordinator", copy.deepcopy(state["coordinator"]))
183
+ return projection
184
+
185
+
122
186
  def select_runtime_state(workspace: Path, team: str | None = None) -> dict[str, Any]:
123
187
  state = load_runtime_state(workspace)
124
- candidates = team_state_candidates(state)
188
+ alive = team_state_candidates(state)
125
189
  if team:
126
190
  matches = [
127
- value
128
- for key, value in candidates.items()
191
+ (key, value)
192
+ for key, value in alive.items()
129
193
  if team in {key, str(value.get("session_name") or ""), str(value.get("team_dir") or "")}
130
194
  ]
131
195
  if len(matches) == 1:
132
- return copy.deepcopy(matches[0])
196
+ return _project_top_level_view(state, matches[0][0])
133
197
  from team_agent.errors import RuntimeError
134
198
  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)
199
+ raise RuntimeError("team selector is ambiguous. " + format_team_candidates(alive))
200
+ raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(alive))
201
+ active = state.get("active_team_key")
202
+ if active and active in alive:
203
+ return _project_top_level_view(state, str(active))
204
+ if len(alive) == 1:
205
+ return _project_top_level_view(state, next(iter(alive)))
206
+ if not alive:
207
+ return copy.deepcopy(state)
208
+ from team_agent.errors import RuntimeError
209
+ raise RuntimeError(
210
+ "multiple teams found in this workspace; pass --team <team> to choose. "
211
+ + format_team_candidates(alive)
212
+ )
141
213
 
142
214
 
143
215
  def ambiguous_team_target_result(state: dict[str, Any]) -> dict[str, Any] | None:
144
- candidates = team_state_candidates(state)
145
- if len(candidates) <= 1:
216
+ alive = team_state_candidates(state)
217
+ active = state.get("active_team_key")
218
+ if active and active in alive:
219
+ return None
220
+ if len(alive) <= 1:
146
221
  return None
147
222
  return {
148
223
  "ok": False,
149
224
  "status": "refused",
150
225
  "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),
226
+ "candidates": sorted(alive.keys()),
227
+ "message": "multiple teams found in this workspace; pass --team <team> to choose. "
228
+ + format_team_candidates(alive),
153
229
  }
154
230
 
155
231
 
@@ -238,7 +314,7 @@ def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str
238
314
  team_id or os.environ.get("TEAM_AGENT_TEAM_ID") or team_state_key(state),
239
315
  )
240
316
  return {
241
- "pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or "",
317
+ "pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or "",
242
318
  "provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER") or "",
243
319
  "machine_fingerprint": machine_fingerprint,
244
320
  "leader_session_uuid": leader_uuid,
@@ -246,6 +322,36 @@ def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str
246
322
  }
247
323
 
248
324
 
325
+ _TMUX_PANE_LIVE = "live"
326
+ _TMUX_PANE_DEAD = "dead"
327
+ _TMUX_PANE_UNKNOWN = "unknown"
328
+
329
+
330
+ def _tmux_pane_liveness(pane_id: str) -> str:
331
+ if not pane_id:
332
+ return _TMUX_PANE_UNKNOWN
333
+ try:
334
+ from team_agent.runtime import run_cmd
335
+ proc = run_cmd(["tmux", "display-message", "-p", "-t", pane_id, "#{pane_id}"], timeout=3)
336
+ except Exception:
337
+ try:
338
+ proc = subprocess.run(
339
+ ["tmux", "display-message", "-p", "-t", pane_id, "#{pane_id}"],
340
+ text=True,
341
+ capture_output=True,
342
+ timeout=3,
343
+ check=False,
344
+ )
345
+ except Exception:
346
+ return _TMUX_PANE_UNKNOWN
347
+ if proc.returncode == 0:
348
+ return _TMUX_PANE_LIVE
349
+ stderr = str(getattr(proc, "stderr", "") or "").lower()
350
+ if "can't find pane" in stderr or "can't find window" in stderr or "can't find session" in stderr:
351
+ return _TMUX_PANE_DEAD
352
+ return _TMUX_PANE_UNKNOWN
353
+
354
+
249
355
  def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
250
356
  owner = state.get("team_owner") or {}
251
357
  if not owner:
@@ -256,6 +362,15 @@ def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
256
362
  caller_uuid = caller["leader_session_uuid"]
257
363
  owner_pane = str(owner.get("pane_id") or "")
258
364
  caller_pane = caller.get("pane_id") or ""
365
+ if caller_pane and caller_pane == owner_pane:
366
+ return None
367
+ if (
368
+ caller_pane
369
+ and not os.environ.get("TEAM_AGENT_ID")
370
+ and owner_pane
371
+ and _tmux_pane_liveness(owner_pane) != _TMUX_PANE_LIVE
372
+ ):
373
+ return None
259
374
  if caller_uuid == owner_uuid and (not caller_pane or caller_pane == owner_pane):
260
375
  return None
261
376
  same_uuid = caller_uuid == owner_uuid