@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,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
5
6
  import copy
@@ -23,6 +24,14 @@ SESSION_STATE_FIELDS = [
23
24
  *SESSION_CAPTURE_FIELDS,
24
25
  "spawn_cwd",
25
26
  ]
27
+ _UUID_SEPARATOR = "\0"
28
+
29
+
30
+ def derive_leader_session_uuid(machine_fingerprint: str, workspace_abspath: str, os_user: str, team_id: str) -> str:
31
+ parts = [machine_fingerprint, workspace_abspath, os_user, team_id]
32
+ if any(_UUID_SEPARATOR in part for part in parts):
33
+ raise ValueError("leader_session_uuid inputs must not contain NUL")
34
+ return hashlib.sha256(_UUID_SEPARATOR.join(parts).encode("utf-8")).hexdigest()[:32]
26
35
 
27
36
 
28
37
  def runtime_state_path(workspace: Path) -> Path:
@@ -45,6 +54,8 @@ def load_runtime_state(workspace: Path) -> dict[str, Any]:
45
54
  return {"agents": {}, "tasks": [], "session_name": None}
46
55
  state = json.loads(path.read_text(encoding="utf-8"))
47
56
  normalize_agent_session_state(state)
57
+ if _migrate_state_identity(state, workspace):
58
+ save_runtime_state(workspace, state)
48
59
  return state
49
60
 
50
61
 
@@ -163,11 +174,75 @@ def resolve_team_scoped_state(
163
174
  }
164
175
 
165
176
 
166
- def _caller_identity_from_env() -> dict[str, str]:
177
+ def _identity_workspace_abspath(state: dict[str, Any], workspace: Path | None = None) -> str:
178
+ if state.get("workspace"):
179
+ return str(Path(str(state["workspace"])).resolve())
180
+ if state.get("team_dir"):
181
+ return str(Path(str(state["team_dir"])).resolve().parent.parent)
182
+ if state.get("spec_path"):
183
+ spec_path = Path(str(state["spec_path"])).resolve()
184
+ return str(spec_path.parent.parent.parent if spec_path.parent.parent.name == ".team" else spec_path.parent)
185
+ return str((workspace or Path(os.environ.get("TEAM_AGENT_WORKSPACE") or os.getcwd())).resolve())
186
+
187
+
188
+ def _identity_os_user() -> str:
189
+ return os.environ.get("USER") or os.environ.get("USERNAME") or ""
190
+
191
+
192
+ def _identity_machine_fingerprint(state: dict[str, Any]) -> str:
193
+ for record in (state.get("team_owner"), state.get("leader_receiver")):
194
+ if isinstance(record, dict) and record.get("machine_fingerprint"):
195
+ return str(record["machine_fingerprint"])
196
+ return os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
197
+
198
+
199
+ def _leader_session_uuid_for_state(state: dict[str, Any], workspace: Path | None = None, team_id: str | None = None) -> str:
200
+ return derive_leader_session_uuid(
201
+ _identity_machine_fingerprint(state),
202
+ _identity_workspace_abspath(state, workspace),
203
+ _identity_os_user(),
204
+ team_id or team_state_key(state),
205
+ )
206
+
207
+
208
+ def _migrate_team_identity(state: dict[str, Any], workspace: Path, team_id: str | None = None) -> bool:
209
+ leader_uuid = _leader_session_uuid_for_state(state, workspace, team_id)
210
+ changed = False
211
+ for key in ("team_owner", "leader_receiver"):
212
+ record = state.get(key)
213
+ if isinstance(record, dict) and not record.get("leader_session_uuid"):
214
+ record["leader_session_uuid"] = leader_uuid
215
+ changed = True
216
+ return changed
217
+
218
+
219
+ def _migrate_state_identity(state: dict[str, Any], workspace: Path) -> bool:
220
+ changed = _migrate_team_identity(state, workspace) if state.get("session_name") else False
221
+ teams = state.get("teams")
222
+ if isinstance(teams, dict):
223
+ for team_id, team_state in teams.items():
224
+ if isinstance(team_state, dict):
225
+ changed = _migrate_team_identity(team_state, workspace, str(team_id)) or changed
226
+ return changed
227
+
228
+
229
+ def _caller_identity_from_env(state: dict[str, Any] | None = None, team_id: str | None = None, workspace: Path | None = None) -> dict[str, str]:
230
+ state = state or {}
231
+ machine_fingerprint = os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or ""
232
+ override = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
233
+ env_uuid = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID") or ""
234
+ leader_uuid = override or env_uuid or derive_leader_session_uuid(
235
+ machine_fingerprint,
236
+ _identity_workspace_abspath(state, workspace),
237
+ _identity_os_user(),
238
+ team_id or os.environ.get("TEAM_AGENT_TEAM_ID") or team_state_key(state),
239
+ )
167
240
  return {
168
241
  "pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or "",
169
242
  "provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER") or "",
170
- "machine_fingerprint": os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or "",
243
+ "machine_fingerprint": machine_fingerprint,
244
+ "leader_session_uuid": leader_uuid,
245
+ "leader_session_uuid_source": "explicit-override" if override else ("env" if env_uuid else "derived"),
171
246
  }
172
247
 
173
248
 
@@ -175,19 +250,22 @@ def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
175
250
  owner = state.get("team_owner") or {}
176
251
  if not owner:
177
252
  return None
178
- caller = _caller_identity_from_env()
179
- if (
180
- caller["pane_id"] == (owner.get("pane_id") or "")
181
- and caller["provider"] == (owner.get("provider") or "")
182
- and caller["machine_fingerprint"] == (owner.get("machine_fingerprint") or "")
183
- ):
253
+ _migrate_team_identity(state, Path(_identity_workspace_abspath(state)), team_state_key(state))
254
+ caller = _caller_identity_from_env(state, team_state_key(state))
255
+ owner_uuid = str(owner.get("leader_session_uuid") or "")
256
+ caller_uuid = caller["leader_session_uuid"]
257
+ owner_pane = str(owner.get("pane_id") or "")
258
+ caller_pane = caller.get("pane_id") or ""
259
+ if caller_uuid == owner_uuid and (not caller_pane or caller_pane == owner_pane):
184
260
  return None
261
+ same_uuid = caller_uuid == owner_uuid
185
262
  return {
186
263
  "ok": False,
187
264
  "status": "refused",
188
265
  "reason": "team_owner_mismatch",
266
+ "reason_kind": "sticky_bind_collision" if same_uuid else "owner_takeover_required",
189
267
  "error": "not_owner",
190
- "action": "use team-agent takeover --confirm",
268
+ "action": "team-agent claim-leader --confirm" if same_uuid else "team-agent takeover --confirm",
191
269
  "team_owner": owner,
192
270
  "caller": caller,
193
271
  }
@@ -209,14 +287,16 @@ def worker_sender_bypasses_owner_gate(state: dict[str, Any], sender: str | None)
209
287
 
210
288
  def populate_team_owner_from_env(state: dict[str, Any], source: str = "autopopulate") -> dict[str, Any] | None:
211
289
  if state.get("team_owner"):
290
+ _migrate_team_identity(state, Path(_identity_workspace_abspath(state)), team_state_key(state))
212
291
  return state["team_owner"]
213
- caller = _caller_identity_from_env()
292
+ caller = _caller_identity_from_env(state, team_state_key(state))
214
293
  if not caller["pane_id"]:
215
294
  return None
216
295
  owner = {
217
296
  "pane_id": caller["pane_id"],
218
297
  "provider": caller["provider"],
219
298
  "machine_fingerprint": caller["machine_fingerprint"],
299
+ "leader_session_uuid": caller["leader_session_uuid"],
220
300
  "claimed_at": datetime.now(timezone.utc).isoformat(),
221
301
  "claimed_via": source,
222
302
  }
@@ -224,7 +304,70 @@ def populate_team_owner_from_env(state: dict[str, Any], source: str = "autopopul
224
304
  return owner
225
305
 
226
306
 
307
+ def apply_first_time_leader_binding(
308
+ workspace: Path,
309
+ state: dict[str, Any],
310
+ receiver: dict[str, Any],
311
+ pane_info: dict[str, Any],
312
+ identity: dict[str, Any],
313
+ source: str,
314
+ ) -> dict[str, Any]:
315
+ from team_agent.messaging.leader_panes import _leader_command_looks_usable
316
+ command = pane_info.get("pane_current_command", "")
317
+ provider = str(receiver.get("provider") or "")
318
+ if not _leader_command_looks_usable(command, provider):
319
+ return {"ok": False, "reason": "leader_pane_wrong_command", "error": f"pane command {command!r} is not a leader host", "pane": pane_info}
320
+ current_path = pane_info.get("pane_current_path")
321
+ if not current_path or os.path.realpath(current_path) != os.path.realpath(str(workspace.resolve())):
322
+ return {"ok": False, "reason": "leader_pane_wrong_workspace", "error": f"pane cwd {current_path!r} does not match workspace {str(workspace.resolve())!r}", "pane": pane_info}
323
+ receiver.update({
324
+ "leader_session_uuid": identity["leader_session_uuid"],
325
+ "machine_fingerprint": identity["machine_fingerprint"],
326
+ "owner_epoch": 0,
327
+ })
328
+ state["team_owner"] = {
329
+ "pane_id": receiver["pane_id"],
330
+ "provider": provider,
331
+ "machine_fingerprint": identity["machine_fingerprint"],
332
+ "leader_session_uuid": identity["leader_session_uuid"],
333
+ "owner_epoch": 0,
334
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
335
+ "claimed_via": source,
336
+ }
337
+ state["leader_receiver"] = receiver
338
+ return {"ok": True, "pane": pane_info, "warning": None, "first_time": True}
339
+
340
+
341
+ def leader_env_exports(receiver: dict[str, Any], identity: dict[str, Any]) -> dict[str, str]:
342
+ return {
343
+ "TEAM_AGENT_LEADER_PANE_ID": str(receiver.get("pane_id") or ""),
344
+ "TEAM_AGENT_LEADER_PROVIDER": str(receiver.get("provider") or ""),
345
+ "TEAM_AGENT_LEADER_SESSION_UUID": str(identity.get("leader_session_uuid") or ""),
346
+ "TEAM_AGENT_MACHINE_FINGERPRINT": str(identity.get("machine_fingerprint") or ""),
347
+ "TEAM_AGENT_WORKSPACE": str(identity.get("workspace_abspath") or ""),
348
+ "TEAM_AGENT_TEAM_ID": str(identity.get("team_id") or ""),
349
+ }
350
+
351
+
352
+ def validate_leader_uuid_from_targets(receiver: dict[str, Any], targets: dict[str, Any]) -> dict[str, Any]:
353
+ expected_uuid = str(receiver.get("leader_session_uuid") or "")
354
+ if not expected_uuid or receiver.get("provider") == "fake":
355
+ return {"ok": True}
356
+ if not targets.get("ok"):
357
+ return {"ok": False, "reason": "leader_uuid_lookup_failed", "error": targets.get("error") or "tmux target scan failed"}
358
+ pane_id = receiver.get("pane_id")
359
+ target = next((item for item in targets.get("targets", []) if item.get("pane_id") == pane_id), None)
360
+ env = target.get("leader_env") if isinstance((target or {}).get("leader_env"), dict) else {}
361
+ actual_uuid = str((target or {}).get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
362
+ if not actual_uuid:
363
+ return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": target}
364
+ if actual_uuid != expected_uuid:
365
+ return {"ok": False, "reason": "leader_uuid_mismatch", "error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner", "pane": target}
366
+ return {"ok": True}
367
+
368
+
227
369
  def save_runtime_state(workspace: Path, state: dict[str, Any]) -> None:
370
+ _migrate_state_identity(state, workspace)
228
371
  path = runtime_state_path(workspace)
229
372
  path.parent.mkdir(parents=True, exist_ok=True)
230
373
  tmp_path = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
@@ -1,24 +1,54 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import datetime, timezone
3
4
  from pathlib import Path
4
5
  from typing import Any
5
6
 
6
7
  from team_agent.message_store import MessageStore
7
8
 
8
9
 
9
- def inbox(workspace: Path, agent_id: str, limit: int = 20) -> dict[str, Any]:
10
+ def _parse_since(since: str | None) -> datetime | None:
11
+ if not since:
12
+ return None
13
+ try:
14
+ dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
15
+ except (ValueError, AttributeError):
16
+ return None
17
+ if dt.tzinfo is None:
18
+ dt = dt.replace(tzinfo=timezone.utc)
19
+ return dt
20
+
21
+
22
+ def _filter_since(rows: list[dict[str, Any]], since: str | None) -> list[dict[str, Any]]:
23
+ cutoff = _parse_since(since)
24
+ if cutoff is None:
25
+ return rows
26
+ filtered: list[dict[str, Any]] = []
27
+ for row in rows:
28
+ ts_raw = str(row.get("created_at") or "")
29
+ ts = _parse_since(ts_raw)
30
+ if ts and ts >= cutoff:
31
+ filtered.append(row)
32
+ return filtered
33
+
34
+
35
+ def inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> dict[str, Any]:
10
36
  rows = MessageStore(workspace).inbox(agent_id, limit=limit)
11
- return {"ok": True, "agent_id": agent_id, "messages": rows}
37
+ rows = _filter_since(rows, since)
38
+ return {"ok": True, "agent_id": agent_id, "messages": rows, "since": since}
12
39
 
13
40
 
14
- def format_inbox(workspace: Path, agent_id: str, limit: int = 20) -> str:
41
+ def format_inbox(workspace: Path, agent_id: str, limit: int = 20, since: str | None = None) -> str:
15
42
  store = MessageStore(workspace)
16
43
  rows = store.inbox(agent_id, limit=limit)
44
+ rows = _filter_since(rows, since)
17
45
  result_counts = store.result_counts()
18
46
  note = "final results are not in inbox; use team-agent collect"
19
47
  if result_counts.get("uncollected", 0):
20
48
  note += f" ({result_counts['uncollected']} uncollected result(s) pending)"
21
49
  if not rows:
50
+ if since:
51
+ return f"{agent_id}: no messages since {since}\n{note}"
22
52
  return f"{agent_id}: no messages\n{note}"
23
53
  lines = [
24
54
  f"{row['created_at']} {row['sender']} -> {row['recipient']} {row['status']}: {row['content']}"