machinaos 0.0.21 → 0.0.23

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 (60) hide show
  1. package/README.md +32 -6
  2. package/bin/cli.js +0 -0
  3. package/client/dist/assets/index-5BWZnM6b.js +703 -0
  4. package/client/dist/index.html +1 -1
  5. package/client/package.json +1 -1
  6. package/client/src/Dashboard.tsx +12 -5
  7. package/client/src/ParameterPanel.tsx +6 -5
  8. package/client/src/components/AIAgentNode.tsx +35 -16
  9. package/client/src/components/CredentialsModal.tsx +450 -5
  10. package/client/src/components/TeamMonitorNode.tsx +269 -0
  11. package/client/src/components/parameterPanel/InputSection.tsx +25 -0
  12. package/client/src/contexts/WebSocketContext.tsx +38 -0
  13. package/client/src/hooks/useApiKeys.ts +44 -0
  14. package/client/src/nodeDefinitions/specializedAgentNodes.ts +59 -3
  15. package/client/src/nodeDefinitions/twitterNodes.ts +441 -0
  16. package/client/src/nodeDefinitions/utilityNodes.ts +45 -1
  17. package/client/src/nodeDefinitions.ts +7 -1
  18. package/client/src/services/executionService.ts +4 -1
  19. package/install.sh +63 -1
  20. package/package.json +5 -2
  21. package/scripts/build.js +0 -0
  22. package/scripts/clean.js +0 -0
  23. package/scripts/daemon.js +0 -0
  24. package/scripts/docker.js +0 -0
  25. package/scripts/install.js +0 -0
  26. package/scripts/postinstall.js +29 -0
  27. package/scripts/preinstall.js +67 -0
  28. package/scripts/serve-client.js +0 -0
  29. package/scripts/start.js +0 -0
  30. package/scripts/stop.js +0 -0
  31. package/scripts/sync-version.js +0 -0
  32. package/server/Dockerfile +10 -15
  33. package/server/constants.py +20 -0
  34. package/server/core/database.py +443 -3
  35. package/server/main.py +9 -1
  36. package/server/models/database.py +112 -2
  37. package/server/pyproject.toml +3 -0
  38. package/server/requirements.txt +3 -0
  39. package/server/routers/twitter.py +390 -0
  40. package/server/routers/websocket.py +320 -0
  41. package/server/services/agent_team.py +266 -0
  42. package/server/services/ai.py +43 -0
  43. package/server/services/compaction.py +39 -4
  44. package/server/services/event_waiter.py +41 -0
  45. package/server/services/handlers/__init__.py +13 -0
  46. package/server/services/handlers/ai.py +66 -2
  47. package/server/services/handlers/tools.py +84 -0
  48. package/server/services/handlers/twitter.py +297 -0
  49. package/server/services/handlers/utility.py +91 -0
  50. package/server/services/node_executor.py +15 -1
  51. package/server/services/pricing.py +270 -0
  52. package/server/services/status_broadcaster.py +79 -0
  53. package/server/services/twitter_oauth.py +410 -0
  54. package/server/skills/social_agent/twitter-search-skill/SKILL.md +146 -0
  55. package/server/skills/social_agent/twitter-send-skill/SKILL.md +142 -0
  56. package/server/skills/social_agent/twitter-user-skill/SKILL.md +165 -0
  57. package/workflows/Zeenie_full.json +459 -0
  58. package/workflows/Zeenie_small.json +459 -0
  59. package/client/dist/assets/index-YVvAiByx.js +0 -703
  60. package/server/requirements-docker.txt +0 -86
@@ -820,3 +820,94 @@ def _format_console_output(data: Any, format_type: str) -> str:
820
820
  return json.dumps(data, indent=2, default=str, ensure_ascii=False)
821
821
  case _:
822
822
  return str(data)
823
+
824
+
825
+ # =============================================================================
826
+ # TEAM MONITOR HANDLER
827
+ # =============================================================================
828
+
829
+ async def handle_team_monitor(
830
+ node_id: str,
831
+ node_type: str,
832
+ parameters: Dict[str, Any],
833
+ context: Dict[str, Any]
834
+ ) -> Dict[str, Any]:
835
+ """Handle team monitor node - returns team status snapshot.
836
+
837
+ Args:
838
+ node_id: The node ID
839
+ node_type: The node type (teamMonitor)
840
+ parameters: Resolved parameters
841
+ context: Execution context with team_id from connected team node
842
+
843
+ Returns:
844
+ Execution result dict with team status
845
+ """
846
+ from services.agent_team import get_agent_team_service
847
+ start_time = time.time()
848
+
849
+ try:
850
+ # Get team_id from context (set by connected team lead node)
851
+ team_id = context.get('team_id')
852
+
853
+ # Try to get from connected node outputs
854
+ if not team_id:
855
+ outputs = context.get('outputs', {})
856
+ for output in outputs.values():
857
+ if isinstance(output, dict) and output.get('team_id'):
858
+ team_id = output.get('team_id')
859
+ break
860
+
861
+ if not team_id:
862
+ return {
863
+ "success": True,
864
+ "node_id": node_id,
865
+ "node_type": "teamMonitor",
866
+ "result": {
867
+ "message": "No team connected",
868
+ "team_id": None,
869
+ "members": [],
870
+ "tasks": {"total": 0, "completed": 0, "active": 0, "pending": 0, "failed": 0},
871
+ "active_tasks": [],
872
+ "recent_events": []
873
+ },
874
+ "execution_time": time.time() - start_time,
875
+ "timestamp": datetime.now().isoformat()
876
+ }
877
+
878
+ team_service = get_agent_team_service()
879
+ status = await team_service.get_team_status(team_id)
880
+
881
+ max_history = parameters.get('maxHistoryItems', 50)
882
+
883
+ return {
884
+ "success": True,
885
+ "node_id": node_id,
886
+ "node_type": "teamMonitor",
887
+ "result": {
888
+ "team_id": team_id,
889
+ "members": status.get('members', []),
890
+ "tasks": {
891
+ "total": status.get('task_count', 0),
892
+ "completed": status.get('completed_count', 0),
893
+ "active": status.get('active_count', 0),
894
+ "pending": status.get('pending_count', 0),
895
+ "failed": status.get('failed_count', 0)
896
+ },
897
+ "active_tasks": status.get('active_tasks', []),
898
+ "recent_events": status.get('recent_events', [])[-max_history:]
899
+ },
900
+ "execution_time": time.time() - start_time,
901
+ "timestamp": datetime.now().isoformat()
902
+ }
903
+
904
+ except Exception as e:
905
+ logger.error("[TeamMonitor] Failed", node_id=node_id, error=str(e))
906
+ return {
907
+ "success": False,
908
+ "node_id": node_id,
909
+ "node_type": "teamMonitor",
910
+ "error": str(e),
911
+ "execution_time": time.time() - start_time,
912
+ "timestamp": datetime.now().isoformat()
913
+ }
@@ -30,12 +30,13 @@ from services.handlers import (
30
30
  handle_create_map, handle_add_locations, handle_nearby_places,
31
31
  handle_text_generator, handle_file_handler,
32
32
  handle_chat_send, handle_chat_history,
33
- handle_start, handle_cron_scheduler, handle_timer, handle_console,
33
+ handle_start, handle_cron_scheduler, handle_timer, handle_console, handle_team_monitor,
34
34
  handle_whatsapp_send, handle_whatsapp_db,
35
35
  handle_social_receive, handle_social_send,
36
36
  handle_http_scraper, handle_file_downloader, handle_document_parser,
37
37
  handle_text_chunker, handle_embedding_generator, handle_vector_store,
38
38
  handle_task_manager,
39
+ handle_twitter_send, handle_twitter_search, handle_twitter_user,
39
40
  )
40
41
 
41
42
  if TYPE_CHECKING:
@@ -122,6 +123,7 @@ class NodeExecutor:
122
123
  'consumer_agent': partial(handle_chat_agent, ai_service=self.ai_service, database=self.database),
123
124
  'autonomous_agent': partial(handle_chat_agent, ai_service=self.ai_service, database=self.database),
124
125
  'orchestrator_agent': partial(handle_chat_agent, ai_service=self.ai_service, database=self.database),
126
+ 'ai_employee': partial(handle_chat_agent, ai_service=self.ai_service, database=self.database),
125
127
  'simpleMemory': handle_simple_memory,
126
128
  # Maps
127
129
  'gmaps_create': partial(handle_create_map, maps_service=self.maps_service),
@@ -133,6 +135,10 @@ class NodeExecutor:
133
135
  # WhatsApp
134
136
  'whatsappSend': handle_whatsapp_send,
135
137
  'whatsappDb': handle_whatsapp_db,
138
+ # Twitter/X
139
+ 'twitterSend': handle_twitter_send,
140
+ 'twitterSearch': handle_twitter_search,
141
+ 'twitterUser': handle_twitter_user,
136
142
  # Social (unified messaging)
137
143
  # Note: socialReceive handled in _dispatch with connected_outputs
138
144
  'socialSend': handle_social_send,
@@ -150,6 +156,8 @@ class NodeExecutor:
150
156
  'vectorStore': handle_vector_store,
151
157
  # Task management
152
158
  'taskManager': handle_task_manager,
159
+ # Team monitoring
160
+ 'teamMonitor': handle_team_monitor,
153
161
  # Note: 'console' handled in _dispatch with connected_outputs
154
162
  }
155
163
 
@@ -227,6 +235,12 @@ class NodeExecutor:
227
235
  await self._output_store(session_id, node_id, "output_contact", output_data.get('contact', {}))
228
236
  await self._output_store(session_id, node_id, "output_metadata", output_data.get('metadata', {}))
229
237
 
238
+ # Store with multiple keys for different handle IDs used by frontend components:
239
+ # - output_main: SquareNode, GenericNode, TriggerNode, StartNode
240
+ # - output_top: AIAgentNode (agents use top output handle)
241
+ # - output_0: backward compatibility
242
+ await self._output_store(session_id, node_id, "output_main", output_data)
243
+ await self._output_store(session_id, node_id, "output_top", output_data)
230
244
  await self._output_store(session_id, node_id, "output_0", output_data)
231
245
 
232
246
  return result
@@ -0,0 +1,270 @@
1
+ """LLM pricing service for token cost calculation.
2
+
3
+ Provides cost calculation for all supported LLM providers based on official pricing.
4
+ Pricing is in USD per million tokens (MTok).
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Dict, Optional
9
+ from core.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class ModelPricing:
16
+ """Pricing for a specific model."""
17
+ input_per_mtok: float # USD per 1M input tokens
18
+ output_per_mtok: float # USD per 1M output tokens
19
+ cache_read_per_mtok: Optional[float] = None # USD per 1M cache read tokens (Anthropic)
20
+ reasoning_per_mtok: Optional[float] = None # USD per 1M reasoning tokens (OpenAI o-series)
21
+
22
+
23
+ # Official pricing as of February 2026
24
+ # Sources: openai.com/api/pricing, anthropic.com/pricing, ai.google.dev/pricing,
25
+ # groq.com/pricing, cerebras.ai/pricing
26
+ PRICING_REGISTRY: Dict[str, Dict[str, ModelPricing]] = {
27
+ 'openai': {
28
+ # GPT-5 series
29
+ 'gpt-5.2': ModelPricing(1.75, 14.00),
30
+ 'gpt-5.1': ModelPricing(1.25, 10.00),
31
+ 'gpt-5': ModelPricing(1.25, 10.00),
32
+ 'gpt-5-mini': ModelPricing(0.25, 2.00),
33
+ 'gpt-5-nano': ModelPricing(0.05, 0.40),
34
+ 'gpt-5-pro': ModelPricing(15.00, 120.00),
35
+ # O-series (reasoning models)
36
+ 'o3': ModelPricing(2.00, 8.00, reasoning_per_mtok=8.00),
37
+ 'o3-mini': ModelPricing(1.10, 4.40, reasoning_per_mtok=4.40),
38
+ 'o3-pro': ModelPricing(20.00, 80.00, reasoning_per_mtok=80.00),
39
+ 'o4-mini': ModelPricing(1.10, 4.40, reasoning_per_mtok=4.40),
40
+ 'o1': ModelPricing(15.00, 60.00, reasoning_per_mtok=60.00),
41
+ 'o1-mini': ModelPricing(1.10, 4.40, reasoning_per_mtok=4.40),
42
+ # GPT-4 series
43
+ 'gpt-4o': ModelPricing(2.50, 10.00),
44
+ 'gpt-4o-mini': ModelPricing(0.15, 0.60),
45
+ 'gpt-4.1': ModelPricing(2.00, 8.00),
46
+ 'gpt-4.1-mini': ModelPricing(0.40, 1.60),
47
+ 'gpt-4.1-nano': ModelPricing(0.10, 0.40),
48
+ 'gpt-4-turbo': ModelPricing(10.00, 30.00),
49
+ 'gpt-4': ModelPricing(30.00, 60.00),
50
+ # GPT-3.5
51
+ 'gpt-3.5-turbo': ModelPricing(0.50, 1.50),
52
+ # Default fallback
53
+ '_default': ModelPricing(2.50, 10.00),
54
+ },
55
+ 'anthropic': {
56
+ # Claude 4.x series
57
+ 'claude-opus-4.6': ModelPricing(5.00, 25.00, cache_read_per_mtok=0.50),
58
+ 'claude-opus-4.5': ModelPricing(5.00, 25.00, cache_read_per_mtok=0.50),
59
+ 'claude-opus-4.1': ModelPricing(15.00, 75.00, cache_read_per_mtok=1.50),
60
+ 'claude-opus-4': ModelPricing(15.00, 75.00, cache_read_per_mtok=1.50),
61
+ 'claude-sonnet-4.6': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
62
+ 'claude-sonnet-4.5': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
63
+ 'claude-sonnet-4': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
64
+ 'claude-haiku-4.5': ModelPricing(1.00, 5.00, cache_read_per_mtok=0.10),
65
+ # Claude 3.x series (legacy)
66
+ 'claude-3-opus': ModelPricing(15.00, 75.00, cache_read_per_mtok=1.50),
67
+ 'claude-3-5-sonnet': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
68
+ 'claude-3-sonnet': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
69
+ 'claude-3-haiku': ModelPricing(0.25, 1.25, cache_read_per_mtok=0.03),
70
+ 'claude-3.5-haiku': ModelPricing(0.80, 4.00, cache_read_per_mtok=0.08),
71
+ # Partial matches (for model names like claude-3-5-sonnet-20241022)
72
+ 'claude-opus': ModelPricing(5.00, 25.00, cache_read_per_mtok=0.50),
73
+ 'claude-sonnet': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
74
+ 'claude-haiku': ModelPricing(1.00, 5.00, cache_read_per_mtok=0.10),
75
+ # Default fallback
76
+ '_default': ModelPricing(3.00, 15.00, cache_read_per_mtok=0.30),
77
+ },
78
+ 'gemini': {
79
+ # Gemini 3.x series
80
+ 'gemini-3-pro': ModelPricing(2.00, 12.00),
81
+ 'gemini-3-flash': ModelPricing(0.50, 3.00),
82
+ # Gemini 2.5 series
83
+ 'gemini-2.5-pro': ModelPricing(1.25, 10.00),
84
+ 'gemini-2.5-flash': ModelPricing(0.30, 2.50),
85
+ 'gemini-2.5-flash-lite': ModelPricing(0.10, 0.40),
86
+ # Gemini 2.0 series
87
+ 'gemini-2.0-flash': ModelPricing(0.10, 0.40),
88
+ 'gemini-2.0-flash-lite': ModelPricing(0.08, 0.30),
89
+ # Gemini 1.x series (legacy)
90
+ 'gemini-1.5-pro': ModelPricing(1.25, 5.00),
91
+ 'gemini-1.5-flash': ModelPricing(0.075, 0.30),
92
+ 'gemini-pro': ModelPricing(0.50, 1.50),
93
+ # Default fallback
94
+ '_default': ModelPricing(0.50, 2.50),
95
+ },
96
+ 'groq': {
97
+ # Llama 4 series
98
+ 'llama-4-scout': ModelPricing(0.11, 0.34),
99
+ 'llama-4-maverick': ModelPricing(0.20, 0.60),
100
+ # Llama 3.x series
101
+ 'llama-3.3-70b': ModelPricing(0.59, 0.79),
102
+ 'llama-3.1-70b': ModelPricing(0.59, 0.79),
103
+ 'llama-3.1-8b': ModelPricing(0.05, 0.08),
104
+ 'llama3-70b': ModelPricing(0.59, 0.79),
105
+ 'llama3-8b': ModelPricing(0.05, 0.08),
106
+ # Qwen series
107
+ 'qwen3-32b': ModelPricing(0.29, 0.59),
108
+ 'qwen-qwq-32b': ModelPricing(0.29, 0.59),
109
+ # Mixtral
110
+ 'mixtral-8x7b': ModelPricing(0.24, 0.24),
111
+ # GPT OSS
112
+ 'gpt-oss-20b': ModelPricing(0.075, 0.30),
113
+ 'gpt-oss-120b': ModelPricing(0.15, 0.60),
114
+ # Default fallback
115
+ '_default': ModelPricing(0.29, 0.59),
116
+ },
117
+ 'cerebras': {
118
+ # Llama series
119
+ 'llama-3.1-8b': ModelPricing(0.10, 0.10),
120
+ 'llama-3.1-70b': ModelPricing(0.60, 0.60),
121
+ 'llama-3.1-405b': ModelPricing(6.00, 12.00),
122
+ 'llama3.1-8b': ModelPricing(0.10, 0.10),
123
+ 'llama3.1-70b': ModelPricing(0.60, 0.60),
124
+ # Qwen
125
+ 'qwen-3-32b': ModelPricing(0.20, 0.20),
126
+ # Default fallback
127
+ '_default': ModelPricing(0.60, 0.60),
128
+ },
129
+ 'openrouter': {
130
+ # OpenRouter passes through provider pricing
131
+ # These are approximate defaults; actual cost depends on underlying provider
132
+ '_default': ModelPricing(1.00, 5.00),
133
+ },
134
+ }
135
+
136
+
137
+ class PricingService:
138
+ """Service for calculating LLM token costs."""
139
+
140
+ def __init__(self):
141
+ self._registry = PRICING_REGISTRY
142
+
143
+ def get_pricing(self, provider: str, model: str) -> ModelPricing:
144
+ """Get pricing for a specific model.
145
+
146
+ Uses partial matching: 'claude-3-5-sonnet-20241022' matches 'claude-3-5-sonnet'.
147
+ Falls back to '_default' if no match found.
148
+
149
+ Args:
150
+ provider: Provider name (openai, anthropic, gemini, groq, cerebras, openrouter)
151
+ model: Model name or ID
152
+
153
+ Returns:
154
+ ModelPricing with rates per million tokens
155
+ """
156
+ provider_lower = provider.lower()
157
+ model_lower = model.lower() if model else ''
158
+
159
+ provider_pricing = self._registry.get(provider_lower, {})
160
+
161
+ # Try exact match first
162
+ if model_lower in provider_pricing:
163
+ return provider_pricing[model_lower]
164
+
165
+ # Try partial match (model name starts with a known key)
166
+ for model_key, pricing in provider_pricing.items():
167
+ if model_key != '_default' and model_lower.startswith(model_key):
168
+ return pricing
169
+
170
+ # Try if any key is contained in the model name
171
+ for model_key, pricing in provider_pricing.items():
172
+ if model_key != '_default' and model_key in model_lower:
173
+ return pricing
174
+
175
+ # Fall back to provider default
176
+ default_pricing = provider_pricing.get('_default')
177
+ if default_pricing:
178
+ return default_pricing
179
+
180
+ # Ultimate fallback
181
+ logger.warning(f"[Pricing] No pricing found for {provider}/{model}, using global default")
182
+ return ModelPricing(1.00, 5.00)
183
+
184
+ def calculate_cost(
185
+ self,
186
+ provider: str,
187
+ model: str,
188
+ input_tokens: int,
189
+ output_tokens: int,
190
+ cache_read_tokens: int = 0,
191
+ cache_creation_tokens: int = 0,
192
+ reasoning_tokens: int = 0
193
+ ) -> Dict[str, float]:
194
+ """Calculate cost for token usage.
195
+
196
+ Args:
197
+ provider: LLM provider name
198
+ model: Model name/ID
199
+ input_tokens: Number of input tokens
200
+ output_tokens: Number of output tokens
201
+ cache_read_tokens: Number of cache read tokens (Anthropic)
202
+ cache_creation_tokens: Number of cache creation tokens (Anthropic)
203
+ reasoning_tokens: Number of reasoning tokens (OpenAI o-series)
204
+
205
+ Returns:
206
+ Dict with cost breakdown:
207
+ - input_cost: USD for input tokens
208
+ - output_cost: USD for output tokens
209
+ - cache_cost: USD for cache tokens
210
+ - reasoning_cost: USD for reasoning tokens
211
+ - total_cost: Total USD cost
212
+ """
213
+ pricing = self.get_pricing(provider, model)
214
+
215
+ # Calculate costs (prices are per 1M tokens)
216
+ input_cost = (input_tokens / 1_000_000) * pricing.input_per_mtok
217
+ output_cost = (output_tokens / 1_000_000) * pricing.output_per_mtok
218
+
219
+ # Cache costs (Anthropic pattern)
220
+ cache_cost = 0.0
221
+ if pricing.cache_read_per_mtok:
222
+ # Cache reads are discounted (typically 10% of input price)
223
+ cache_cost += (cache_read_tokens / 1_000_000) * pricing.cache_read_per_mtok
224
+ # Cache creation is charged at 1.25x output rate
225
+ cache_cost += (cache_creation_tokens / 1_000_000) * pricing.output_per_mtok * 1.25
226
+
227
+ # Reasoning costs (OpenAI o-series)
228
+ reasoning_cost = 0.0
229
+ if pricing.reasoning_per_mtok and reasoning_tokens > 0:
230
+ reasoning_cost = (reasoning_tokens / 1_000_000) * pricing.reasoning_per_mtok
231
+
232
+ total_cost = input_cost + output_cost + cache_cost + reasoning_cost
233
+
234
+ return {
235
+ 'input_cost': round(input_cost, 6),
236
+ 'output_cost': round(output_cost, 6),
237
+ 'cache_cost': round(cache_cost, 6),
238
+ 'reasoning_cost': round(reasoning_cost, 6),
239
+ 'total_cost': round(total_cost, 6),
240
+ }
241
+
242
+ def get_all_pricing(self) -> Dict[str, Dict[str, Dict[str, float]]]:
243
+ """Get all pricing data for frontend display.
244
+
245
+ Returns:
246
+ Nested dict: {provider: {model: {input, output, cache_read, reasoning}}}
247
+ """
248
+ result = {}
249
+ for provider, models in self._registry.items():
250
+ result[provider] = {}
251
+ for model, pricing in models.items():
252
+ result[provider][model] = {
253
+ 'input': pricing.input_per_mtok,
254
+ 'output': pricing.output_per_mtok,
255
+ 'cache_read': pricing.cache_read_per_mtok,
256
+ 'reasoning': pricing.reasoning_per_mtok,
257
+ }
258
+ return result
259
+
260
+
261
+ # Singleton instance
262
+ _service: Optional[PricingService] = None
263
+
264
+
265
+ def get_pricing_service() -> PricingService:
266
+ """Get the singleton PricingService instance."""
267
+ global _service
268
+ if _service is None:
269
+ _service = PricingService()
270
+ return _service
@@ -41,6 +41,13 @@ class StatusBroadcaster:
41
41
  "device_id": None,
42
42
  "qr": None
43
43
  },
44
+ "twitter": {
45
+ "connected": False,
46
+ "username": None,
47
+ "user_id": None,
48
+ "name": None,
49
+ "profile_image_url": None
50
+ },
44
51
  "api_keys": {}, # provider -> validation status
45
52
  "nodes": {}, # node_id -> node status
46
53
  "variables": {}, # variable_name -> value
@@ -73,6 +80,9 @@ class StatusBroadcaster:
73
80
  # This ensures client sees actual connection state (especially after auto-connect)
74
81
  await self._refresh_whatsapp_status()
75
82
 
83
+ # Fetch Twitter status from stored OAuth tokens
84
+ await self._refresh_twitter_status()
85
+
76
86
  # Auto-reconnect Android relay if there's a stored session
77
87
  await self._auto_reconnect_android_relay()
78
88
 
@@ -228,6 +238,56 @@ class StatusBroadcaster:
228
238
  # Don't fail client connection if WhatsApp service is down
229
239
  logger.debug(f"[StatusBroadcaster] Could not refresh WhatsApp status: {e}")
230
240
 
241
+ async def _refresh_twitter_status(self):
242
+ """Fetch Twitter status from stored OAuth tokens in database.
243
+
244
+ Called on client connect to check if user is authenticated with Twitter.
245
+ """
246
+ try:
247
+ from core.container import container
248
+ auth_service = container.auth_service()
249
+
250
+ # Check for stored access token
251
+ access_token = await auth_service.get_api_key("twitter_access_token")
252
+ if not access_token:
253
+ self._status["twitter"] = {
254
+ "connected": False,
255
+ "username": None,
256
+ "user_id": None,
257
+ "name": None,
258
+ "profile_image_url": None
259
+ }
260
+ return
261
+
262
+ # Get stored user info
263
+ user_info_str = await auth_service.get_api_key("twitter_user_info")
264
+ if user_info_str:
265
+ # Format: "user_id:username:name"
266
+ parts = user_info_str.split(":", 2)
267
+ user_id = parts[0] if len(parts) > 0 else None
268
+ username = parts[1] if len(parts) > 1 else None
269
+ name = parts[2] if len(parts) > 2 else None
270
+
271
+ self._status["twitter"] = {
272
+ "connected": True,
273
+ "username": username,
274
+ "user_id": user_id,
275
+ "name": name,
276
+ "profile_image_url": None # Could fetch fresh if needed
277
+ }
278
+ logger.debug(f"[StatusBroadcaster] Twitter status: connected as @{username}")
279
+ else:
280
+ # Has token but no user info - still connected
281
+ self._status["twitter"] = {
282
+ "connected": True,
283
+ "username": None,
284
+ "user_id": None,
285
+ "name": None,
286
+ "profile_image_url": None
287
+ }
288
+ except Exception as e:
289
+ logger.debug(f"[StatusBroadcaster] Could not refresh Twitter status: {e}")
290
+
231
291
  async def _auto_reconnect_android_relay(self):
232
292
  """Auto-reconnect to Android relay if there's a stored pairing session.
233
293
 
@@ -755,6 +815,25 @@ class StatusBroadcaster:
755
815
  "type": "terminal_logs_cleared"
756
816
  })
757
817
 
818
+ # =========================================================================
819
+ # Agent Team Updates
820
+ # =========================================================================
821
+
822
+ async def broadcast_team_event(self, team_id: str, event_type: str, data: Dict[str, Any]):
823
+ """Broadcast a team-related event to all connected clients.
824
+
825
+ Args:
826
+ team_id: The team ID
827
+ event_type: Event type (team_created, task_added, task_claimed, etc.)
828
+ data: Event data
829
+ """
830
+ await self.broadcast({
831
+ "type": "team_event",
832
+ "team_id": team_id,
833
+ "event_type": event_type,
834
+ "data": data
835
+ })
836
+
758
837
  # =========================================================================
759
838
  # Generic Updates
760
839
  # =========================================================================