@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
@@ -215,15 +215,54 @@ pub fn validate_profile(profile: &Profile) -> Result<(), Vec<String>> {
215
215
  }
216
216
 
217
217
  pub fn contains_inline_secret(value: &str) -> bool {
218
- let lower = value.to_ascii_lowercase();
219
- lower.contains("api_key")
220
- || lower.contains("apikey")
221
- || lower.contains("token")
222
- || lower.contains("secret")
218
+ contains_secret_assignment(value)
219
+ || contains_bearer_secret(value)
220
+ || value
221
+ .split_whitespace()
222
+ .any(|chunk| chunk.starts_with("sk-") || looks_base64_secret(chunk))
223
223
  || value.starts_with("sk-")
224
224
  || looks_base64_secret(value)
225
225
  }
226
226
 
227
+ fn contains_secret_assignment(value: &str) -> bool {
228
+ for line in value.lines() {
229
+ for separator in ['=', ':'] {
230
+ let Some((key, raw)) = line.split_once(separator) else {
231
+ continue;
232
+ };
233
+ let normalized: String = key
234
+ .to_ascii_lowercase()
235
+ .chars()
236
+ .filter(|ch| ch.is_ascii_alphanumeric())
237
+ .collect();
238
+ if !matches!(
239
+ normalized.as_str(),
240
+ "apikey" | "token" | "secret" | "password" | "credential"
241
+ ) {
242
+ continue;
243
+ }
244
+ let candidate = raw.trim().trim_matches(|ch| ch == '"' || ch == '\'');
245
+ if candidate.starts_with("sk-") || candidate.len() >= 8 || looks_base64_secret(candidate) {
246
+ return true;
247
+ }
248
+ }
249
+ }
250
+ false
251
+ }
252
+
253
+ fn contains_bearer_secret(value: &str) -> bool {
254
+ let mut previous_was_bearer = false;
255
+ for chunk in value.split_whitespace() {
256
+ if previous_was_bearer && chunk.len() >= 16 && chunk.chars().all(|ch| {
257
+ ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '~' | '+' | '/' | '=' | '-')
258
+ }) {
259
+ return true;
260
+ }
261
+ previous_was_bearer = chunk.eq_ignore_ascii_case("bearer");
262
+ }
263
+ false
264
+ }
265
+
227
266
  pub fn json_escape(input: &str) -> String {
228
267
  let mut out = String::new();
229
268
  for ch in input.chars() {
@@ -273,6 +312,12 @@ mod tests {
273
312
  assert!(!redacted.contains("sk-test"));
274
313
  }
275
314
 
315
+ #[test]
316
+ fn inline_secret_scan_allows_ordinary_token_prose() {
317
+ assert!(!contains_inline_secret("Use the visible token in logs for delivery evidence."));
318
+ assert!(contains_inline_secret("API_KEY=sk-inline-secret"));
319
+ }
320
+
276
321
  #[test]
277
322
  fn profile_rejects_inline_secret() {
278
323
  let profile = Profile {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -134,6 +134,7 @@
134
134
  "auth_mode": { "enum": ["subscription", "official_api", "compatible_api"] },
135
135
  "profile": { "type": "string", "minLength": 1 },
136
136
  "credential_ref": { "type": "string", "minLength": 1 },
137
+ "forked_from": { "type": "string", "minLength": 1 },
137
138
  "working_directory": { "type": "string", "minLength": 1 },
138
139
  "paused": { "type": "boolean" },
139
140
  "system_prompt": {
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.approvals.constants import (
4
+ INTERNAL_MCP_APPROVAL_CHOICE,
5
+ INTERNAL_MCP_AUTO_APPROVE_TOOLS,
6
+ STARTUP_PROMPT_RUNTIME_CHECK_LIMIT,
7
+ )
8
+ from team_agent.approvals.parsing import (
9
+ APPROVAL_CHOICE_RE,
10
+ active_approval_choice_index,
11
+ active_approval_control_index,
12
+ approval_choice_keys,
13
+ approval_prompt_fingerprint,
14
+ capture_has_approval_prompt,
15
+ capture_has_team_orchestrator_mcp_prompt,
16
+ choose_internal_mcp_approval_choice,
17
+ extract_approval_choices,
18
+ extract_approval_prompt,
19
+ extract_command_approval_subject,
20
+ is_approval_control_line,
21
+ line_is_approval_choice,
22
+ )
23
+ from team_agent.approvals.runtime_prompts import (
24
+ handle_internal_mcp_approval_prompt,
25
+ handle_provider_runtime_prompts,
26
+ handle_provider_startup_prompts,
27
+ submit_internal_mcp_approval,
28
+ )
29
+ from team_agent.approvals.status import (
30
+ age_text,
31
+ agent_health_status,
32
+ current_task_for_agent,
33
+ detect_provider_status,
34
+ refresh_agent_runtime_statuses,
35
+ sync_agent_health,
36
+ )
37
+
38
+ __all__ = [
39
+ "APPROVAL_CHOICE_RE",
40
+ "INTERNAL_MCP_APPROVAL_CHOICE",
41
+ "INTERNAL_MCP_AUTO_APPROVE_TOOLS",
42
+ "STARTUP_PROMPT_RUNTIME_CHECK_LIMIT",
43
+ "active_approval_choice_index",
44
+ "active_approval_control_index",
45
+ "age_text",
46
+ "agent_health_status",
47
+ "approval_choice_keys",
48
+ "approval_prompt_fingerprint",
49
+ "capture_has_approval_prompt",
50
+ "capture_has_team_orchestrator_mcp_prompt",
51
+ "choose_internal_mcp_approval_choice",
52
+ "current_task_for_agent",
53
+ "detect_provider_status",
54
+ "extract_approval_choices",
55
+ "extract_approval_prompt",
56
+ "extract_command_approval_subject",
57
+ "handle_internal_mcp_approval_prompt",
58
+ "handle_provider_runtime_prompts",
59
+ "handle_provider_startup_prompts",
60
+ "is_approval_control_line",
61
+ "line_is_approval_choice",
62
+ "refresh_agent_runtime_statuses",
63
+ "submit_internal_mcp_approval",
64
+ "sync_agent_health",
65
+ ]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ STARTUP_PROMPT_RUNTIME_CHECK_LIMIT = 3
5
+ INTERNAL_MCP_AUTO_APPROVE_TOOLS = {"send_message", "report_result", "get_team_status", "request_human"}
6
+ INTERNAL_MCP_APPROVAL_CHOICE = "Allow for this session"
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from typing import Any
7
+
8
+ from team_agent.approvals.constants import INTERNAL_MCP_APPROVAL_CHOICE
9
+
10
+
11
+ APPROVAL_CHOICE_RE = re.compile(r"(?:[›❯>]\s*)?(\d+)\.\s+(.+?)(?:\s{2,}.+)?$")
12
+
13
+
14
+ def capture_has_approval_prompt(text: str) -> bool:
15
+ return extract_approval_prompt("_", text) is not None
16
+
17
+
18
+ def extract_approval_prompt(agent_id: str, text: str) -> dict[str, Any] | None:
19
+ lines = text.splitlines()
20
+ control_index = active_approval_control_index(lines)
21
+ if control_index is None:
22
+ return None
23
+ for index in range(control_index, -1, -1):
24
+ line = lines[index]
25
+ if "Allow the team_orchestrator MCP server to run tool" not in line:
26
+ continue
27
+ tool_match = re.search(r'run tool "([^"]+)"', line)
28
+ return {
29
+ "agent_id": agent_id,
30
+ "state": "waiting_approval",
31
+ "kind": "mcp_tool",
32
+ "tool": tool_match.group(1) if tool_match else None,
33
+ "prompt": line.strip(),
34
+ "choices": extract_approval_choices(lines[index : control_index + 1]),
35
+ }
36
+ for index in range(control_index, -1, -1):
37
+ line = lines[index]
38
+ if line_is_approval_choice(line):
39
+ continue
40
+ tool_match = re.search(r"\bteam_orchestrator\s*[-.]\s*([A-Za-z_][A-Za-z0-9_]*)\b", line)
41
+ if not tool_match:
42
+ continue
43
+ return {
44
+ "agent_id": agent_id,
45
+ "state": "waiting_approval",
46
+ "kind": "mcp_tool",
47
+ "tool": tool_match.group(1),
48
+ "prompt": f"team_orchestrator - {tool_match.group(1)}",
49
+ "choices": extract_approval_choices(lines[index : control_index + 1]),
50
+ }
51
+ for index in range(control_index, -1, -1):
52
+ line = lines[index]
53
+ if "Would you like to run the following command" not in line:
54
+ continue
55
+ return {
56
+ "agent_id": agent_id,
57
+ "state": "waiting_approval",
58
+ "kind": "command",
59
+ "command": extract_command_approval_subject(lines[: control_index + 1], index),
60
+ "prompt": line.strip(),
61
+ "choices": extract_approval_choices(lines[index : control_index + 1]),
62
+ }
63
+ return {
64
+ "agent_id": agent_id,
65
+ "state": "waiting_approval",
66
+ "kind": "unknown",
67
+ "prompt": "approval prompt detected",
68
+ "choices": extract_approval_choices(lines[: control_index + 1]),
69
+ }
70
+
71
+
72
+ def active_approval_control_index(lines: list[str]) -> int | None:
73
+ control_indices = [
74
+ index
75
+ for index, line in enumerate(lines)
76
+ if is_approval_control_line(line)
77
+ ]
78
+ if not control_indices:
79
+ return None
80
+ control_index = control_indices[-1]
81
+ if any(line.strip() for line in lines[control_index + 1 :]):
82
+ return None
83
+ return control_index
84
+
85
+
86
+ def is_approval_control_line(line: str) -> bool:
87
+ normalized = line.lower()
88
+ return "enter to submit | esc to cancel" in normalized or ("esc to cancel" in normalized and "tab to amend" in normalized)
89
+
90
+
91
+ def extract_approval_choices(lines: list[str]) -> list[str]:
92
+ choices: list[str] = []
93
+ for line in lines:
94
+ stripped = line.strip()
95
+ match = APPROVAL_CHOICE_RE.match(stripped)
96
+ if not match:
97
+ continue
98
+ label = match.group(2).strip()
99
+ if label and label not in choices:
100
+ choices.append(label)
101
+ return choices
102
+
103
+
104
+ def line_is_approval_choice(line: str) -> bool:
105
+ return APPROVAL_CHOICE_RE.match(line.strip()) is not None
106
+
107
+
108
+ def extract_command_approval_subject(lines: list[str], prompt_index: int) -> str | None:
109
+ for line in reversed(lines[:prompt_index]):
110
+ stripped = line.strip()
111
+ if stripped.startswith("Bash(") or stripped.startswith("Shell("):
112
+ return stripped[:200]
113
+ for line in lines[prompt_index + 1 : prompt_index + 8]:
114
+ stripped = line.strip()
115
+ if stripped.startswith("Bash(") or stripped.startswith("Shell("):
116
+ return stripped[:200]
117
+ return None
118
+
119
+
120
+ def active_approval_choice_index(text: str) -> int | None:
121
+ for line in text.splitlines():
122
+ stripped = line.strip()
123
+ if not (stripped.startswith("›") or stripped.startswith("❯") or stripped.startswith(">")):
124
+ continue
125
+ match = re.match(r"[›❯>]\s*(\d+)\.", stripped)
126
+ if match:
127
+ return int(match.group(1)) - 1
128
+ return None
129
+
130
+
131
+ def capture_has_team_orchestrator_mcp_prompt(text: str) -> bool:
132
+ return (
133
+ "Allow the team_orchestrator MCP server to run tool" in text
134
+ or re.search(r"\bteam_orchestrator\s*[-.]\s*[A-Za-z_][A-Za-z0-9_]*\b", text) is not None
135
+ )
136
+
137
+
138
+ def approval_prompt_fingerprint(prompt: dict[str, Any]) -> str:
139
+ data = {
140
+ "kind": prompt.get("kind"),
141
+ "tool": prompt.get("tool"),
142
+ "prompt": prompt.get("prompt"),
143
+ "choices": prompt.get("choices") or [],
144
+ }
145
+ return hashlib.sha256(json.dumps(data, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
146
+
147
+
148
+ def choose_internal_mcp_approval_choice(prompt: dict[str, Any]) -> str:
149
+ choices = prompt.get("choices") or []
150
+ if INTERNAL_MCP_APPROVAL_CHOICE in choices:
151
+ return INTERNAL_MCP_APPROVAL_CHOICE
152
+ for choice in choices:
153
+ if str(choice).startswith("Yes, and don't ask again"):
154
+ return str(choice)
155
+ if "Allow" in choices:
156
+ return "Allow"
157
+ if "Yes" in choices:
158
+ return "Yes"
159
+ return INTERNAL_MCP_APPROVAL_CHOICE
160
+
161
+
162
+ def approval_choice_keys(prompt: dict[str, Any], capture_text: str, choice: str) -> list[str]:
163
+ choices = prompt.get("choices") or []
164
+ try:
165
+ target_index = choices.index(choice)
166
+ except ValueError:
167
+ return ["Down", "Enter"]
168
+ active_index = active_approval_choice_index(capture_text)
169
+ if active_index is None:
170
+ return [str(target_index + 1), "Enter"]
171
+ delta = target_index - active_index
172
+ if delta > 0:
173
+ return ["Down"] * delta + ["Enter"]
174
+ if delta < 0:
175
+ return ["Up"] * abs(delta) + ["Enter"]
176
+ return ["Enter"]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.approvals.constants import (
8
+ INTERNAL_MCP_AUTO_APPROVE_TOOLS,
9
+ STARTUP_PROMPT_RUNTIME_CHECK_LIMIT,
10
+ )
11
+ from team_agent.approvals.parsing import (
12
+ approval_choice_keys,
13
+ approval_prompt_fingerprint,
14
+ choose_internal_mcp_approval_choice,
15
+ extract_approval_prompt,
16
+ )
17
+ from team_agent.events import EventLog
18
+ from team_agent.status import APPROVAL_SCAN_LINES
19
+
20
+
21
+ def handle_provider_runtime_prompts(workspace: Path, state: dict[str, Any], event_log: EventLog) -> None:
22
+ from team_agent.runtime import _tmux_session_exists, _tmux_window_exists, get_adapter
23
+ _ = workspace
24
+ session_name = state.get("session_name")
25
+ if not session_name or not _tmux_session_exists(session_name):
26
+ return
27
+ for agent_id, agent_state in state.get("agents", {}).items():
28
+ if agent_state.get("status") in {"paused", "stopped", "missing"}:
29
+ continue
30
+ window = agent_state.get("window", agent_id)
31
+ if not _tmux_window_exists(session_name, window):
32
+ continue
33
+ internal_mcp = handle_internal_mcp_approval_prompt(agent_id, session_name, window, event_log)
34
+ if internal_mcp is not None:
35
+ continue
36
+ adapter = get_adapter(agent_state["provider"])
37
+ for prompt_event in adapter.handle_runtime_prompts(session_name, window):
38
+ event_log.write(
39
+ "runtime.prompt_handled",
40
+ agent_id=agent_id,
41
+ provider=agent_state["provider"],
42
+ **prompt_event,
43
+ )
44
+
45
+
46
+ def handle_provider_startup_prompts(workspace: Path, state: dict[str, Any], event_log: EventLog) -> None:
47
+ from team_agent.runtime import _tmux_session_exists, _tmux_window_exists, get_adapter
48
+ _ = workspace
49
+ session_name = state.get("session_name")
50
+ if not session_name or not _tmux_session_exists(session_name):
51
+ return
52
+ for agent_id, agent_state in state.get("agents", {}).items():
53
+ if agent_state.get("status") in {"paused", "stopped", "missing"}:
54
+ continue
55
+ window = agent_state.get("window", agent_id)
56
+ if not _tmux_window_exists(session_name, window):
57
+ continue
58
+ spawned_at = str(agent_state.get("spawned_at") or "")
59
+ if agent_state.get("startup_prompt_check_spawned_at") != spawned_at:
60
+ agent_state["startup_prompt_check_spawned_at"] = spawned_at
61
+ agent_state["startup_prompt_check_count"] = 0
62
+ check_count = int(agent_state.get("startup_prompt_check_count") or 0)
63
+ if check_count >= STARTUP_PROMPT_RUNTIME_CHECK_LIMIT:
64
+ continue
65
+ agent_state["startup_prompt_check_count"] = check_count + 1
66
+ adapter = get_adapter(agent_state["provider"])
67
+ for prompt_event in adapter.handle_startup_prompts(session_name, window, checks=1, sleep_s=0.0):
68
+ event_log.write(
69
+ "runtime.startup_prompt_handled",
70
+ agent_id=agent_id,
71
+ provider=agent_state["provider"],
72
+ **prompt_event,
73
+ )
74
+
75
+
76
+ def handle_internal_mcp_approval_prompt(
77
+ agent_id: str,
78
+ session_name: str,
79
+ window: str,
80
+ event_log: EventLog,
81
+ ) -> dict[str, Any] | None:
82
+ from team_agent.runtime import run_cmd
83
+ target = f"{session_name}:{window}"
84
+ proc = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{APPROVAL_SCAN_LINES}", "-t", target], timeout=5)
85
+ if proc.returncode != 0:
86
+ return None
87
+ prompt = extract_approval_prompt(agent_id, proc.stdout)
88
+ if not prompt or prompt.get("kind") != "mcp_tool":
89
+ return None
90
+ tool = str(prompt.get("tool") or "")
91
+ fingerprint = approval_prompt_fingerprint(prompt)
92
+ if tool not in INTERNAL_MCP_AUTO_APPROVE_TOOLS:
93
+ result = {
94
+ "ok": False,
95
+ "action": "skipped",
96
+ "reason": "tool_not_allowlisted",
97
+ "tool": tool,
98
+ "fingerprint": fingerprint,
99
+ }
100
+ event_log.write("runtime.internal_mcp_approval.skipped", agent_id=agent_id, **result)
101
+ return result
102
+ result = submit_internal_mcp_approval(agent_id, target, tool, prompt, proc.stdout)
103
+ event_log.write("runtime.internal_mcp_approval.auto", agent_id=agent_id, **result)
104
+ return result
105
+
106
+
107
+ def submit_internal_mcp_approval(
108
+ agent_id: str,
109
+ target: str,
110
+ tool: str,
111
+ prompt: dict[str, Any],
112
+ capture_text: str,
113
+ attempts: int = 3,
114
+ ) -> dict[str, Any]:
115
+ from team_agent.runtime import run_cmd
116
+ choice = choose_internal_mcp_approval_choice(prompt)
117
+ fingerprint = approval_prompt_fingerprint(prompt)
118
+ attempt_log: list[dict[str, Any]] = []
119
+ current_prompt = prompt
120
+ current_capture = capture_text
121
+ for attempt in range(1, attempts + 1):
122
+ keys = approval_choice_keys(current_prompt, current_capture, choice)
123
+ proc = run_cmd(["tmux", "send-keys", "-t", target, *keys], timeout=10)
124
+ if proc.returncode != 0:
125
+ return {
126
+ "ok": False,
127
+ "action": "auto_approve",
128
+ "tool": tool,
129
+ "choice": choice,
130
+ "fingerprint": fingerprint,
131
+ "attempts": attempt_log + [{"attempt": attempt, "submitted": False, "error": proc.stderr.strip()}],
132
+ "verification": "send_keys_failed",
133
+ }
134
+ time.sleep(0.35)
135
+ verify = run_cmd(["tmux", "capture-pane", "-p", "-S", f"-{APPROVAL_SCAN_LINES}", "-t", target], timeout=5)
136
+ if verify.returncode != 0:
137
+ attempt_log.append({"attempt": attempt, "submitted": True, "keys": keys, "verification": "capture_failed"})
138
+ continue
139
+ after_prompt = extract_approval_prompt(agent_id, verify.stdout)
140
+ if not after_prompt:
141
+ return {
142
+ "ok": True,
143
+ "action": "auto_approved",
144
+ "tool": tool,
145
+ "choice": choice,
146
+ "fingerprint": fingerprint,
147
+ "attempts": attempt_log + [{"attempt": attempt, "submitted": True, "keys": keys, "verification": "prompt_absent"}],
148
+ "verification": "prompt_absent_after_submit",
149
+ }
150
+ if after_prompt.get("kind") != "mcp_tool" or after_prompt.get("tool") != tool:
151
+ return {
152
+ "ok": True,
153
+ "action": "auto_approved",
154
+ "tool": tool,
155
+ "choice": choice,
156
+ "fingerprint": fingerprint,
157
+ "attempts": attempt_log + [{"attempt": attempt, "submitted": True, "keys": keys, "verification": "different_prompt_present"}],
158
+ "verification": "original_prompt_replaced",
159
+ }
160
+ attempt_log.append({"attempt": attempt, "submitted": True, "keys": keys, "verification": "prompt_still_present"})
161
+ current_prompt = after_prompt
162
+ current_capture = verify.stdout
163
+ return {
164
+ "ok": False,
165
+ "action": "auto_approve",
166
+ "tool": tool,
167
+ "choice": choice,
168
+ "fingerprint": fingerprint,
169
+ "attempts": attempt_log,
170
+ "verification": "prompt_still_present_after_retries",
171
+ }
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from team_agent.approvals.parsing import capture_has_approval_prompt
10
+ from team_agent.events import EventLog
11
+ from team_agent.message_store import MessageStore
12
+ from team_agent.state import team_state_key
13
+
14
+
15
+ def refresh_agent_runtime_statuses(workspace: Path, state: dict[str, Any], event_log: EventLog) -> None:
16
+ from team_agent.runtime import _tmux_session_exists, _tmux_window_exists
17
+ _ = workspace
18
+ session_name = state.get("session_name")
19
+ tmux_exists = _tmux_session_exists(session_name) if session_name else False
20
+ for agent_id, agent_state in state.get("agents", {}).items():
21
+ if agent_state.get("status") in {"paused", "stopped"}:
22
+ continue
23
+ old_status = agent_state.get("status")
24
+ window = agent_state.get("window", agent_id)
25
+ window_present = _tmux_window_exists(session_name, window) if tmux_exists else False
26
+ agent_state["tmux_window_present"] = window_present
27
+ if not window_present:
28
+ if session_name:
29
+ agent_state["status"] = "missing"
30
+ else:
31
+ detected = detect_provider_status(agent_state["provider"], session_name, window)
32
+ if detected:
33
+ agent_state["status"] = detected
34
+ else:
35
+ agent_state.setdefault("status", "running")
36
+ if old_status != agent_state.get("status"):
37
+ event_log.write(
38
+ "runtime.status_detected",
39
+ agent_id=agent_id,
40
+ provider=agent_state.get("provider"),
41
+ old_status=old_status,
42
+ status=agent_state.get("status"),
43
+ )
44
+
45
+
46
+ def sync_agent_health(workspace: Path, state: dict[str, Any], store: MessageStore | None = None) -> dict[str, dict[str, Any]]:
47
+ from team_agent.runtime import _tmux_window_exists, _tmux_pane_info, run_cmd
48
+ from team_agent.messaging.activity_detector import classify_agent_activity
49
+ store = store or MessageStore(workspace)
50
+ session_name = state.get("session_name")
51
+ owner_team_id = team_state_key(state)
52
+ captures: dict[str, dict[str, Any]] = {}
53
+ for agent_id, agent_state in state.get("agents", {}).items():
54
+ health_status = agent_health_status(agent_state)
55
+ last_output_at = agent_state.get("last_output_at")
56
+ window = agent_state.get("window", agent_id)
57
+ scrollback = ""
58
+ pane_info: dict[str, Any] | None = None
59
+ if session_name and _tmux_window_exists(session_name, window):
60
+ proc = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", f"{session_name}:{window}"], timeout=5)
61
+ if proc.returncode == 0:
62
+ scrollback = proc.stdout
63
+ digest = hashlib.sha256(proc.stdout.encode("utf-8", errors="ignore")).hexdigest()
64
+ if digest != agent_state.get("last_output_hash"):
65
+ last_output_at = datetime.now(timezone.utc).isoformat()
66
+ agent_state["last_output_hash"] = digest
67
+ agent_state["last_output_at"] = last_output_at
68
+ if capture_has_approval_prompt(proc.stdout):
69
+ health_status = "AWAITING_APPROVAL"
70
+ try:
71
+ pane_info = _tmux_pane_info(f"{session_name}:{window}")
72
+ except Exception:
73
+ pane_info = None
74
+ if scrollback and health_status != "AWAITING_APPROVAL":
75
+ activity = classify_agent_activity(
76
+ agent_id,
77
+ agent_state.get("provider") or "",
78
+ last_output_at,
79
+ pane_info,
80
+ scrollback,
81
+ )
82
+ agent_state["activity"] = {
83
+ "status": activity.get("status"),
84
+ "confidence": activity.get("confidence"),
85
+ "rationale": activity.get("rationale"),
86
+ "classified_at": datetime.now(timezone.utc).isoformat(),
87
+ }
88
+ if activity.get("confidence", 0) >= 0.85:
89
+ raw = str(activity.get("status") or "")
90
+ mapping = {"idle": "IDLE", "working": "WORKING", "stuck": "STUCK", "uncertain": "UNCERTAIN"}
91
+ mapped = mapping.get(raw)
92
+ if mapped:
93
+ health_status = mapped
94
+ current_task = current_task_for_agent(state.get("tasks", []), agent_id)
95
+ store.upsert_agent_health(
96
+ agent_id,
97
+ health_status,
98
+ last_output_at=last_output_at,
99
+ context_usage_pct=agent_state.get("context_usage_pct"),
100
+ current_task_id=current_task,
101
+ owner_team_id=owner_team_id,
102
+ )
103
+ captures[agent_id] = {"scrollback": scrollback, "pane_info": pane_info, "health_status": health_status}
104
+ return captures
105
+
106
+
107
+ def agent_health_status(agent_state: dict[str, Any]) -> str:
108
+ raw = str(agent_state.get("status") or "").lower()
109
+ if raw in {"busy", "running"}:
110
+ return "RUNNING" if raw == "busy" else "IDLE"
111
+ if raw in {"paused", "blocked"}:
112
+ return "BLOCKED"
113
+ if raw in {"error", "missing", "interrupted"}:
114
+ return "ERROR"
115
+ if raw in {"stopped", "done"}:
116
+ return "DONE"
117
+ return "IDLE"
118
+
119
+
120
+ def current_task_for_agent(tasks: list[dict[str, Any]], agent_id: str) -> str | None:
121
+ active = {"pending", "ready", "running", "blocked", "needs_retry"}
122
+ for task in reversed(tasks):
123
+ if task.get("assignee") == agent_id and task.get("status", "pending") in active:
124
+ return task.get("id")
125
+ return None
126
+
127
+
128
+ def age_text(iso_text: str | None) -> str:
129
+ if not iso_text:
130
+ return "-"
131
+ try:
132
+ dt = datetime.fromisoformat(iso_text)
133
+ if dt.tzinfo is None:
134
+ dt = dt.replace(tzinfo=timezone.utc)
135
+ seconds = max(0, int((datetime.now(timezone.utc) - dt).total_seconds()))
136
+ except ValueError:
137
+ return "-"
138
+ if seconds < 60:
139
+ return f"{seconds}s ago"
140
+ minutes = seconds // 60
141
+ if minutes < 60:
142
+ return f"{minutes}m ago"
143
+ return f"{minutes // 60}h ago"
144
+
145
+
146
+ def detect_provider_status(provider: str, session_name: str, window: str) -> str | None:
147
+ from team_agent.runtime import get_adapter, run_cmd
148
+ proc = run_cmd(["tmux", "capture-pane", "-p", "-t", f"{session_name}:{window}"], timeout=5)
149
+ if proc.returncode != 0:
150
+ return None
151
+ patterns = get_adapter(provider).status_patterns()
152
+ positions: dict[str, int] = {}
153
+ for status_name, pattern in patterns.items():
154
+ if not pattern:
155
+ continue
156
+ try:
157
+ matches = list(re.finditer(pattern, proc.stdout, re.MULTILINE))
158
+ except re.error:
159
+ continue
160
+ if matches:
161
+ positions[status_name] = matches[-1].start()
162
+ if not positions:
163
+ return None
164
+ latest = max(positions, key=positions.get)
165
+ return {"idle": "running", "processing": "busy", "error": "error"}.get(latest)