@team-agent/installer 0.1.10 → 0.2.0

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.
Files changed (111) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/skills/team-agent/SKILL.md +1 -1
  5. package/src/team_agent/approvals/__init__.py +65 -0
  6. package/src/team_agent/approvals/constants.py +6 -0
  7. package/src/team_agent/approvals/parsing.py +176 -0
  8. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  9. package/src/team_agent/approvals/status.py +165 -0
  10. package/src/team_agent/cli/__init__.py +135 -0
  11. package/src/team_agent/cli/commands.py +335 -0
  12. package/src/team_agent/cli/e2e.py +202 -0
  13. package/src/team_agent/cli/helpers.py +137 -0
  14. package/src/team_agent/cli/parser.py +470 -0
  15. package/src/team_agent/compiler.py +98 -33
  16. package/src/team_agent/coordinator/__init__.py +53 -0
  17. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  18. package/src/team_agent/coordinator/lifecycle.py +319 -0
  19. package/src/team_agent/coordinator/metadata.py +61 -0
  20. package/src/team_agent/coordinator/paths.py +17 -0
  21. package/src/team_agent/diagnose/__init__.py +48 -0
  22. package/src/team_agent/diagnose/checks.py +101 -0
  23. package/src/team_agent/diagnose/health.py +241 -0
  24. package/src/team_agent/diagnose/preflight.py +194 -0
  25. package/src/team_agent/diagnose/quick_start.py +233 -0
  26. package/src/team_agent/display/__init__.py +61 -0
  27. package/src/team_agent/display/close.py +147 -0
  28. package/src/team_agent/display/ghostty.py +77 -0
  29. package/src/team_agent/display/worker_window.py +110 -0
  30. package/src/team_agent/display/workspace.py +473 -0
  31. package/src/team_agent/launch/__init__.py +41 -0
  32. package/src/team_agent/launch/bootstrap.py +85 -0
  33. package/src/team_agent/launch/config.py +106 -0
  34. package/src/team_agent/launch/core.py +291 -0
  35. package/src/team_agent/launch/requirements.py +57 -0
  36. package/src/team_agent/leader/__init__.py +320 -0
  37. package/src/team_agent/lifecycle/__init__.py +5 -0
  38. package/src/team_agent/lifecycle/agents.py +226 -0
  39. package/src/team_agent/lifecycle/operations.py +321 -0
  40. package/src/team_agent/lifecycle/start.py +360 -0
  41. package/src/team_agent/mcp_server/__init__.py +42 -0
  42. package/src/team_agent/mcp_server/__main__.py +7 -0
  43. package/src/team_agent/mcp_server/contracts.py +148 -0
  44. package/src/team_agent/mcp_server/normalize.py +257 -0
  45. package/src/team_agent/mcp_server/server.py +150 -0
  46. package/src/team_agent/mcp_server/tools.py +205 -0
  47. package/src/team_agent/message_store/__init__.py +23 -0
  48. package/src/team_agent/message_store/agent_health.py +109 -0
  49. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  50. package/src/team_agent/message_store/result_watchers.py +102 -0
  51. package/src/team_agent/message_store/schema.py +266 -0
  52. package/src/team_agent/messaging/__init__.py +1 -0
  53. package/src/team_agent/messaging/activity_detector.py +190 -0
  54. package/src/team_agent/messaging/delivery.py +128 -0
  55. package/src/team_agent/messaging/deps.py +263 -0
  56. package/src/team_agent/messaging/idle_alerts.py +217 -0
  57. package/src/team_agent/messaging/internal_delivery.py +46 -0
  58. package/src/team_agent/messaging/leader.py +317 -0
  59. package/src/team_agent/messaging/leader_panes.py +343 -0
  60. package/src/team_agent/messaging/result_delivery.py +300 -0
  61. package/src/team_agent/messaging/results.py +456 -0
  62. package/src/team_agent/messaging/scheduler.py +418 -0
  63. package/src/team_agent/messaging/send.py +493 -0
  64. package/src/team_agent/messaging/tmux_io.py +337 -0
  65. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  66. package/src/team_agent/orchestrator/__init__.py +376 -0
  67. package/src/team_agent/orchestrator/plan.py +122 -0
  68. package/src/team_agent/orchestrator/state.py +128 -0
  69. package/src/team_agent/profiles/__init__.py +82 -0
  70. package/src/team_agent/profiles/constants.py +19 -0
  71. package/src/team_agent/profiles/core.py +407 -0
  72. package/src/team_agent/profiles/helpers.py +69 -0
  73. package/src/team_agent/profiles/provider_env.py +188 -0
  74. package/src/team_agent/profiles/smoke.py +201 -0
  75. package/src/team_agent/provider_cli/__init__.py +43 -0
  76. package/src/team_agent/provider_cli/adapter.py +167 -0
  77. package/src/team_agent/provider_cli/base.py +48 -0
  78. package/src/team_agent/provider_cli/claude.py +457 -0
  79. package/src/team_agent/provider_cli/codex.py +319 -0
  80. package/src/team_agent/provider_cli/copilot.py +8 -0
  81. package/src/team_agent/provider_cli/fake.py +39 -0
  82. package/src/team_agent/provider_cli/gemini.py +95 -0
  83. package/src/team_agent/provider_cli/opencode.py +8 -0
  84. package/src/team_agent/provider_cli/prompt.py +62 -0
  85. package/src/team_agent/provider_cli/registry.py +18 -0
  86. package/src/team_agent/provider_cli/unsupported.py +32 -0
  87. package/src/team_agent/providers.py +67 -949
  88. package/src/team_agent/quality_gates.py +104 -0
  89. package/src/team_agent/restart/__init__.py +34 -0
  90. package/src/team_agent/restart/orchestration.py +328 -0
  91. package/src/team_agent/restart/selection.py +89 -0
  92. package/src/team_agent/restart/snapshot.py +70 -0
  93. package/src/team_agent/runtime.py +802 -5740
  94. package/src/team_agent/rust_core.py +22 -5
  95. package/src/team_agent/sessions/__init__.py +25 -0
  96. package/src/team_agent/sessions/capture.py +93 -0
  97. package/src/team_agent/sessions/inventory.py +44 -0
  98. package/src/team_agent/sessions/resume.py +135 -0
  99. package/src/team_agent/spec.py +3 -1
  100. package/src/team_agent/state.py +204 -4
  101. package/src/team_agent/status/__init__.py +63 -0
  102. package/src/team_agent/status/approvals.py +52 -0
  103. package/src/team_agent/status/compact.py +158 -0
  104. package/src/team_agent/status/constants.py +18 -0
  105. package/src/team_agent/status/inbox.py +28 -0
  106. package/src/team_agent/status/peek.py +117 -0
  107. package/src/team_agent/status/queries.py +168 -0
  108. package/src/team_agent/terminal.py +57 -0
  109. package/src/team_agent/cli.py +0 -857
  110. package/src/team_agent/mcp_server.py +0 -579
  111. package/src/team_agent/profiles.py +0 -882
@@ -141,16 +141,33 @@ def list_targets() -> dict[str, Any]:
141
141
 
142
142
 
143
143
  def contains_inline_secret(value: str) -> bool:
144
- lower = value.lower()
145
144
  return (
146
- "api_key" in lower
147
- or "apikey" in lower
148
- or "token" in lower
149
- or "secret" in lower
145
+ _contains_secret_assignment(value)
146
+ or _contains_bearer_secret(value)
147
+ or any(chunk.startswith("sk-") or _looks_base64_secret(chunk) for chunk in value.split())
150
148
  or value.startswith("sk-")
151
149
  or _looks_base64_secret(value)
152
150
  )
153
151
 
154
152
 
153
+ def _contains_secret_assignment(value: str) -> bool:
154
+ for line in value.splitlines():
155
+ for separator in ("=", ":"):
156
+ if separator not in line:
157
+ continue
158
+ key, raw = line.split(separator, 1)
159
+ normalized = re.sub(r"[^a-z0-9]", "", key.lower())
160
+ if normalized not in {"apikey", "token", "secret", "password", "credential"}:
161
+ continue
162
+ candidate = raw.strip().strip("'\"")
163
+ if candidate.startswith("sk-") or len(candidate) >= 8 or _looks_base64_secret(candidate):
164
+ return True
165
+ return False
166
+
167
+
168
+ def _contains_bearer_secret(value: str) -> bool:
169
+ return re.search(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}", value) is not None
170
+
171
+
155
172
  def _looks_base64_secret(value: str) -> bool:
156
173
  return len(value) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", value) is not None
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.sessions.capture import (
4
+ capture_agent_session,
5
+ capture_missing_sessions,
6
+ clear_session_capture_fields,
7
+ copy_session_metadata,
8
+ )
9
+ from team_agent.sessions.inventory import sessions_overview
10
+ from team_agent.sessions.resume import (
11
+ attach_profile_resume_root,
12
+ prepare_resume_state,
13
+ recover_resume_session_from_events,
14
+ )
15
+
16
+ __all__ = [
17
+ "attach_profile_resume_root",
18
+ "capture_agent_session",
19
+ "capture_missing_sessions",
20
+ "clear_session_capture_fields",
21
+ "copy_session_metadata",
22
+ "prepare_resume_state",
23
+ "recover_resume_session_from_events",
24
+ "sessions_overview",
25
+ ]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.events import EventLog
8
+ from team_agent.providers import get_adapter
9
+ from team_agent.state import SESSION_CAPTURE_FIELDS, SESSION_STATE_FIELDS
10
+
11
+
12
+ def capture_missing_sessions(
13
+ workspace: Path,
14
+ state: dict[str, Any],
15
+ event_log: EventLog,
16
+ timeout_s: float,
17
+ log_miss: bool = True,
18
+ ) -> list[str]:
19
+ captured: list[str] = []
20
+ for agent_id, agent_state in state.get("agents", {}).items():
21
+ if agent_state.get("session_id"):
22
+ continue
23
+ known_session_ids = {
24
+ str(item.get("session_id"))
25
+ for aid, item in state.get("agents", {}).items()
26
+ if aid != agent_id and item.get("session_id")
27
+ }
28
+ result = capture_agent_session(
29
+ workspace,
30
+ agent_id,
31
+ agent_state,
32
+ event_log,
33
+ timeout_s=timeout_s,
34
+ exclude_session_ids=known_session_ids,
35
+ )
36
+ if result:
37
+ captured.append(agent_id)
38
+ elif log_miss:
39
+ event_log.write(
40
+ "session.capture_timeout",
41
+ agent_id=agent_id,
42
+ provider=agent_state.get("provider"),
43
+ timeout_s=timeout_s,
44
+ spawn_cwd=agent_state.get("spawn_cwd"),
45
+ )
46
+ return captured
47
+
48
+
49
+ def capture_agent_session(
50
+ workspace: Path,
51
+ agent_id: str,
52
+ agent_state: dict[str, Any],
53
+ event_log: EventLog,
54
+ timeout_s: float,
55
+ exclude_session_ids: set[str] | None = None,
56
+ ) -> dict[str, Any] | None:
57
+ if agent_state.get("session_id"):
58
+ return None
59
+ adapter = get_adapter(agent_state["provider"])
60
+ spawn_context = {
61
+ "agent_id": agent_id,
62
+ "cwd": agent_state.get("spawn_cwd") or str(workspace),
63
+ "spawn_time": agent_state.get("spawned_at") or datetime.now(timezone.utc).isoformat(),
64
+ "tmux_target": f"{agent_state.get('session_name', '')}:{agent_state.get('window', agent_id)}",
65
+ "predetermined_session_id": agent_state.get("_pending_session_id"),
66
+ "exclude_session_ids": sorted(exclude_session_ids or set()),
67
+ "claude_projects_root": agent_state.get("claude_projects_root"),
68
+ }
69
+ result = adapter.capture_session_id(agent_id, spawn_context, timeout_s=timeout_s)
70
+ if not isinstance(result, dict) or not result.get("session_id"):
71
+ return None
72
+ copy_session_metadata(agent_state, result)
73
+ agent_state.pop("_pending_session_id", None)
74
+ event_log.write(
75
+ "session.captured",
76
+ agent_id=agent_id,
77
+ provider=agent_state.get("provider"),
78
+ session_id=agent_state.get("session_id"),
79
+ rollout_path=agent_state.get("rollout_path"),
80
+ captured_via=agent_state.get("captured_via"),
81
+ attribution_confidence=agent_state.get("attribution_confidence"),
82
+ )
83
+ return result
84
+
85
+
86
+ def copy_session_metadata(target: dict[str, Any], source: dict[str, Any]) -> None:
87
+ for key in SESSION_STATE_FIELDS:
88
+ target[key] = source.get(key)
89
+
90
+
91
+ def clear_session_capture_fields(target: dict[str, Any]) -> None:
92
+ for key in SESSION_CAPTURE_FIELDS:
93
+ target[key] = None
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from team_agent.spec import load_spec
7
+ from team_agent.state import load_runtime_state
8
+
9
+
10
+ def sessions_overview(workspace: Path) -> dict[str, Any]:
11
+ state = load_runtime_state(workspace)
12
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
13
+ spec = load_spec(spec_path) if spec_path.exists() else {}
14
+ tasks = state.get("tasks", [])
15
+ rows = []
16
+ for agent in spec.get("agents", []):
17
+ agent_state = state.get("agents", {}).get(agent["id"], {})
18
+ last_task = next((task.get("id") for task in reversed(tasks) if task.get("assignee") == agent["id"]), None)
19
+ rows.append(
20
+ {
21
+ "agent_id": agent["id"],
22
+ "provider": agent.get("provider"),
23
+ "model": agent.get("model"),
24
+ "profile": agent.get("profile"),
25
+ "session_id": agent_state.get("session_id"),
26
+ "resume_id": agent_state.get("resume_id"),
27
+ "rollout_path": agent_state.get("rollout_path"),
28
+ "captured_at": agent_state.get("captured_at"),
29
+ "captured_via": agent_state.get("captured_via"),
30
+ "attribution_confidence": agent_state.get("attribution_confidence"),
31
+ "spawn_cwd": agent_state.get("spawn_cwd"),
32
+ "context_usage": agent_state.get("context_usage"),
33
+ "status": agent_state.get("status", "unknown"),
34
+ "last_task": last_task,
35
+ "handoff_path": agent_state.get("handoff_path"),
36
+ "display_target": agent_state.get("display"),
37
+ "terminal_target": {
38
+ "session": state.get("session_name"),
39
+ "window": agent_state.get("window", agent["id"]),
40
+ "pane": agent_state.get("pane_id"),
41
+ },
42
+ }
43
+ )
44
+ return {"ok": True, "sessions": rows, "workspace": str(workspace)}
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.events import EventLog
8
+ from team_agent.paths import logs_dir
9
+ from team_agent.profiles import prepare_agent_profile_launch
10
+ from team_agent.providers import ResumeUnavailable
11
+ from team_agent.sessions.capture import clear_session_capture_fields, copy_session_metadata
12
+
13
+
14
+ def attach_profile_resume_root(workspace: Path, command_agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
15
+ profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
16
+ if not profile_launch:
17
+ return previous
18
+ command_agent["_provider_profile"] = profile_launch
19
+ root = profile_launch.get("claude_projects_root")
20
+ if not root:
21
+ return previous
22
+ prepared = dict(previous)
23
+ prepared["claude_projects_root"] = root
24
+ return prepared
25
+
26
+
27
+ def prepare_resume_state(
28
+ workspace: Path,
29
+ agent_id: str,
30
+ previous: dict[str, Any],
31
+ adapter: Any,
32
+ event_log: EventLog,
33
+ exclude_session_ids: set[str] | None = None,
34
+ allow_fresh_on_resume_failure: bool = False,
35
+ ) -> dict[str, Any]:
36
+ prepared = dict(previous)
37
+ session_id = prepared.get("session_id")
38
+ if session_id and adapter.session_is_resumable(prepared, workspace):
39
+ return prepared
40
+ if session_id:
41
+ event_log.write(
42
+ "resume.session_unverified",
43
+ agent_id=agent_id,
44
+ provider=prepared.get("provider"),
45
+ session_id=session_id,
46
+ captured_via=prepared.get("captured_via"),
47
+ spawn_cwd=prepared.get("spawn_cwd"),
48
+ )
49
+ else:
50
+ event_log.write(
51
+ "resume.session_missing_repair_attempt",
52
+ agent_id=agent_id,
53
+ provider=prepared.get("provider"),
54
+ spawn_cwd=prepared.get("spawn_cwd"),
55
+ )
56
+ repaired = recover_resume_session_from_events(workspace, agent_id, prepared, adapter, exclude_session_ids or set())
57
+ if not repaired:
58
+ repaired = adapter.recover_session_id(agent_id, prepared, workspace, exclude_session_ids or set())
59
+ if repaired:
60
+ copy_session_metadata(prepared, repaired)
61
+ event_log.write(
62
+ "resume.session_repaired",
63
+ agent_id=agent_id,
64
+ provider=prepared.get("provider"),
65
+ old_session_id=session_id,
66
+ session_id=prepared.get("session_id"),
67
+ rollout_path=prepared.get("rollout_path"),
68
+ captured_via=prepared.get("captured_via"),
69
+ attribution_confidence=prepared.get("attribution_confidence"),
70
+ )
71
+ return prepared
72
+ if session_id and not allow_fresh_on_resume_failure:
73
+ event_log.write(
74
+ "resume.session_required_missing",
75
+ agent_id=agent_id,
76
+ provider=prepared.get("provider"),
77
+ old_session_id=session_id,
78
+ rollout_path=prepared.get("rollout_path"),
79
+ reason="provider transcript not found",
80
+ )
81
+ raise ResumeUnavailable(
82
+ f"Cannot resume agent {agent_id}: stored session {session_id} is not available. "
83
+ "Use --allow-fresh only if losing that worker context is acceptable."
84
+ )
85
+ clear_session_capture_fields(prepared)
86
+ event_log.write(
87
+ "resume.session_unavailable",
88
+ agent_id=agent_id,
89
+ provider=prepared.get("provider"),
90
+ old_session_id=session_id,
91
+ reason="provider transcript not found",
92
+ )
93
+ return prepared
94
+
95
+
96
+ def recover_resume_session_from_events(
97
+ workspace: Path,
98
+ agent_id: str,
99
+ previous: dict[str, Any],
100
+ adapter: Any,
101
+ exclude_session_ids: set[str],
102
+ ) -> dict[str, Any] | None:
103
+ events_path = logs_dir(workspace) / "events.jsonl"
104
+ try:
105
+ lines = events_path.read_text(encoding="utf-8").splitlines()
106
+ except OSError:
107
+ return None
108
+ current_session_id = str(previous.get("session_id") or "")
109
+ for line in reversed(lines):
110
+ try:
111
+ event = json.loads(line)
112
+ except json.JSONDecodeError:
113
+ continue
114
+ if event.get("agent_id") != agent_id:
115
+ continue
116
+ if event.get("event") == "discard.session_tombstone":
117
+ return None
118
+ if event.get("event") != "session.captured":
119
+ continue
120
+ session_id = str(event.get("session_id") or "")
121
+ if not session_id or session_id == current_session_id or session_id in exclude_session_ids:
122
+ continue
123
+ candidate = dict(previous)
124
+ candidate.update(
125
+ {
126
+ "session_id": session_id,
127
+ "rollout_path": event.get("rollout_path"),
128
+ "captured_at": event.get("ts"),
129
+ "captured_via": "event_log_repair",
130
+ "attribution_confidence": event.get("attribution_confidence"),
131
+ }
132
+ )
133
+ if adapter.session_is_resumable(candidate, workspace):
134
+ return candidate
135
+ return None
@@ -131,7 +131,7 @@ def _result_schema_errors(envelope: Any) -> list[str]:
131
131
 
132
132
  def _check_agent(agent: Any, path: str, errors: list[str]) -> None:
133
133
  required = {"id", "role", "provider", "model", "working_directory", "system_prompt", "tools", "permission_mode", "preferred_for", "avoid_for", "output_contract"}
134
- allowed = required | {"paused", "auth_mode", "profile", "credential_ref"}
134
+ allowed = required | {"paused", "auth_mode", "profile", "credential_ref", "forked_from"}
135
135
  _check_keys(agent, path, required, allowed, errors)
136
136
  if not isinstance(agent, dict):
137
137
  return
@@ -245,6 +245,8 @@ def _semantic_errors(spec: dict[str, Any], base_dir: Path) -> list[str]:
245
245
  agents = spec.get("agents", [])
246
246
  agent_ids = {a.get("id") for a in agents if isinstance(a, dict)}
247
247
  all_ids = set(agent_ids)
248
+ if len(agent_ids) != len([a for a in agents if isinstance(a, dict)]):
249
+ errors.append("/agents: duplicate agent id")
248
250
  if leader.get("id"):
249
251
  all_ids.add(leader["id"])
250
252
 
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import copy
6
+ import uuid
5
7
  from datetime import datetime, timezone
6
8
  from pathlib import Path
7
9
  from typing import Any
@@ -46,12 +48,207 @@ def load_runtime_state(workspace: Path) -> dict[str, Any]:
46
48
  return state
47
49
 
48
50
 
51
+ def team_state_key(state: dict[str, Any]) -> str:
52
+ for field in ("team_dir", "spec_path"):
53
+ value = state.get(field)
54
+ if not value:
55
+ continue
56
+ path = Path(str(value))
57
+ key = path.name if field == "team_dir" else path.parent.name
58
+ if key and key not in {".team", "runtime"}:
59
+ return key
60
+ return str(state.get("session_name") or "current")
61
+
62
+
63
+ def compact_team_state(state: dict[str, Any]) -> dict[str, Any]:
64
+ compact = copy.deepcopy(state)
65
+ compact.pop("teams", None)
66
+ return compact
67
+
68
+
69
+ def merge_workspace_team_state(existing: dict[str, Any], launched: dict[str, Any]) -> dict[str, Any]:
70
+ launched_key = team_state_key(launched)
71
+ if not existing.get("session_name"):
72
+ merged = copy.deepcopy(launched)
73
+ merged.setdefault("teams", {})[launched_key] = compact_team_state(launched)
74
+ return merged
75
+ existing_key = team_state_key(existing)
76
+ if existing_key == launched_key:
77
+ merged = copy.deepcopy(launched)
78
+ teams = copy.deepcopy(existing.get("teams") or {})
79
+ teams[launched_key] = compact_team_state(launched)
80
+ merged["teams"] = teams
81
+ return merged
82
+ merged = copy.deepcopy(existing)
83
+ teams = merged.setdefault("teams", {})
84
+ teams.setdefault(existing_key, compact_team_state(existing))
85
+ teams[launched_key] = compact_team_state(launched)
86
+ return merged
87
+
88
+
89
+ def team_state_candidates(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
90
+ candidates: dict[str, dict[str, Any]] = {}
91
+ teams = state.get("teams")
92
+ if isinstance(teams, dict):
93
+ for key, value in teams.items():
94
+ if isinstance(value, dict):
95
+ candidates[str(key)] = value
96
+ if state.get("session_name"):
97
+ candidates.setdefault(team_state_key(state), compact_team_state(state))
98
+ return candidates
99
+
100
+
101
+ def format_team_candidates(candidates: dict[str, dict[str, Any]]) -> str:
102
+ if not candidates:
103
+ return "No team state was found."
104
+ parts = []
105
+ for key, state in sorted(candidates.items()):
106
+ agents = ",".join(sorted(state.get("agents", {}).keys())) or "-"
107
+ parts.append(f"{key} session={state.get('session_name') or '-'} agents={agents}")
108
+ return "Candidates: " + "; ".join(parts)
109
+
110
+
111
+ def select_runtime_state(workspace: Path, team: str | None = None) -> dict[str, Any]:
112
+ state = load_runtime_state(workspace)
113
+ candidates = team_state_candidates(state)
114
+ if team:
115
+ matches = [
116
+ value
117
+ for key, value in candidates.items()
118
+ if team in {key, str(value.get("session_name") or ""), str(value.get("team_dir") or "")}
119
+ ]
120
+ if len(matches) == 1:
121
+ return copy.deepcopy(matches[0])
122
+ from team_agent.errors import RuntimeError
123
+ if len(matches) > 1:
124
+ raise RuntimeError("team selector is ambiguous. " + format_team_candidates(candidates))
125
+ raise RuntimeError(f"team {team!r} not found. " + format_team_candidates(candidates))
126
+ if len(candidates) > 1:
127
+ from team_agent.errors import RuntimeError
128
+ raise RuntimeError("multiple teams found in this workspace; pass --team <team> to choose. " + format_team_candidates(candidates))
129
+ return copy.deepcopy(state)
130
+
131
+
132
+ def ambiguous_team_target_result(state: dict[str, Any]) -> dict[str, Any] | None:
133
+ candidates = team_state_candidates(state)
134
+ if len(candidates) <= 1:
135
+ return None
136
+ return {
137
+ "ok": False,
138
+ "status": "refused",
139
+ "reason": "team_target_ambiguous",
140
+ "candidates": sorted(candidates),
141
+ "message": "multiple teams found in this workspace; pass --team <team> to choose. " + format_team_candidates(candidates),
142
+ }
143
+
144
+
145
+ def resolve_team_scoped_state(
146
+ workspace: Path,
147
+ team: str | None,
148
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
149
+ if team is None:
150
+ ambiguous = ambiguous_team_target_result(load_runtime_state(workspace))
151
+ if ambiguous:
152
+ return None, ambiguous
153
+ try:
154
+ from team_agent.errors import RuntimeError as _TeamAgentRuntimeError
155
+ return select_runtime_state(workspace, team), None
156
+ except _TeamAgentRuntimeError as exc:
157
+ return None, {
158
+ "ok": False,
159
+ "status": "refused",
160
+ "reason": "team_target_unresolved",
161
+ "team": team,
162
+ "error": str(exc),
163
+ }
164
+
165
+
166
+ def _caller_identity_from_env() -> dict[str, str]:
167
+ return {
168
+ "pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or "",
169
+ "provider": os.environ.get("TEAM_AGENT_LEADER_PROVIDER") or "",
170
+ "machine_fingerprint": os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or "",
171
+ }
172
+
173
+
174
+ def check_team_owner(state: dict[str, Any]) -> dict[str, Any] | None:
175
+ owner = state.get("team_owner") or {}
176
+ if not owner:
177
+ 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
+ ):
184
+ return None
185
+ return {
186
+ "ok": False,
187
+ "status": "refused",
188
+ "reason": "team_owner_mismatch",
189
+ "error": "not_owner",
190
+ "action": "use team-agent takeover --confirm",
191
+ "team_owner": owner,
192
+ "caller": caller,
193
+ }
194
+
195
+
196
+ def populate_team_owner_from_env(state: dict[str, Any], source: str = "autopopulate") -> dict[str, Any] | None:
197
+ if state.get("team_owner"):
198
+ return state["team_owner"]
199
+ caller = _caller_identity_from_env()
200
+ if not caller["pane_id"]:
201
+ return None
202
+ owner = {
203
+ "pane_id": caller["pane_id"],
204
+ "provider": caller["provider"],
205
+ "machine_fingerprint": caller["machine_fingerprint"],
206
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
207
+ "claimed_via": source,
208
+ }
209
+ state["team_owner"] = owner
210
+ return owner
211
+
212
+
49
213
  def save_runtime_state(workspace: Path, state: dict[str, Any]) -> None:
50
214
  path = runtime_state_path(workspace)
51
215
  path.parent.mkdir(parents=True, exist_ok=True)
52
- tmp_path = path.with_suffix(path.suffix + ".tmp")
53
- tmp_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
54
- os.replace(tmp_path, path)
216
+ tmp_path = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
217
+ try:
218
+ tmp_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
219
+ os.replace(tmp_path, path)
220
+ finally:
221
+ tmp_path.unlink(missing_ok=True)
222
+
223
+
224
+ def save_team_scoped_state(workspace: Path, team_state: dict[str, Any]) -> None:
225
+ target_key = team_state_key(team_state)
226
+ existing = load_runtime_state(workspace)
227
+ existing_primary_key = team_state_key(existing) if existing.get("session_name") else None
228
+ if (
229
+ existing_primary_key is not None
230
+ and existing_primary_key != target_key
231
+ and existing.get("session_name")
232
+ and existing.get("session_name") == team_state.get("session_name")
233
+ ):
234
+ existing_primary_key = target_key
235
+ existing_teams = existing.get("teams") or {}
236
+ if not existing_teams and existing_primary_key == target_key:
237
+ merged = copy.deepcopy(team_state)
238
+ merged.pop("teams", None)
239
+ save_runtime_state(workspace, merged)
240
+ return
241
+ teams = copy.deepcopy(existing_teams)
242
+ teams[target_key] = compact_team_state(team_state)
243
+ if existing_primary_key is None or existing_primary_key == target_key:
244
+ merged = copy.deepcopy(team_state)
245
+ merged["teams"] = teams
246
+ else:
247
+ merged = copy.deepcopy(existing)
248
+ merged["teams"] = teams
249
+ if not merged.get("teams"):
250
+ merged.pop("teams", None)
251
+ save_runtime_state(workspace, merged)
55
252
 
56
253
 
57
254
  def write_team_state(workspace: Path, spec: dict[str, Any], runtime: dict[str, Any], results: list[dict[str, Any]] | None = None) -> Path:
@@ -119,4 +316,7 @@ def write_team_state(workspace: Path, spec: dict[str, Any], runtime: dict[str, A
119
316
 
120
317
 
121
318
  def write_spec(path: Path, spec: dict[str, Any]) -> None:
122
- path.write_text(dumps(spec), encoding="utf-8")
319
+ path.parent.mkdir(parents=True, exist_ok=True)
320
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
321
+ tmp_path.write_text(dumps(spec), encoding="utf-8")
322
+ os.replace(tmp_path, path)
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.status.approvals import approvals, format_approvals
4
+ from team_agent.status.compact import (
5
+ compact_agent_state,
6
+ compact_event,
7
+ compact_mapping,
8
+ compact_status,
9
+ compact_task,
10
+ compact_value,
11
+ )
12
+ from team_agent.status.constants import (
13
+ APPROVAL_SCAN_LINES,
14
+ PEEK_MAX_LINES,
15
+ PEEK_MAX_MATCHES,
16
+ PEEK_SEARCH_SCAN_LINES,
17
+ PENDING_DELIVERY_STATUSES,
18
+ STATUS_EVENT_LIMIT,
19
+ STATUS_TEXT_LIMIT,
20
+ )
21
+ from team_agent.status.inbox import format_inbox, inbox
22
+ from team_agent.status.peek import (
23
+ format_search_matches,
24
+ peek,
25
+ search_lines,
26
+ validate_line_count,
27
+ )
28
+ from team_agent.status.queries import (
29
+ format_status,
30
+ latest_result_summaries,
31
+ queued_message_statuses,
32
+ result_summary_from_row,
33
+ status,
34
+ )
35
+
36
+ __all__ = [
37
+ "APPROVAL_SCAN_LINES",
38
+ "PEEK_MAX_LINES",
39
+ "PEEK_MAX_MATCHES",
40
+ "PEEK_SEARCH_SCAN_LINES",
41
+ "PENDING_DELIVERY_STATUSES",
42
+ "STATUS_EVENT_LIMIT",
43
+ "STATUS_TEXT_LIMIT",
44
+ "approvals",
45
+ "compact_agent_state",
46
+ "compact_event",
47
+ "compact_mapping",
48
+ "compact_status",
49
+ "compact_task",
50
+ "compact_value",
51
+ "format_approvals",
52
+ "format_inbox",
53
+ "format_search_matches",
54
+ "format_status",
55
+ "inbox",
56
+ "latest_result_summaries",
57
+ "peek",
58
+ "queued_message_statuses",
59
+ "result_summary_from_row",
60
+ "search_lines",
61
+ "status",
62
+ "validate_line_count",
63
+ ]