@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.
Files changed (110) 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/src/team_agent/approvals/__init__.py +65 -0
  5. package/src/team_agent/approvals/constants.py +6 -0
  6. package/src/team_agent/approvals/parsing.py +176 -0
  7. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  8. package/src/team_agent/approvals/status.py +165 -0
  9. package/src/team_agent/cli/__init__.py +135 -0
  10. package/src/team_agent/cli/commands.py +335 -0
  11. package/src/team_agent/cli/e2e.py +202 -0
  12. package/src/team_agent/cli/helpers.py +137 -0
  13. package/src/team_agent/cli/parser.py +470 -0
  14. package/src/team_agent/compiler.py +98 -33
  15. package/src/team_agent/coordinator/__init__.py +53 -0
  16. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  17. package/src/team_agent/coordinator/lifecycle.py +319 -0
  18. package/src/team_agent/coordinator/metadata.py +61 -0
  19. package/src/team_agent/coordinator/paths.py +17 -0
  20. package/src/team_agent/diagnose/__init__.py +48 -0
  21. package/src/team_agent/diagnose/checks.py +101 -0
  22. package/src/team_agent/diagnose/health.py +241 -0
  23. package/src/team_agent/diagnose/preflight.py +194 -0
  24. package/src/team_agent/diagnose/quick_start.py +233 -0
  25. package/src/team_agent/display/__init__.py +61 -0
  26. package/src/team_agent/display/close.py +147 -0
  27. package/src/team_agent/display/ghostty.py +77 -0
  28. package/src/team_agent/display/worker_window.py +110 -0
  29. package/src/team_agent/display/workspace.py +473 -0
  30. package/src/team_agent/launch/__init__.py +41 -0
  31. package/src/team_agent/launch/bootstrap.py +85 -0
  32. package/src/team_agent/launch/config.py +106 -0
  33. package/src/team_agent/launch/core.py +291 -0
  34. package/src/team_agent/launch/requirements.py +57 -0
  35. package/src/team_agent/leader/__init__.py +320 -0
  36. package/src/team_agent/lifecycle/__init__.py +5 -0
  37. package/src/team_agent/lifecycle/agents.py +226 -0
  38. package/src/team_agent/lifecycle/operations.py +321 -0
  39. package/src/team_agent/lifecycle/start.py +360 -0
  40. package/src/team_agent/mcp_server/__init__.py +42 -0
  41. package/src/team_agent/mcp_server/__main__.py +7 -0
  42. package/src/team_agent/mcp_server/contracts.py +148 -0
  43. package/src/team_agent/mcp_server/normalize.py +257 -0
  44. package/src/team_agent/mcp_server/server.py +150 -0
  45. package/src/team_agent/mcp_server/tools.py +205 -0
  46. package/src/team_agent/message_store/__init__.py +23 -0
  47. package/src/team_agent/message_store/agent_health.py +109 -0
  48. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  49. package/src/team_agent/message_store/result_watchers.py +102 -0
  50. package/src/team_agent/message_store/schema.py +266 -0
  51. package/src/team_agent/messaging/__init__.py +1 -0
  52. package/src/team_agent/messaging/activity_detector.py +190 -0
  53. package/src/team_agent/messaging/delivery.py +128 -0
  54. package/src/team_agent/messaging/deps.py +263 -0
  55. package/src/team_agent/messaging/idle_alerts.py +217 -0
  56. package/src/team_agent/messaging/internal_delivery.py +46 -0
  57. package/src/team_agent/messaging/leader.py +317 -0
  58. package/src/team_agent/messaging/leader_panes.py +343 -0
  59. package/src/team_agent/messaging/result_delivery.py +300 -0
  60. package/src/team_agent/messaging/results.py +456 -0
  61. package/src/team_agent/messaging/scheduler.py +418 -0
  62. package/src/team_agent/messaging/send.py +493 -0
  63. package/src/team_agent/messaging/tmux_io.py +337 -0
  64. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  65. package/src/team_agent/orchestrator/__init__.py +376 -0
  66. package/src/team_agent/orchestrator/plan.py +122 -0
  67. package/src/team_agent/orchestrator/state.py +128 -0
  68. package/src/team_agent/profiles/__init__.py +82 -0
  69. package/src/team_agent/profiles/constants.py +19 -0
  70. package/src/team_agent/profiles/core.py +407 -0
  71. package/src/team_agent/profiles/helpers.py +69 -0
  72. package/src/team_agent/profiles/provider_env.py +188 -0
  73. package/src/team_agent/profiles/smoke.py +201 -0
  74. package/src/team_agent/provider_cli/__init__.py +43 -0
  75. package/src/team_agent/provider_cli/adapter.py +167 -0
  76. package/src/team_agent/provider_cli/base.py +48 -0
  77. package/src/team_agent/provider_cli/claude.py +457 -0
  78. package/src/team_agent/provider_cli/codex.py +319 -0
  79. package/src/team_agent/provider_cli/copilot.py +8 -0
  80. package/src/team_agent/provider_cli/fake.py +39 -0
  81. package/src/team_agent/provider_cli/gemini.py +95 -0
  82. package/src/team_agent/provider_cli/opencode.py +8 -0
  83. package/src/team_agent/provider_cli/prompt.py +62 -0
  84. package/src/team_agent/provider_cli/registry.py +18 -0
  85. package/src/team_agent/provider_cli/unsupported.py +32 -0
  86. package/src/team_agent/providers.py +67 -949
  87. package/src/team_agent/quality_gates.py +104 -0
  88. package/src/team_agent/restart/__init__.py +34 -0
  89. package/src/team_agent/restart/orchestration.py +328 -0
  90. package/src/team_agent/restart/selection.py +89 -0
  91. package/src/team_agent/restart/snapshot.py +70 -0
  92. package/src/team_agent/runtime.py +802 -5893
  93. package/src/team_agent/rust_core.py +22 -5
  94. package/src/team_agent/sessions/__init__.py +25 -0
  95. package/src/team_agent/sessions/capture.py +93 -0
  96. package/src/team_agent/sessions/inventory.py +44 -0
  97. package/src/team_agent/sessions/resume.py +135 -0
  98. package/src/team_agent/spec.py +3 -1
  99. package/src/team_agent/state.py +204 -4
  100. package/src/team_agent/status/__init__.py +63 -0
  101. package/src/team_agent/status/approvals.py +52 -0
  102. package/src/team_agent/status/compact.py +158 -0
  103. package/src/team_agent/status/constants.py +18 -0
  104. package/src/team_agent/status/inbox.py +28 -0
  105. package/src/team_agent/status/peek.py +117 -0
  106. package/src/team_agent/status/queries.py +168 -0
  107. package/src/team_agent/terminal.py +57 -0
  108. package/src/team_agent/cli.py +0 -858
  109. package/src/team_agent/mcp_server.py +0 -579
  110. 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