@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.
- package/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/skills/team-agent/SKILL.md +1 -1
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +135 -0
- package/src/team_agent/cli/commands.py +335 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +470 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +319 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/start.py +360 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +128 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +217 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +418 -0
- package/src/team_agent/messaging/send.py +493 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +802 -5740
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +204 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -857
- package/src/team_agent/mcp_server.py +0 -579
- 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
|
-
|
|
147
|
-
or
|
|
148
|
-
or "
|
|
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
|
package/src/team_agent/spec.py
CHANGED
|
@@ -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
|
|
package/src/team_agent/state.py
CHANGED
|
@@ -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.
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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
|
+
]
|