@team-agent/installer 0.1.11 → 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/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 -5893
- 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 -858
- package/src/team_agent/mcp_server.py +0 -579
- package/src/team_agent/profiles.py +0 -882
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from team_agent.state import load_runtime_state
|
|
7
|
+
from team_agent.status.constants import APPROVAL_SCAN_LINES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def approvals(workspace: Path, agent_id: str | None = None) -> dict[str, Any]:
|
|
11
|
+
from team_agent.runtime import RuntimeError, _extract_approval_prompt, _tmux_window_exists, run_cmd
|
|
12
|
+
state = load_runtime_state(workspace)
|
|
13
|
+
session_name = state.get("session_name")
|
|
14
|
+
approvals_found: list[dict[str, Any]] = []
|
|
15
|
+
agents = state.get("agents", {})
|
|
16
|
+
target_ids = [agent_id] if agent_id else sorted(agents)
|
|
17
|
+
for target_id in target_ids:
|
|
18
|
+
agent = agents.get(target_id)
|
|
19
|
+
if not agent:
|
|
20
|
+
raise RuntimeError(f"unknown agent id: {target_id}")
|
|
21
|
+
window = agent.get("window", target_id)
|
|
22
|
+
if not session_name or not _tmux_window_exists(session_name, window):
|
|
23
|
+
continue
|
|
24
|
+
proc = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{APPROVAL_SCAN_LINES}", "-t", f"{session_name}:{window}"], timeout=5)
|
|
25
|
+
if proc.returncode != 0:
|
|
26
|
+
continue
|
|
27
|
+
prompt = _extract_approval_prompt(target_id, proc.stdout)
|
|
28
|
+
if prompt:
|
|
29
|
+
approvals_found.append(prompt)
|
|
30
|
+
return {
|
|
31
|
+
"ok": True,
|
|
32
|
+
"waiting": bool(approvals_found),
|
|
33
|
+
"waiting_count": len(approvals_found),
|
|
34
|
+
"approvals": approvals_found,
|
|
35
|
+
"scan": {"mode": "tail", "lines": APPROVAL_SCAN_LINES, "raw_output": False},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_approvals(workspace: Path, agent_id: str | None = None) -> str:
|
|
40
|
+
result = approvals(workspace, agent_id=agent_id)
|
|
41
|
+
if not result["approvals"]:
|
|
42
|
+
return "No pending approvals."
|
|
43
|
+
lines: list[str] = []
|
|
44
|
+
for item in result["approvals"]:
|
|
45
|
+
detail = item.get("tool") or item.get("command") or item.get("kind")
|
|
46
|
+
lines.append(f"{item['agent_id']}: {item['state']} {item['kind']} {detail}".rstrip())
|
|
47
|
+
if item.get("prompt"):
|
|
48
|
+
lines.append(f" prompt: {item['prompt']}")
|
|
49
|
+
if item.get("choices"):
|
|
50
|
+
lines.append(" choices: " + "; ".join(item["choices"]))
|
|
51
|
+
lines.append(" raw terminal output omitted; use debug-only peek with --search/--tail/--head if the user explicitly asks.")
|
|
52
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from team_agent.status.constants import STATUS_EVENT_LIMIT, STATUS_TEXT_LIMIT
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compact_status(data: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
return {
|
|
10
|
+
"team": data.get("team"),
|
|
11
|
+
"session_name": data.get("session_name"),
|
|
12
|
+
"tmux_session_present": data.get("tmux_session_present"),
|
|
13
|
+
"leader_receiver": compact_mapping(
|
|
14
|
+
data.get("leader_receiver", {}),
|
|
15
|
+
{
|
|
16
|
+
"status",
|
|
17
|
+
"provider",
|
|
18
|
+
"mode",
|
|
19
|
+
"session_name",
|
|
20
|
+
"window_name",
|
|
21
|
+
"pane_id",
|
|
22
|
+
"pane_current_command",
|
|
23
|
+
},
|
|
24
|
+
),
|
|
25
|
+
"agents": {
|
|
26
|
+
agent_id: compact_agent_state(agent_id, agent)
|
|
27
|
+
for agent_id, agent in (data.get("agents") or {}).items()
|
|
28
|
+
},
|
|
29
|
+
"agent_health": data.get("agent_health", {}),
|
|
30
|
+
"tasks": [compact_task(task) for task in data.get("tasks", [])],
|
|
31
|
+
"messages": data.get("messages", {}),
|
|
32
|
+
"queued_messages": data.get("queued_messages", [])[:8],
|
|
33
|
+
"results": data.get("results", {}),
|
|
34
|
+
"latest_results": data.get("latest_results", [])[:5],
|
|
35
|
+
"coordinator": compact_mapping(data.get("coordinator", {}), {"status", "pid", "metadata_ok", "schema_ok"}),
|
|
36
|
+
"last_events": [compact_event(event) for event in data.get("last_events", [])[-STATUS_EVENT_LIMIT:]],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compact_agent_state(agent_id: str, agent: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
display = agent.get("display") or {}
|
|
42
|
+
result = compact_mapping(
|
|
43
|
+
agent,
|
|
44
|
+
{
|
|
45
|
+
"agent_id",
|
|
46
|
+
"status",
|
|
47
|
+
"provider",
|
|
48
|
+
"model",
|
|
49
|
+
"tmux_window_present",
|
|
50
|
+
"session_id",
|
|
51
|
+
"captured_via",
|
|
52
|
+
"attribution_confidence",
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
result.setdefault("agent_id", agent_id)
|
|
56
|
+
if display:
|
|
57
|
+
result["display"] = compact_mapping(
|
|
58
|
+
display,
|
|
59
|
+
{
|
|
60
|
+
"backend",
|
|
61
|
+
"status",
|
|
62
|
+
"workspace_window",
|
|
63
|
+
"pane_id",
|
|
64
|
+
"pid",
|
|
65
|
+
"pids",
|
|
66
|
+
"reason",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compact_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
return compact_mapping(
|
|
74
|
+
task,
|
|
75
|
+
{
|
|
76
|
+
"id",
|
|
77
|
+
"title",
|
|
78
|
+
"status",
|
|
79
|
+
"assignee",
|
|
80
|
+
"type",
|
|
81
|
+
"risk",
|
|
82
|
+
"accepted_result_id",
|
|
83
|
+
"last_result_summary",
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compact_event(event: dict[str, Any]) -> dict[str, Any]:
|
|
89
|
+
skipped = {"command", "payload", "launch_args", "content", "prompt", "developer_instructions"}
|
|
90
|
+
kept = {
|
|
91
|
+
"event",
|
|
92
|
+
"ts",
|
|
93
|
+
"agent_id",
|
|
94
|
+
"task_id",
|
|
95
|
+
"message_id",
|
|
96
|
+
"result_id",
|
|
97
|
+
"status",
|
|
98
|
+
"ok",
|
|
99
|
+
"reason",
|
|
100
|
+
"error",
|
|
101
|
+
"session",
|
|
102
|
+
"window",
|
|
103
|
+
"target",
|
|
104
|
+
"backend",
|
|
105
|
+
"workspace_window",
|
|
106
|
+
"pane_id",
|
|
107
|
+
"restart_mode",
|
|
108
|
+
"provider",
|
|
109
|
+
"delivery_status",
|
|
110
|
+
"warning",
|
|
111
|
+
"collected",
|
|
112
|
+
"notified",
|
|
113
|
+
"lock",
|
|
114
|
+
"waited_sec",
|
|
115
|
+
"once",
|
|
116
|
+
"pid",
|
|
117
|
+
}
|
|
118
|
+
result: dict[str, Any] = {}
|
|
119
|
+
for key, value in event.items():
|
|
120
|
+
if key in skipped or key not in kept | {"agents", "coordinator"}:
|
|
121
|
+
continue
|
|
122
|
+
if key == "agents" and isinstance(value, list):
|
|
123
|
+
result["agent_count"] = len(value)
|
|
124
|
+
result["agents"] = [
|
|
125
|
+
compact_mapping(item, {"agent_id", "restart_mode", "session_id"})
|
|
126
|
+
for item in value[:8]
|
|
127
|
+
if isinstance(item, dict)
|
|
128
|
+
]
|
|
129
|
+
continue
|
|
130
|
+
result[key] = compact_value(value)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def compact_mapping(source: Any, keys: set[str]) -> dict[str, Any]:
|
|
135
|
+
if not isinstance(source, dict):
|
|
136
|
+
return {}
|
|
137
|
+
return {key: compact_value(source[key]) for key in keys if key in source}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compact_value(value: Any) -> Any:
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
return value if len(value) <= STATUS_TEXT_LIMIT else value[: STATUS_TEXT_LIMIT - 1] + "…"
|
|
143
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, list):
|
|
146
|
+
if all(isinstance(item, (str, int, float, bool)) or item is None for item in value):
|
|
147
|
+
compact = [compact_value(item) for item in value[:8]]
|
|
148
|
+
if len(value) > 8:
|
|
149
|
+
compact.append(f"... {len(value) - 8} more")
|
|
150
|
+
return compact
|
|
151
|
+
return f"{len(value)} item(s)"
|
|
152
|
+
if isinstance(value, dict):
|
|
153
|
+
return {
|
|
154
|
+
key: compact_value(item)
|
|
155
|
+
for key, item in value.items()
|
|
156
|
+
if key not in {"command", "payload", "launch_args", "content", "prompt", "developer_instructions"}
|
|
157
|
+
}
|
|
158
|
+
return str(value)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
STATUS_TEXT_LIMIT = 240
|
|
5
|
+
STATUS_EVENT_LIMIT = 3
|
|
6
|
+
PEEK_MAX_LINES = 80
|
|
7
|
+
PEEK_SEARCH_SCAN_LINES = 300
|
|
8
|
+
PEEK_MAX_MATCHES = 5
|
|
9
|
+
APPROVAL_SCAN_LINES = 120
|
|
10
|
+
|
|
11
|
+
PENDING_DELIVERY_STATUSES = {
|
|
12
|
+
"pending",
|
|
13
|
+
"accepted",
|
|
14
|
+
"queued_until_idle",
|
|
15
|
+
"queued_until_start",
|
|
16
|
+
"queued_stopped",
|
|
17
|
+
"queued_pane_missing",
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from team_agent.message_store import MessageStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def inbox(workspace: Path, agent_id: str, limit: int = 20) -> dict[str, Any]:
|
|
10
|
+
rows = MessageStore(workspace).inbox(agent_id, limit=limit)
|
|
11
|
+
return {"ok": True, "agent_id": agent_id, "messages": rows}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_inbox(workspace: Path, agent_id: str, limit: int = 20) -> str:
|
|
15
|
+
store = MessageStore(workspace)
|
|
16
|
+
rows = store.inbox(agent_id, limit=limit)
|
|
17
|
+
result_counts = store.result_counts()
|
|
18
|
+
note = "final results are not in inbox; use team-agent collect"
|
|
19
|
+
if result_counts.get("uncollected", 0):
|
|
20
|
+
note += f" ({result_counts['uncollected']} uncollected result(s) pending)"
|
|
21
|
+
if not rows:
|
|
22
|
+
return f"{agent_id}: no messages\n{note}"
|
|
23
|
+
lines = [
|
|
24
|
+
f"{row['created_at']} {row['sender']} -> {row['recipient']} {row['status']}: {row['content']}"
|
|
25
|
+
for row in rows
|
|
26
|
+
]
|
|
27
|
+
lines.append(note)
|
|
28
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from team_agent.state import load_runtime_state
|
|
7
|
+
from team_agent.status.constants import (
|
|
8
|
+
PEEK_MAX_LINES,
|
|
9
|
+
PEEK_MAX_MATCHES,
|
|
10
|
+
PEEK_SEARCH_SCAN_LINES,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def peek(
|
|
15
|
+
workspace: Path,
|
|
16
|
+
agent_id: str,
|
|
17
|
+
*,
|
|
18
|
+
head: int | None = None,
|
|
19
|
+
tail: int | None = None,
|
|
20
|
+
search: str | None = None,
|
|
21
|
+
context: int = 3,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
from team_agent.runtime import RuntimeError, _tmux_window_exists, run_cmd
|
|
24
|
+
modes = [head is not None, tail is not None, search is not None]
|
|
25
|
+
if sum(modes) != 1:
|
|
26
|
+
raise RuntimeError("peek requires exactly one of --head, --tail, or --search")
|
|
27
|
+
if head is not None:
|
|
28
|
+
validate_line_count("--head", head)
|
|
29
|
+
if tail is not None:
|
|
30
|
+
validate_line_count("--tail", tail)
|
|
31
|
+
if search is not None and not search.strip():
|
|
32
|
+
raise RuntimeError("--search must not be empty")
|
|
33
|
+
if context < 0 or context > 10:
|
|
34
|
+
raise RuntimeError("--context must be between 0 and 10")
|
|
35
|
+
state = load_runtime_state(workspace)
|
|
36
|
+
agent = state.get("agents", {}).get(agent_id)
|
|
37
|
+
if not agent:
|
|
38
|
+
raise RuntimeError(f"unknown agent id: {agent_id}")
|
|
39
|
+
session_name = state.get("session_name")
|
|
40
|
+
window = agent.get("window", agent_id)
|
|
41
|
+
if not session_name or not _tmux_window_exists(session_name, window):
|
|
42
|
+
raise RuntimeError(f"agent terminal is not available: {agent_id}")
|
|
43
|
+
scan_lines = tail or PEEK_SEARCH_SCAN_LINES
|
|
44
|
+
proc = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{scan_lines}", "-t", f"{session_name}:{window}"], timeout=5)
|
|
45
|
+
if proc.returncode != 0:
|
|
46
|
+
raise RuntimeError(proc.stderr.strip() or f"capture failed for {agent_id}")
|
|
47
|
+
captured = proc.stdout.splitlines()
|
|
48
|
+
if head is not None:
|
|
49
|
+
selected = captured[:head]
|
|
50
|
+
return {
|
|
51
|
+
"ok": True,
|
|
52
|
+
"agent_id": agent_id,
|
|
53
|
+
"mode": "head",
|
|
54
|
+
"lines": head,
|
|
55
|
+
"scanned_lines": scan_lines,
|
|
56
|
+
"text": "\n".join(selected),
|
|
57
|
+
}
|
|
58
|
+
if tail is not None:
|
|
59
|
+
return {
|
|
60
|
+
"ok": True,
|
|
61
|
+
"agent_id": agent_id,
|
|
62
|
+
"mode": "tail",
|
|
63
|
+
"lines": tail,
|
|
64
|
+
"scanned_lines": scan_lines,
|
|
65
|
+
"text": "\n".join(captured[-tail:]),
|
|
66
|
+
}
|
|
67
|
+
assert search is not None
|
|
68
|
+
matches = search_lines(captured, search, context)
|
|
69
|
+
return {
|
|
70
|
+
"ok": True,
|
|
71
|
+
"agent_id": agent_id,
|
|
72
|
+
"mode": "search",
|
|
73
|
+
"search": search,
|
|
74
|
+
"context": context,
|
|
75
|
+
"scanned_lines": scan_lines,
|
|
76
|
+
"matches": matches,
|
|
77
|
+
"truncated": len(matches) >= PEEK_MAX_MATCHES,
|
|
78
|
+
"text": format_search_matches(matches),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_line_count(flag: str, value: int) -> None:
|
|
83
|
+
from team_agent.runtime import RuntimeError
|
|
84
|
+
if value < 1 or value > PEEK_MAX_LINES:
|
|
85
|
+
raise RuntimeError(f"{flag} must be between 1 and {PEEK_MAX_LINES}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def search_lines(lines: list[str], needle: str, context: int) -> list[dict[str, Any]]:
|
|
89
|
+
needle_lower = needle.lower()
|
|
90
|
+
matches: list[dict[str, Any]] = []
|
|
91
|
+
used_ranges: list[tuple[int, int]] = []
|
|
92
|
+
for index, line in enumerate(lines):
|
|
93
|
+
if needle_lower not in line.lower():
|
|
94
|
+
continue
|
|
95
|
+
start = max(0, index - context)
|
|
96
|
+
end = min(len(lines), index + context + 1)
|
|
97
|
+
if used_ranges and start <= used_ranges[-1][1]:
|
|
98
|
+
previous = matches[-1]
|
|
99
|
+
previous["lines"] = lines[previous["start_line"] - 1 : end]
|
|
100
|
+
previous["end_line"] = end
|
|
101
|
+
used_ranges[-1] = (previous["start_line"] - 1, end)
|
|
102
|
+
else:
|
|
103
|
+
matches.append({"line": index + 1, "start_line": start + 1, "end_line": end, "lines": lines[start:end]})
|
|
104
|
+
used_ranges.append((start, end))
|
|
105
|
+
if len(matches) >= PEEK_MAX_MATCHES:
|
|
106
|
+
break
|
|
107
|
+
return matches
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_search_matches(matches: list[dict[str, Any]]) -> str:
|
|
111
|
+
if not matches:
|
|
112
|
+
return "no matches"
|
|
113
|
+
blocks: list[str] = []
|
|
114
|
+
for match in matches:
|
|
115
|
+
blocks.append(f"match line {match['line']} ({match['start_line']}-{match['end_line']}):")
|
|
116
|
+
blocks.extend(str(line) for line in match["lines"])
|
|
117
|
+
return "\n".join(blocks)
|
|
@@ -0,0 +1,168 @@
|
|
|
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.message_store import MessageStore
|
|
9
|
+
from team_agent.state import load_runtime_state, save_runtime_state
|
|
10
|
+
from team_agent.status.compact import compact_status
|
|
11
|
+
from team_agent.status.constants import PENDING_DELIVERY_STATUSES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def status(workspace: Path, as_json: bool = False, *, compact: bool = False) -> dict[str, Any]:
|
|
15
|
+
from team_agent.runtime import (
|
|
16
|
+
_capture_missing_sessions,
|
|
17
|
+
_handle_provider_startup_prompts,
|
|
18
|
+
_refresh_agent_runtime_statuses,
|
|
19
|
+
_sync_agent_health,
|
|
20
|
+
_tmux_session_exists,
|
|
21
|
+
coordinator_health,
|
|
22
|
+
)
|
|
23
|
+
_ = as_json
|
|
24
|
+
state = load_runtime_state(workspace)
|
|
25
|
+
store = MessageStore(workspace)
|
|
26
|
+
event_log = EventLog(workspace)
|
|
27
|
+
_capture_missing_sessions(workspace, state, event_log, timeout_s=0.0, log_miss=False)
|
|
28
|
+
_refresh_agent_runtime_statuses(workspace, state, event_log)
|
|
29
|
+
_handle_provider_startup_prompts(workspace, state, event_log)
|
|
30
|
+
_sync_agent_health(workspace, state, store)
|
|
31
|
+
save_runtime_state(workspace, state)
|
|
32
|
+
session_name = state.get("session_name")
|
|
33
|
+
tmux_exists = _tmux_session_exists(session_name) if session_name else False
|
|
34
|
+
result = {
|
|
35
|
+
"team": state.get("leader", {}).get("id", "leader"),
|
|
36
|
+
"session_name": session_name,
|
|
37
|
+
"tmux_session_present": tmux_exists,
|
|
38
|
+
"leader_receiver": state.get("leader_receiver", {}),
|
|
39
|
+
"agents": state.get("agents", {}),
|
|
40
|
+
"agent_health": store.agent_health(),
|
|
41
|
+
"tasks": state.get("tasks", []),
|
|
42
|
+
"messages": store.message_counts(),
|
|
43
|
+
"queued_messages": queued_message_statuses(store.messages()),
|
|
44
|
+
"results": store.result_counts(),
|
|
45
|
+
"latest_results": latest_result_summaries(store),
|
|
46
|
+
"coordinator": coordinator_health(workspace),
|
|
47
|
+
"last_events": EventLog(workspace).tail(10),
|
|
48
|
+
}
|
|
49
|
+
return compact_status(result) if compact else result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def latest_result_summaries(store: MessageStore, limit: int = 5) -> list[dict[str, Any]]:
|
|
53
|
+
summaries: list[dict[str, Any]] = []
|
|
54
|
+
for row in store.latest_results(limit=limit):
|
|
55
|
+
summary = result_summary_from_row(row)
|
|
56
|
+
if summary:
|
|
57
|
+
summaries.append(summary)
|
|
58
|
+
return summaries
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def result_summary_from_row(row: dict[str, Any]) -> dict[str, Any] | None:
|
|
62
|
+
try:
|
|
63
|
+
envelope = json.loads(row["envelope"]) if isinstance(row.get("envelope"), str) else row.get("envelope")
|
|
64
|
+
except (TypeError, json.JSONDecodeError):
|
|
65
|
+
return None
|
|
66
|
+
if not isinstance(envelope, dict):
|
|
67
|
+
return None
|
|
68
|
+
return {
|
|
69
|
+
"result_id": row.get("result_id"),
|
|
70
|
+
"task_id": envelope.get("task_id") or row.get("task_id"),
|
|
71
|
+
"agent_id": envelope.get("agent_id") or row.get("agent_id"),
|
|
72
|
+
"status": envelope.get("status") or row.get("status"),
|
|
73
|
+
"summary": envelope.get("summary"),
|
|
74
|
+
"created_at": row.get("created_at"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def queued_message_statuses(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
79
|
+
from team_agent.runtime import _age_text
|
|
80
|
+
visible_statuses = PENDING_DELIVERY_STATUSES | {"target_resolved", "delivery_blocked", "injected_unverified"}
|
|
81
|
+
queued: list[dict[str, Any]] = []
|
|
82
|
+
for row in messages:
|
|
83
|
+
if row.get("status") not in visible_statuses:
|
|
84
|
+
continue
|
|
85
|
+
queued.append(
|
|
86
|
+
{
|
|
87
|
+
"message_id": row.get("message_id"),
|
|
88
|
+
"recipient": row.get("recipient"),
|
|
89
|
+
"sender": row.get("sender"),
|
|
90
|
+
"status": row.get("status"),
|
|
91
|
+
"reason": row.get("error"),
|
|
92
|
+
"age": _age_text(row.get("created_at")),
|
|
93
|
+
"attempts": row.get("delivery_attempts") or 0,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return queued
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_status(workspace: Path, agent_id: str | None = None) -> str:
|
|
100
|
+
from team_agent.runtime import RuntimeError, _agent_health_status, _age_text, _current_task_for_agent
|
|
101
|
+
data = status(workspace, as_json=True)
|
|
102
|
+
health = data.get("agent_health", {})
|
|
103
|
+
tasks = data.get("tasks", [])
|
|
104
|
+
if agent_id:
|
|
105
|
+
if agent_id not in data.get("agents", {}) and agent_id not in health:
|
|
106
|
+
raise RuntimeError(f"unknown agent id: {agent_id}")
|
|
107
|
+
agent = data.get("agents", {}).get(agent_id, {})
|
|
108
|
+
row = health.get(agent_id, {})
|
|
109
|
+
task_id = _current_task_for_agent(tasks, agent_id) or "-"
|
|
110
|
+
inbox_rows = MessageStore(workspace).inbox(agent_id, limit=3)
|
|
111
|
+
lines = [
|
|
112
|
+
f"{agent_id} {row.get('status', _agent_health_status(agent))}",
|
|
113
|
+
f" provider: {agent.get('provider', '-')}",
|
|
114
|
+
f" model: {agent.get('model', '-')}",
|
|
115
|
+
f" profile: {agent.get('profile', '-')}",
|
|
116
|
+
f" session_id: {agent.get('session_id') or '-'}",
|
|
117
|
+
f" captured_via: {agent.get('captured_via') or '-'}",
|
|
118
|
+
f" attribution_confidence: {agent.get('attribution_confidence') or '-'}",
|
|
119
|
+
f" task: {task_id}",
|
|
120
|
+
f" handoff: {agent.get('handoff_path', '-')}",
|
|
121
|
+
" recent messages:",
|
|
122
|
+
]
|
|
123
|
+
if inbox_rows:
|
|
124
|
+
for item in inbox_rows:
|
|
125
|
+
lines.append(
|
|
126
|
+
f" {item['created_at']} {item['sender']} -> {item['recipient']} "
|
|
127
|
+
f"{item['status']}: {item['content'][:120]}"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
lines.append(" none")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
agents = data.get("agents", {})
|
|
134
|
+
state_name = "up" if data.get("tmux_session_present") else "down"
|
|
135
|
+
results = data.get("results", {})
|
|
136
|
+
lines = [
|
|
137
|
+
f"team {data.get('session_name') or '-'} ({state_name})",
|
|
138
|
+
(
|
|
139
|
+
"results "
|
|
140
|
+
f"total {results.get('total', 0)} "
|
|
141
|
+
f"uncollected {results.get('uncollected', 0)} "
|
|
142
|
+
f"collected {results.get('collected', 0)} "
|
|
143
|
+
f"invalid {results.get('invalid', 0)}"
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
if results.get("uncollected", 0):
|
|
147
|
+
lines.append(" final result pending in result store; run team-agent collect")
|
|
148
|
+
queued_messages = data.get("queued_messages") or []
|
|
149
|
+
if queued_messages:
|
|
150
|
+
lines.append("queued messages")
|
|
151
|
+
for item in queued_messages[:8]:
|
|
152
|
+
reason = item.get("reason") or "-"
|
|
153
|
+
lines.append(
|
|
154
|
+
f" {item.get('message_id')} -> {item.get('recipient')} "
|
|
155
|
+
f"{item.get('status')} age {item.get('age')} attempts {item.get('attempts')} reason {reason}"
|
|
156
|
+
)
|
|
157
|
+
for aid in sorted(agents):
|
|
158
|
+
agent = agents[aid]
|
|
159
|
+
row = health.get(aid, {})
|
|
160
|
+
status_value = row.get("status") or _agent_health_status(agent)
|
|
161
|
+
task_id = _current_task_for_agent(tasks, aid) or "-"
|
|
162
|
+
context = row.get("context_usage_pct")
|
|
163
|
+
context_text = f"ctx {context}%" if context is not None else "ctx -"
|
|
164
|
+
last = _age_text(row.get("last_output_at"))
|
|
165
|
+
session_text = f"sid {agent.get('session_id') or '-'}"
|
|
166
|
+
capture_text = f"via {agent.get('captured_via') or '-'} {agent.get('attribution_confidence') or '-'}"
|
|
167
|
+
lines.append(f" {aid} {status_value} {task_id} {context_text} last {last} {session_text} {capture_text}")
|
|
168
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
RunCommand = Callable[[list[str], int], subprocess.CompletedProcess[str]]
|
|
9
|
+
SessionExists = Callable[[str | None], bool]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_cmd(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
|
13
|
+
return subprocess.run(args, text=True, capture_output=True, timeout=timeout, check=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def shutil_which(command: str) -> str | None:
|
|
17
|
+
return shutil.which(command)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tmux_session_exists(session_name: str | None, *, run: RunCommand = run_cmd) -> bool:
|
|
21
|
+
if not session_name:
|
|
22
|
+
return False
|
|
23
|
+
proc = run(["tmux", "has-session", "-t", session_name], timeout=5)
|
|
24
|
+
return proc.returncode == 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tmux_window_exists(session_name: str | None, window: str | None, *, run: RunCommand = run_cmd) -> bool:
|
|
28
|
+
if not session_name or not window:
|
|
29
|
+
return False
|
|
30
|
+
proc = run(["tmux", "list-windows", "-t", session_name, "-F", "#{window_name}"], timeout=5)
|
|
31
|
+
if proc.returncode != 0:
|
|
32
|
+
return False
|
|
33
|
+
return window in proc.stdout.splitlines()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tmux_start_command_for_agent_window(
|
|
37
|
+
session_name: str,
|
|
38
|
+
window_name: str,
|
|
39
|
+
command: str,
|
|
40
|
+
*,
|
|
41
|
+
session_exists: SessionExists = tmux_session_exists,
|
|
42
|
+
) -> tuple[list[str], str]:
|
|
43
|
+
if session_exists(session_name):
|
|
44
|
+
return ["tmux", "new-window", "-t", session_name, "-n", window_name, "sh", "-lc", command], "new-window"
|
|
45
|
+
return ["tmux", "new-session", "-d", "-s", session_name, "-n", window_name, "sh", "-lc", command], "new-session"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tmux_stdout_last_line(stdout: str) -> str | None:
|
|
49
|
+
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
50
|
+
return lines[-1] if lines else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tmux_truthy(value: str) -> int:
|
|
54
|
+
try:
|
|
55
|
+
return 1 if int(value) > 0 else 0
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return 1 if value and value != "0" else 0
|