codetether 1.2.2__py3-none-any.whl

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 (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,1594 @@
1
+ """
2
+ OpenCode Bridge - Integrates OpenCode AI coding agent with A2A Server
3
+
4
+ This module provides a bridge between the A2A protocol server and OpenCode,
5
+ allowing web UI triggers to start AI agents working on registered codebases.
6
+
7
+ Architecture:
8
+ - Workers sync codebases, tasks, and sessions to PostgreSQL (via database.py)
9
+ - Bridge reads from PostgreSQL for a consistent view across replicas
10
+ - No SQLite persistence - all durable storage is in PostgreSQL
11
+ - In-memory caches are used for performance but are not authoritative
12
+
13
+ Production usage:
14
+ - Configure DATABASE_URL environment variable to point to PostgreSQL
15
+ - Workers register codebases and sync session state to PostgreSQL
16
+ - Multiple server replicas can read the same PostgreSQL data
17
+ - Monitor API queries PostgreSQL directly for session listings
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import os
24
+ import subprocess
25
+ import uuid
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from enum import Enum
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Callable
31
+
32
+ import aiohttp
33
+
34
+ # Import PostgreSQL database module
35
+ from . import database as db
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # OpenCode host configuration - allows container to connect to host VM's opencode
40
+ # Use 'host.docker.internal' when running in Docker on Linux/Mac/Windows
41
+ # Use the actual host IP when host.docker.internal is not available
42
+ OPENCODE_HOST = os.environ.get('OPENCODE_HOST', 'localhost')
43
+ OPENCODE_DEFAULT_PORT = int(os.environ.get('OPENCODE_PORT', '9777'))
44
+
45
+
46
+ class AgentStatus(str, Enum):
47
+ """Status of an OpenCode agent instance."""
48
+
49
+ IDLE = 'idle'
50
+ RUNNING = 'running'
51
+ BUSY = 'busy'
52
+ ERROR = 'error'
53
+ STOPPED = 'stopped'
54
+ WATCHING = 'watching' # Agent is watching for tasks
55
+
56
+
57
+ class AgentTaskStatus(str, Enum):
58
+ """Status of an agent task."""
59
+
60
+ PENDING = 'pending'
61
+ ASSIGNED = 'assigned'
62
+ RUNNING = 'running'
63
+ COMPLETED = 'completed'
64
+ FAILED = 'failed'
65
+ CANCELLED = 'cancelled'
66
+
67
+
68
+ # Model selector mapping: user-friendly names -> provider/model-id
69
+ # This allows users to say "use minimax" instead of "minimax/minimax-m2.1"
70
+ MODEL_SELECTOR = {
71
+ # Anthropic models
72
+ 'claude-sonnet': 'anthropic/claude-sonnet-4-20250514',
73
+ 'claude-sonnet-4': 'anthropic/claude-sonnet-4-20250514',
74
+ 'sonnet': 'anthropic/claude-sonnet-4-20250514',
75
+ 'claude-opus': 'anthropic/claude-opus-4-20250514',
76
+ 'opus': 'anthropic/claude-opus-4-20250514',
77
+ 'claude-haiku': 'anthropic/claude-haiku',
78
+ 'haiku': 'anthropic/claude-haiku',
79
+ # Minimax models
80
+ 'minimax': 'minimax/minimax-m2.1',
81
+ 'minimax-m2': 'minimax/minimax-m2.1',
82
+ 'minimax-m2.1': 'minimax/minimax-m2.1',
83
+ 'm2.1': 'minimax/minimax-m2.1',
84
+ # OpenAI models
85
+ 'gpt-4': 'openai/gpt-4',
86
+ 'gpt-4o': 'openai/gpt-4o',
87
+ 'gpt-4-turbo': 'openai/gpt-4-turbo',
88
+ 'gpt-4.1': 'openai/gpt-4.1',
89
+ 'o1': 'openai/o1',
90
+ 'o1-mini': 'openai/o1-mini',
91
+ 'o3': 'openai/o3',
92
+ 'o3-mini': 'openai/o3-mini',
93
+ # Google models
94
+ 'gemini': 'google/gemini-2.5-pro',
95
+ 'gemini-pro': 'google/gemini-2.5-pro',
96
+ 'gemini-2.5-pro': 'google/gemini-2.5-pro',
97
+ 'gemini-flash': 'google/gemini-2.5-flash',
98
+ 'gemini-2.5-flash': 'google/gemini-2.5-flash',
99
+ # xAI models
100
+ 'grok': 'xai/grok-3',
101
+ 'grok-3': 'xai/grok-3',
102
+ # Default (empty string or None uses agent default)
103
+ 'default': '',
104
+ '': '',
105
+ }
106
+
107
+ # List of valid model selector keys for enum validation
108
+ MODEL_SELECTOR_KEYS = list(MODEL_SELECTOR.keys())
109
+
110
+
111
+ def resolve_model(model_input: Optional[str]) -> Optional[str]:
112
+ """
113
+ Resolve a user-friendly model name to the full provider/model-id format.
114
+
115
+ Args:
116
+ model_input: User input like 'minimax', 'claude-sonnet', or full 'provider/model-id'
117
+
118
+ Returns:
119
+ Full provider/model-id string, or None if default should be used
120
+ """
121
+ if not model_input:
122
+ return None
123
+
124
+ model_lower = model_input.lower().strip()
125
+
126
+ # Check if it's already in provider/model format
127
+ if '/' in model_input:
128
+ return model_input
129
+
130
+ # Look up in selector mapping
131
+ if model_lower in MODEL_SELECTOR:
132
+ resolved = MODEL_SELECTOR[model_lower]
133
+ return resolved if resolved else None
134
+
135
+ # If not found, return as-is (let the worker handle validation)
136
+ logger.warning(
137
+ f"Unknown model selector '{model_input}', passing through as-is"
138
+ )
139
+ return model_input
140
+
141
+
142
+ @dataclass
143
+ class AgentTask:
144
+ """Represents a task assigned to an agent."""
145
+
146
+ id: str
147
+ codebase_id: str
148
+ title: str
149
+ prompt: str
150
+ agent_type: str = 'build' # build, plan, general, explore
151
+ model: Optional[str] = (
152
+ None # Full provider/model-id (e.g., 'minimax/minimax-m2.1')
153
+ )
154
+ status: AgentTaskStatus = AgentTaskStatus.PENDING
155
+ priority: int = 0 # Higher = more urgent
156
+ created_at: datetime = field(default_factory=datetime.utcnow)
157
+ started_at: Optional[datetime] = None
158
+ completed_at: Optional[datetime] = None
159
+ result: Optional[str] = None
160
+ error: Optional[str] = None
161
+ session_id: Optional[str] = None
162
+ metadata: Dict[str, Any] = field(default_factory=dict)
163
+
164
+ def to_dict(self) -> Dict[str, Any]:
165
+ return {
166
+ 'id': self.id,
167
+ 'codebase_id': self.codebase_id,
168
+ 'title': self.title,
169
+ 'prompt': self.prompt,
170
+ 'agent_type': self.agent_type,
171
+ 'model': self.model,
172
+ 'status': self.status.value,
173
+ 'priority': self.priority,
174
+ 'created_at': self.created_at.isoformat(),
175
+ 'started_at': self.started_at.isoformat()
176
+ if self.started_at
177
+ else None,
178
+ 'completed_at': self.completed_at.isoformat()
179
+ if self.completed_at
180
+ else None,
181
+ 'result': self.result,
182
+ 'error': self.error,
183
+ 'session_id': self.session_id,
184
+ 'metadata': self.metadata,
185
+ }
186
+
187
+
188
+ @dataclass
189
+ class RegisteredCodebase:
190
+ """Represents a codebase registered for agent work."""
191
+
192
+ id: str
193
+ name: str
194
+ path: str
195
+ description: str = ''
196
+ registered_at: datetime = field(default_factory=datetime.utcnow)
197
+ agent_config: Dict[str, Any] = field(default_factory=dict)
198
+ last_triggered: Optional[datetime] = None
199
+ status: AgentStatus = AgentStatus.IDLE
200
+ opencode_port: Optional[int] = None
201
+ session_id: Optional[str] = None
202
+ watch_mode: bool = False # Whether agent is in watch mode
203
+ watch_interval: int = 5 # Seconds between task checks
204
+ worker_id: Optional[str] = None # ID of the worker that owns this codebase
205
+
206
+ def to_dict(self) -> Dict[str, Any]:
207
+ return {
208
+ 'id': self.id,
209
+ 'name': self.name,
210
+ 'path': self.path,
211
+ 'description': self.description,
212
+ 'registered_at': self.registered_at.isoformat(),
213
+ 'agent_config': self.agent_config,
214
+ 'last_triggered': self.last_triggered.isoformat()
215
+ if self.last_triggered
216
+ else None,
217
+ 'status': self.status.value,
218
+ 'opencode_port': self.opencode_port,
219
+ 'session_id': self.session_id,
220
+ 'watch_mode': self.watch_mode,
221
+ 'watch_interval': self.watch_interval,
222
+ 'worker_id': self.worker_id,
223
+ }
224
+
225
+
226
+ @dataclass
227
+ class AgentTriggerRequest:
228
+ """Request to trigger an agent on a codebase."""
229
+
230
+ codebase_id: str
231
+ prompt: str
232
+ agent: str = 'build' # build, plan, general, explore
233
+ model: Optional[str] = None
234
+ files: List[str] = field(default_factory=list)
235
+ metadata: Dict[str, Any] = field(default_factory=dict)
236
+
237
+
238
+ @dataclass
239
+ class AgentTriggerResponse:
240
+ """Response from triggering an agent."""
241
+
242
+ success: bool
243
+ session_id: Optional[str] = None
244
+ message: str = ''
245
+ codebase_id: Optional[str] = None
246
+ agent: Optional[str] = None
247
+ error: Optional[str] = None
248
+
249
+ def to_dict(self) -> Dict[str, Any]:
250
+ return {
251
+ 'success': self.success,
252
+ 'session_id': self.session_id,
253
+ 'message': self.message,
254
+ 'codebase_id': self.codebase_id,
255
+ 'agent': self.agent,
256
+ 'error': self.error,
257
+ }
258
+
259
+
260
+ class OpenCodeBridge:
261
+ """
262
+ Bridge between A2A Server and OpenCode.
263
+
264
+ Manages codebase registrations, task queues, and triggers OpenCode agents
265
+ through its HTTP API. Supports watch mode where agents poll for tasks.
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ opencode_bin: Optional[str] = None,
271
+ default_port: int = None,
272
+ auto_start: bool = True,
273
+ db_path: Optional[str] = None,
274
+ opencode_host: Optional[str] = None,
275
+ ):
276
+ """
277
+ Initialize the OpenCode bridge.
278
+
279
+ Args:
280
+ opencode_bin: Path to opencode binary (auto-detected if None)
281
+ default_port: Default port for OpenCode server
282
+ auto_start: Whether to auto-start OpenCode when triggering
283
+ db_path: DEPRECATED - bridge now uses PostgreSQL from database.py
284
+ opencode_host: Host where OpenCode API is running (for container->host)
285
+ """
286
+ self.opencode_bin = opencode_bin or self._find_opencode_binary()
287
+ self.default_port = default_port or OPENCODE_DEFAULT_PORT
288
+ self.auto_start = auto_start
289
+ # OpenCode host - allows container to connect to host VM's opencode
290
+ self.opencode_host = opencode_host or OPENCODE_HOST
291
+
292
+ # In-memory caches (populated from PostgreSQL on demand)
293
+ self._codebases: Dict[str, RegisteredCodebase] = {}
294
+ self._tasks: Dict[str, AgentTask] = {} # task_id -> task
295
+ self._codebase_tasks: Dict[
296
+ str, List[str]
297
+ ] = {} # codebase_id -> [task_ids]
298
+
299
+ # Watch mode background tasks
300
+ self._watch_tasks: Dict[
301
+ str, asyncio.Task
302
+ ] = {} # codebase_id -> asyncio task
303
+
304
+ # Active OpenCode processes
305
+ self._processes: Dict[str, subprocess.Popen] = {}
306
+
307
+ # Port allocations
308
+ self._port_allocations: Dict[str, int] = {}
309
+ self._next_port = self.default_port
310
+
311
+ # Event callbacks
312
+ self._on_status_change: List[Callable] = []
313
+ self._on_message: List[Callable] = []
314
+ self._on_task_update: List[Callable] = []
315
+
316
+ # HTTP session for API calls
317
+ self._session: Optional[aiohttp.ClientSession] = None
318
+
319
+ logger.info(
320
+ f'OpenCode bridge initialized with binary: {self.opencode_bin}'
321
+ )
322
+ logger.info(f'OpenCode host: {self.opencode_host}:{self.default_port}')
323
+ logger.info(f'Using PostgreSQL database for persistence')
324
+
325
+ def _get_opencode_base_url(self, port: Optional[int] = None) -> str:
326
+ """
327
+ Get the base URL for OpenCode API.
328
+
329
+ Uses configured opencode_host to allow container->host communication.
330
+ """
331
+ p = port or self.default_port
332
+ return f'http://{self.opencode_host}:{p}'
333
+
334
+ async def _save_codebase(self, codebase: RegisteredCodebase):
335
+ """Save or update a codebase in PostgreSQL."""
336
+ try:
337
+ await db.db_upsert_codebase(
338
+ {
339
+ 'id': codebase.id,
340
+ 'name': codebase.name,
341
+ 'path': codebase.path,
342
+ 'description': codebase.description,
343
+ 'worker_id': codebase.worker_id,
344
+ 'agent_config': codebase.agent_config,
345
+ 'created_at': codebase.registered_at.isoformat(),
346
+ 'updated_at': datetime.utcnow().isoformat(),
347
+ 'status': codebase.status.value,
348
+ 'session_id': codebase.session_id,
349
+ 'opencode_port': codebase.opencode_port,
350
+ }
351
+ )
352
+ except Exception as e:
353
+ logger.error(f'Failed to save codebase to PostgreSQL: {e}')
354
+
355
+ async def _delete_codebase(self, codebase_id: str):
356
+ """Delete a codebase from PostgreSQL."""
357
+ try:
358
+ await db.db_delete_codebase(codebase_id)
359
+ except Exception as e:
360
+ logger.error(f'Failed to delete codebase from PostgreSQL: {e}')
361
+
362
+ async def _save_task(self, task: AgentTask):
363
+ """Save or update a task in PostgreSQL."""
364
+ try:
365
+ await db.db_upsert_task(
366
+ {
367
+ 'id': task.id,
368
+ 'codebase_id': task.codebase_id,
369
+ 'title': task.title,
370
+ 'prompt': task.prompt,
371
+ 'agent_type': task.agent_type,
372
+ 'status': task.status.value,
373
+ 'priority': task.priority,
374
+ 'worker_id': None, # Will be set by worker when claimed
375
+ 'result': task.result,
376
+ 'error': task.error,
377
+ 'metadata': task.metadata,
378
+ 'created_at': task.created_at.isoformat(),
379
+ 'updated_at': datetime.utcnow().isoformat(),
380
+ 'started_at': task.started_at.isoformat()
381
+ if task.started_at
382
+ else None,
383
+ 'completed_at': task.completed_at.isoformat()
384
+ if task.completed_at
385
+ else None,
386
+ }
387
+ )
388
+ except Exception as e:
389
+ logger.error(f'Failed to save task to PostgreSQL: {e}')
390
+
391
+ def _task_from_db_row(self, row: Dict[str, Any]) -> AgentTask:
392
+ """Convert a database row to an AgentTask object."""
393
+
394
+ def parse_dt(val):
395
+ if val is None:
396
+ return None
397
+ if isinstance(val, datetime):
398
+ return val
399
+ if isinstance(val, str):
400
+ # Handle ISO format with or without timezone
401
+ try:
402
+ return datetime.fromisoformat(val.replace('Z', '+00:00'))
403
+ except ValueError:
404
+ return datetime.strptime(val, '%Y-%m-%dT%H:%M:%S.%f')
405
+ return None
406
+
407
+ return AgentTask(
408
+ id=row['id'],
409
+ codebase_id=row.get('codebase_id', 'global'),
410
+ title=row.get('title', ''),
411
+ prompt=row.get('prompt', row.get('title', '')),
412
+ agent_type=row.get('agent_type', 'build'),
413
+ status=AgentTaskStatus(row.get('status', 'pending')),
414
+ priority=row.get('priority', 0),
415
+ created_at=parse_dt(row.get('created_at')) or datetime.utcnow(),
416
+ started_at=parse_dt(row.get('started_at')),
417
+ completed_at=parse_dt(row.get('completed_at')),
418
+ result=row.get('result'),
419
+ error=row.get('error'),
420
+ session_id=row.get('session_id'),
421
+ metadata=row.get('metadata') or {},
422
+ )
423
+
424
+ async def _load_task_from_db(self, task_id: str) -> Optional[AgentTask]:
425
+ """Load a task from PostgreSQL and cache it in memory."""
426
+ try:
427
+ row = await db.db_get_task(task_id)
428
+ if row:
429
+ task = self._task_from_db_row(row)
430
+ # Cache in memory
431
+ self._tasks[task_id] = task
432
+ if task.codebase_id not in self._codebase_tasks:
433
+ self._codebase_tasks[task.codebase_id] = []
434
+ if task_id not in self._codebase_tasks[task.codebase_id]:
435
+ self._codebase_tasks[task.codebase_id].append(task_id)
436
+ return task
437
+ except Exception as e:
438
+ logger.error(f'Failed to load task from PostgreSQL: {e}')
439
+ return None
440
+
441
+ async def _load_tasks_from_db(
442
+ self,
443
+ codebase_id: Optional[str] = None,
444
+ status: Optional[str] = None,
445
+ limit: int = 100,
446
+ ) -> List[AgentTask]:
447
+ """Load tasks from PostgreSQL and cache them in memory."""
448
+ try:
449
+ rows = await db.db_list_tasks(
450
+ codebase_id=codebase_id,
451
+ status=status,
452
+ limit=limit,
453
+ )
454
+ tasks = []
455
+ for row in rows:
456
+ task = self._task_from_db_row(row)
457
+ # Cache in memory
458
+ self._tasks[task.id] = task
459
+ if task.codebase_id not in self._codebase_tasks:
460
+ self._codebase_tasks[task.codebase_id] = []
461
+ if task.id not in self._codebase_tasks[task.codebase_id]:
462
+ self._codebase_tasks[task.codebase_id].append(task.id)
463
+ tasks.append(task)
464
+ return tasks
465
+ except Exception as e:
466
+ logger.error(f'Failed to load tasks from PostgreSQL: {e}')
467
+ return []
468
+
469
+ async def _update_codebase_status(
470
+ self, codebase: RegisteredCodebase, status: AgentStatus
471
+ ):
472
+ """Update codebase status and persist to PostgreSQL."""
473
+ codebase.status = status
474
+ await self._save_codebase(codebase)
475
+
476
+ def _find_opencode_binary(self) -> str:
477
+ """Find the opencode binary in common locations."""
478
+ # Check environment variable first
479
+ env_bin = os.environ.get('OPENCODE_BIN_PATH')
480
+ if env_bin and os.path.exists(env_bin):
481
+ return env_bin
482
+
483
+ # Check common locations
484
+ locations = [
485
+ # Local project
486
+ str(
487
+ Path(__file__).parent.parent
488
+ / 'opencode'
489
+ / 'packages'
490
+ / 'opencode'
491
+ / 'bin'
492
+ / 'opencode'
493
+ ),
494
+ # System paths
495
+ '/usr/local/bin/opencode',
496
+ '/usr/bin/opencode',
497
+ # User paths
498
+ str(Path.home() / '.local' / 'bin' / 'opencode'),
499
+ str(Path.home() / 'bin' / 'opencode'),
500
+ str(Path.home() / '.opencode' / 'bin' / 'opencode'),
501
+ # npm/bun global
502
+ str(Path.home() / '.bun' / 'bin' / 'opencode'),
503
+ str(Path.home() / '.npm-global' / 'bin' / 'opencode'),
504
+ ]
505
+
506
+ for loc in locations:
507
+ if Path(loc).exists() and os.access(loc, os.X_OK):
508
+ return loc
509
+
510
+ # Try which command
511
+ try:
512
+ result = subprocess.run(
513
+ ['which', 'opencode'], capture_output=True, text=True
514
+ )
515
+ if result.returncode == 0:
516
+ return result.stdout.strip()
517
+ except Exception:
518
+ pass
519
+
520
+ # Fallback to just "opencode" (assume in PATH)
521
+ return 'opencode'
522
+
523
+ async def _get_session(self) -> aiohttp.ClientSession:
524
+ """Get or create HTTP session."""
525
+ if self._session is None or self._session.closed:
526
+ self._session = aiohttp.ClientSession(
527
+ timeout=aiohttp.ClientTimeout(total=30)
528
+ )
529
+ return self._session
530
+
531
+ async def close(self):
532
+ """Close the bridge and cleanup resources."""
533
+ # Stop all running processes
534
+ for codebase_id in list(self._processes.keys()):
535
+ await self.stop_agent(codebase_id)
536
+
537
+ # Close HTTP session
538
+ if self._session and not self._session.closed:
539
+ await self._session.close()
540
+
541
+ async def register_codebase(
542
+ self,
543
+ name: str,
544
+ path: str,
545
+ description: str = '',
546
+ agent_config: Optional[Dict[str, Any]] = None,
547
+ worker_id: Optional[str] = None,
548
+ codebase_id: Optional[str] = None,
549
+ ) -> RegisteredCodebase:
550
+ """
551
+ Register a codebase for agent work.
552
+
553
+ Args:
554
+ name: Display name for the codebase
555
+ path: Absolute path to the codebase directory (on worker machine)
556
+ description: Optional description
557
+ agent_config: Optional OpenCode agent configuration
558
+ worker_id: ID of the worker that owns this codebase (for remote execution)
559
+
560
+ Returns:
561
+ The registered codebase entry
562
+
563
+ Note: Path validation is skipped when a worker_id is provided, as the path
564
+ exists on the remote worker machine, not on the A2A server.
565
+ """
566
+ # Normalize path
567
+ path = os.path.abspath(os.path.expanduser(path))
568
+
569
+ # NOTE: Path validation removed - the control plane never executes locally.
570
+ # Paths are validated by workers when they register codebases.
571
+
572
+ # If the caller provided a specific ID and we already have it in-memory,
573
+ # update that entry in-place.
574
+ if codebase_id and codebase_id in self._codebases:
575
+ codebase = self._codebases[codebase_id]
576
+ codebase.name = name
577
+ codebase.description = description
578
+ codebase.agent_config = agent_config or {}
579
+ if worker_id:
580
+ codebase.worker_id = worker_id
581
+ codebase.opencode_port = (
582
+ None # Clear local port if it's now remote
583
+ )
584
+ codebase.status = AgentStatus.IDLE
585
+ await self._save_codebase(codebase) # Persist update
586
+
587
+ worker_info = f' (worker: {worker_id})' if worker_id else ''
588
+ logger.info(
589
+ f'Updated existing codebase: {name} ({codebase_id}) at {path}{worker_info}'
590
+ )
591
+ return codebase
592
+
593
+ # Check for existing codebase with same path - update instead of duplicate
594
+ existing_id = None
595
+ for cid, cb in self._codebases.items():
596
+ if cb.path == path:
597
+ existing_id = cid
598
+ break
599
+
600
+ if existing_id:
601
+ # Update existing codebase instead of creating duplicate.
602
+ # If the caller supplied a conflicting codebase_id, keep the existing
603
+ # in-memory ID (it is already referenced by tasks/sessions).
604
+ if codebase_id and codebase_id != existing_id:
605
+ logger.info(
606
+ f'register_codebase: ignoring provided codebase_id={codebase_id} '
607
+ f'because path is already registered as {existing_id}'
608
+ )
609
+ codebase = self._codebases[existing_id]
610
+ codebase.name = name
611
+ codebase.description = description
612
+ codebase.agent_config = agent_config or {}
613
+ if worker_id:
614
+ codebase.worker_id = worker_id
615
+ codebase.opencode_port = (
616
+ None # Clear local port if it's now remote
617
+ )
618
+ codebase.status = AgentStatus.IDLE
619
+ await self._save_codebase(codebase) # Persist update
620
+
621
+ worker_info = f' (worker: {worker_id})' if worker_id else ''
622
+ logger.info(
623
+ f'Updated existing codebase: {name} ({existing_id}) at {path}{worker_info}'
624
+ )
625
+ return codebase
626
+
627
+ # Use caller-provided ID when available (e.g., when rehydrating from
628
+ # PostgreSQL/Redis after a restart), otherwise generate a new ID.
629
+ if not codebase_id:
630
+ codebase_id = str(uuid.uuid4())[:8]
631
+
632
+ codebase = RegisteredCodebase(
633
+ id=codebase_id,
634
+ name=name,
635
+ path=path,
636
+ description=description,
637
+ agent_config=agent_config or {},
638
+ worker_id=worker_id,
639
+ )
640
+
641
+ self._codebases[codebase_id] = codebase
642
+ await self._save_codebase(codebase) # Persist to database
643
+
644
+ worker_info = f' (worker: {worker_id})' if worker_id else ''
645
+ logger.info(
646
+ f'Registered codebase: {name} ({codebase_id}) at {path}{worker_info}'
647
+ )
648
+
649
+ return codebase
650
+
651
+ async def unregister_codebase(self, codebase_id: str) -> bool:
652
+ """Remove a codebase from the registry."""
653
+ if codebase_id in self._codebases:
654
+ # Stop any running agent
655
+ if codebase_id in self._processes:
656
+ await self.stop_agent(codebase_id)
657
+
658
+ del self._codebases[codebase_id]
659
+ await self._delete_codebase(codebase_id) # Remove from database
660
+ logger.info(f'Unregistered codebase: {codebase_id}')
661
+ return True
662
+ return False
663
+
664
+ def get_codebase(self, codebase_id: str) -> Optional[RegisteredCodebase]:
665
+ """Get a registered codebase by ID."""
666
+ return self._codebases.get(codebase_id)
667
+
668
+ def list_codebases(self) -> List[RegisteredCodebase]:
669
+ """List all registered codebases."""
670
+ return list(self._codebases.values())
671
+
672
+ def _allocate_port(self, codebase_id: str) -> int:
673
+ """Allocate a port for an OpenCode instance."""
674
+ if codebase_id in self._port_allocations:
675
+ return self._port_allocations[codebase_id]
676
+
677
+ port = self._next_port
678
+ self._port_allocations[codebase_id] = port
679
+ self._next_port += 1
680
+ return port
681
+
682
+ async def _start_opencode_server(self, codebase: RegisteredCodebase) -> int:
683
+ """
684
+ Start an OpenCode server for a codebase.
685
+
686
+ Returns the port number.
687
+ """
688
+ port = self._allocate_port(codebase.id)
689
+
690
+ # Build command
691
+ cmd = [
692
+ self.opencode_bin,
693
+ 'serve',
694
+ '--port',
695
+ str(port),
696
+ ]
697
+
698
+ logger.info(
699
+ f'Starting OpenCode server for {codebase.name} on port {port}'
700
+ )
701
+ logger.debug(f'Command: {" ".join(cmd)}')
702
+
703
+ try:
704
+ # Start process in the codebase directory
705
+ process = subprocess.Popen(
706
+ cmd,
707
+ cwd=codebase.path,
708
+ stdout=subprocess.PIPE,
709
+ stderr=subprocess.PIPE,
710
+ env={**os.environ, 'NO_COLOR': '1'},
711
+ )
712
+
713
+ self._processes[codebase.id] = process
714
+ codebase.opencode_port = port
715
+ await self._update_codebase_status(codebase, AgentStatus.RUNNING)
716
+
717
+ # Wait a moment for server to start
718
+ await asyncio.sleep(2)
719
+
720
+ # Verify server is running
721
+ if process.poll() is not None:
722
+ # Process exited
723
+ stderr = (
724
+ process.stderr.read().decode() if process.stderr else ''
725
+ )
726
+ raise RuntimeError(f'OpenCode server failed to start: {stderr}')
727
+
728
+ logger.info(f'OpenCode server started successfully on port {port}')
729
+ return port
730
+
731
+ except Exception as e:
732
+ logger.error(f'Failed to start OpenCode server: {e}')
733
+ await self._update_codebase_status(codebase, AgentStatus.ERROR)
734
+ raise
735
+
736
+ async def stop_agent(self, codebase_id: str) -> bool:
737
+ """Stop a running OpenCode agent."""
738
+ codebase = self._codebases.get(codebase_id)
739
+ if not codebase:
740
+ return False
741
+
742
+ process = self._processes.get(codebase_id)
743
+ if process:
744
+ try:
745
+ process.terminate()
746
+ process.wait(timeout=5)
747
+ except subprocess.TimeoutExpired:
748
+ process.kill()
749
+
750
+ del self._processes[codebase_id]
751
+
752
+ await self._update_codebase_status(codebase, AgentStatus.STOPPED)
753
+ codebase.opencode_port = None
754
+
755
+ # Free port allocation
756
+ if codebase_id in self._port_allocations:
757
+ del self._port_allocations[codebase_id]
758
+
759
+ logger.info(f'Stopped OpenCode agent for {codebase.name}')
760
+ return True
761
+
762
+ async def trigger_agent(
763
+ self,
764
+ request: AgentTriggerRequest,
765
+ ) -> AgentTriggerResponse:
766
+ """
767
+ Trigger an OpenCode agent to work on a codebase.
768
+
769
+ Args:
770
+ request: The trigger request with prompt and configuration
771
+
772
+ Returns:
773
+ Response with session ID and status
774
+ """
775
+ codebase = self._codebases.get(request.codebase_id)
776
+ if not codebase:
777
+ return AgentTriggerResponse(
778
+ success=False,
779
+ error=f'Codebase not found: {request.codebase_id}',
780
+ )
781
+
782
+ # For remote workers, create a task instead of local execution
783
+ if codebase.worker_id:
784
+ task = await self.create_task(
785
+ codebase_id=request.codebase_id,
786
+ title=request.prompt[:80]
787
+ + ('...' if len(request.prompt) > 80 else ''),
788
+ prompt=request.prompt,
789
+ agent_type=request.agent,
790
+ metadata={
791
+ 'model': request.model,
792
+ 'files': request.files,
793
+ **request.metadata,
794
+ },
795
+ )
796
+ if task:
797
+ logger.info(
798
+ f'Created task {task.id} for remote worker {codebase.worker_id}'
799
+ )
800
+ return AgentTriggerResponse(
801
+ success=True,
802
+ session_id=task.id, # Use task ID as session ID for tracking
803
+ message=f'Task queued for remote worker (task: {task.id})',
804
+ codebase_id=request.codebase_id,
805
+ agent=request.agent,
806
+ )
807
+ else:
808
+ return AgentTriggerResponse(
809
+ success=False,
810
+ error='Failed to create task for remote worker',
811
+ )
812
+
813
+ try:
814
+ # Local execution: Ensure OpenCode server is running
815
+ if (
816
+ not codebase.opencode_port
817
+ or codebase.status != AgentStatus.RUNNING
818
+ ):
819
+ if self.auto_start:
820
+ await self._start_opencode_server(codebase)
821
+ else:
822
+ return AgentTriggerResponse(
823
+ success=False,
824
+ error='OpenCode server not running and auto_start is disabled',
825
+ )
826
+
827
+ # Build API URL - use configured host for container->host communication
828
+ base_url = self._get_opencode_base_url(codebase.opencode_port)
829
+
830
+ session = await self._get_session()
831
+
832
+ # Create a new session
833
+ async with session.post(f'{base_url}/session') as resp:
834
+ if resp.status != 200:
835
+ raise RuntimeError(
836
+ f'Failed to create session: {await resp.text()}'
837
+ )
838
+ session_data = await resp.json()
839
+ session_id = session_data.get('id')
840
+
841
+ codebase.session_id = session_id
842
+ codebase.last_triggered = datetime.utcnow()
843
+ await self._update_codebase_status(codebase, AgentStatus.BUSY)
844
+
845
+ # Build prompt parts
846
+ parts = [{'type': 'text', 'text': request.prompt}]
847
+
848
+ # Add file references if specified
849
+ for file_path in request.files:
850
+ full_path = os.path.join(codebase.path, file_path)
851
+ if os.path.exists(full_path):
852
+ parts.append(
853
+ {
854
+ 'type': 'file',
855
+ 'url': f'file://{full_path}',
856
+ 'filename': file_path,
857
+ 'mime': 'text/plain',
858
+ }
859
+ )
860
+
861
+ # Trigger prompt
862
+ prompt_payload = {
863
+ 'sessionID': session_id,
864
+ 'parts': parts,
865
+ 'agent': request.agent,
866
+ }
867
+
868
+ if request.model:
869
+ parts_model = request.model.split('/')
870
+ if len(parts_model) == 2:
871
+ prompt_payload['model'] = {
872
+ 'providerID': parts_model[0],
873
+ 'modelID': parts_model[1],
874
+ }
875
+
876
+ async with session.post(
877
+ f'{base_url}/session/{session_id}/prompt',
878
+ json=prompt_payload,
879
+ ) as resp:
880
+ if resp.status != 200:
881
+ raise RuntimeError(
882
+ f'Failed to send prompt: {await resp.text()}'
883
+ )
884
+
885
+ # Notify callbacks
886
+ await self._notify_status_change(codebase)
887
+
888
+ logger.info(
889
+ f'Triggered agent {request.agent} on {codebase.name} (session: {session_id})'
890
+ )
891
+
892
+ return AgentTriggerResponse(
893
+ success=True,
894
+ session_id=session_id,
895
+ message=f"Agent '{request.agent}' triggered successfully",
896
+ codebase_id=codebase.id,
897
+ agent=request.agent,
898
+ )
899
+
900
+ except Exception as e:
901
+ logger.error(f'Failed to trigger agent: {e}')
902
+ await self._update_codebase_status(codebase, AgentStatus.ERROR)
903
+ return AgentTriggerResponse(
904
+ success=False,
905
+ error=str(e),
906
+ codebase_id=codebase.id,
907
+ )
908
+
909
+ async def get_available_models(self) -> List[Dict[str, Any]]:
910
+ """
911
+ Fetch available models from OpenCode.
912
+
913
+ Tries to query an active OpenCode instance. If none are running,
914
+ it may start a temporary one or fall back to reading config.
915
+ """
916
+ # 1. Try to find an active OpenCode instance
917
+ active_port = None
918
+ for codebase in self._codebases.values():
919
+ if (
920
+ codebase.opencode_port
921
+ and codebase.status == AgentStatus.RUNNING
922
+ ):
923
+ active_port = codebase.opencode_port
924
+ break
925
+
926
+ if not active_port:
927
+ # Try default port
928
+ active_port = self.default_port
929
+
930
+ try:
931
+ base_url = self._get_opencode_base_url(active_port)
932
+ session = await self._get_session()
933
+
934
+ async with session.get(f'{base_url}/provider') as resp:
935
+ if resp.status == 200:
936
+ data = await resp.json()
937
+ # Transform OpenCode provider/model format to A2A format
938
+ models = []
939
+ all_providers = data.get('all', [])
940
+ for provider in all_providers:
941
+ provider_id = provider.get('id')
942
+ provider_name = provider.get('name', provider_id)
943
+ for model_id, model_info in provider.get(
944
+ 'models', {}
945
+ ).items():
946
+ models.append(
947
+ {
948
+ 'id': f'{provider_id}/{model_id}',
949
+ 'name': model_info.get('name', model_id),
950
+ 'provider': provider_name,
951
+ 'capabilities': {
952
+ 'reasoning': model_info.get(
953
+ 'reasoning', False
954
+ ),
955
+ 'attachment': model_info.get(
956
+ 'attachment', False
957
+ ),
958
+ 'tool_call': model_info.get(
959
+ 'tool_call', False
960
+ ),
961
+ },
962
+ }
963
+ )
964
+
965
+ # Sort models: Gemini 3 Flash first, then by provider
966
+ models.sort(
967
+ key=lambda x: (
968
+ 0 if 'gemini-3-flash' in x['id'].lower() else 1,
969
+ x['provider'],
970
+ x['name'],
971
+ )
972
+ )
973
+
974
+ return models
975
+ except Exception as e:
976
+ logger.debug(f'Failed to fetch models from OpenCode API: {e}')
977
+
978
+ return []
979
+
980
+ async def get_agent_status(
981
+ self, codebase_id: str
982
+ ) -> Optional[Dict[str, Any]]:
983
+ """Get the current status of an agent."""
984
+ codebase = self._codebases.get(codebase_id)
985
+ if not codebase:
986
+ return None
987
+
988
+ result = codebase.to_dict()
989
+
990
+ # If running, try to get more info from OpenCode API
991
+ if codebase.opencode_port and codebase.session_id:
992
+ try:
993
+ session = await self._get_session()
994
+ base_url = self._get_opencode_base_url(codebase.opencode_port)
995
+
996
+ async with session.get(
997
+ f'{base_url}/session/{codebase.session_id}/message',
998
+ params={'limit': 10},
999
+ ) as resp:
1000
+ if resp.status == 200:
1001
+ messages = await resp.json()
1002
+ result['recent_messages'] = messages
1003
+
1004
+ except Exception as e:
1005
+ logger.debug(f'Could not fetch session info: {e}')
1006
+
1007
+ return result
1008
+
1009
+ async def send_message(
1010
+ self,
1011
+ codebase_id: str,
1012
+ message: str,
1013
+ agent: Optional[str] = None,
1014
+ ) -> AgentTriggerResponse:
1015
+ """Send an additional message to an active agent session."""
1016
+ codebase = self._codebases.get(codebase_id)
1017
+ if not codebase:
1018
+ return AgentTriggerResponse(
1019
+ success=False,
1020
+ error=f'Codebase not found: {codebase_id}',
1021
+ )
1022
+
1023
+ if not codebase.session_id or not codebase.opencode_port:
1024
+ return AgentTriggerResponse(
1025
+ success=False,
1026
+ error='No active session for this codebase',
1027
+ )
1028
+
1029
+ try:
1030
+ session = await self._get_session()
1031
+ base_url = self._get_opencode_base_url(codebase.opencode_port)
1032
+
1033
+ payload = {
1034
+ 'sessionID': codebase.session_id,
1035
+ 'parts': [{'type': 'text', 'text': message}],
1036
+ }
1037
+
1038
+ if agent:
1039
+ payload['agent'] = agent
1040
+
1041
+ async with session.post(
1042
+ f'{base_url}/session/{codebase.session_id}/prompt',
1043
+ json=payload,
1044
+ ) as resp:
1045
+ if resp.status != 200:
1046
+ raise RuntimeError(
1047
+ f'Failed to send message: {await resp.text()}'
1048
+ )
1049
+
1050
+ await self._update_codebase_status(codebase, AgentStatus.BUSY)
1051
+ codebase.last_triggered = datetime.utcnow()
1052
+
1053
+ return AgentTriggerResponse(
1054
+ success=True,
1055
+ session_id=codebase.session_id,
1056
+ message='Message sent successfully',
1057
+ codebase_id=codebase.id,
1058
+ )
1059
+
1060
+ except Exception as e:
1061
+ logger.error(f'Failed to send message: {e}')
1062
+ return AgentTriggerResponse(
1063
+ success=False,
1064
+ error=str(e),
1065
+ codebase_id=codebase.id,
1066
+ )
1067
+
1068
+ async def interrupt_agent(self, codebase_id: str) -> bool:
1069
+ """Interrupt the current agent task."""
1070
+ codebase = self._codebases.get(codebase_id)
1071
+ if (
1072
+ not codebase
1073
+ or not codebase.session_id
1074
+ or not codebase.opencode_port
1075
+ ):
1076
+ return False
1077
+
1078
+ try:
1079
+ session = await self._get_session()
1080
+ base_url = self._get_opencode_base_url(codebase.opencode_port)
1081
+
1082
+ async with session.post(
1083
+ f'{base_url}/session/{codebase.session_id}/interrupt'
1084
+ ) as resp:
1085
+ if resp.status == 200:
1086
+ await self._update_codebase_status(
1087
+ codebase, AgentStatus.RUNNING
1088
+ )
1089
+ logger.info(f'Interrupted agent for {codebase.name}')
1090
+ return True
1091
+
1092
+ except Exception as e:
1093
+ logger.error(f'Failed to interrupt agent: {e}')
1094
+
1095
+ return False
1096
+
1097
+ def on_status_change(self, callback: Callable):
1098
+ """Register a callback for status changes."""
1099
+ self._on_status_change.append(callback)
1100
+
1101
+ def on_message(self, callback: Callable):
1102
+ """Register a callback for agent messages."""
1103
+ self._on_message.append(callback)
1104
+
1105
+ async def _notify_status_change(self, codebase: RegisteredCodebase):
1106
+ """Notify registered callbacks of status change."""
1107
+ for callback in self._on_status_change:
1108
+ try:
1109
+ if asyncio.iscoroutinefunction(callback):
1110
+ await callback(codebase)
1111
+ else:
1112
+ callback(codebase)
1113
+ except Exception as e:
1114
+ logger.error(f'Error in status change callback: {e}')
1115
+
1116
+ # ========================================
1117
+ # Task Management
1118
+ # ========================================
1119
+
1120
+ async def create_task(
1121
+ self,
1122
+ codebase_id: str,
1123
+ title: str,
1124
+ prompt: str,
1125
+ agent_type: str = 'build',
1126
+ priority: int = 0,
1127
+ model: Optional[str] = None,
1128
+ metadata: Optional[Dict[str, Any]] = None,
1129
+ ) -> Optional[AgentTask]:
1130
+ """
1131
+ Create a new task for an agent.
1132
+
1133
+ Special codebase_id values:
1134
+ - '__pending__': Registration tasks that any worker can claim
1135
+ - 'global': Tasks without a specific codebase that any worker with a registered global codebase can pick up
1136
+
1137
+ Args:
1138
+ model: Full provider/model-id (e.g., 'minimax/minimax-m2.1'). Use resolve_model() to convert friendly names.
1139
+ """
1140
+ # Allow special '__pending__' and 'global' codebase_id values
1141
+ # 'global' is for MCP tasks that any worker with a registered global codebase can pick up
1142
+ if codebase_id not in ('__pending__', 'global'):
1143
+ codebase = self._codebases.get(codebase_id)
1144
+ if not codebase:
1145
+ logger.error(
1146
+ f'Cannot create task: codebase {codebase_id} not found'
1147
+ )
1148
+ return None
1149
+ codebase_name = codebase.name
1150
+ elif codebase_id == '__pending__':
1151
+ codebase_name = 'pending-registration'
1152
+ else:
1153
+ codebase_name = 'global'
1154
+
1155
+ task_id = str(uuid.uuid4())
1156
+ task = AgentTask(
1157
+ id=task_id,
1158
+ codebase_id=codebase_id,
1159
+ title=title,
1160
+ prompt=prompt,
1161
+ agent_type=agent_type,
1162
+ model=model,
1163
+ priority=priority,
1164
+ metadata=metadata or {},
1165
+ )
1166
+
1167
+ self._tasks[task_id] = task
1168
+
1169
+ # Add to codebase task list
1170
+ if codebase_id not in self._codebase_tasks:
1171
+ self._codebase_tasks[codebase_id] = []
1172
+ self._codebase_tasks[codebase_id].append(task_id)
1173
+
1174
+ # Persist to database
1175
+ await self._save_task(task)
1176
+
1177
+ logger.info(f'Created task {task_id} for {codebase_name}: {title}')
1178
+
1179
+ # Notify callbacks
1180
+ asyncio.create_task(self._notify_task_update(task))
1181
+
1182
+ return task
1183
+
1184
+ async def get_task(self, task_id: str) -> Optional[AgentTask]:
1185
+ """Get a task by ID. Checks in-memory cache first, then database."""
1186
+ # Check in-memory cache first
1187
+ task = self._tasks.get(task_id)
1188
+ if task:
1189
+ return task
1190
+ # Fall back to database
1191
+ return await self._load_task_from_db(task_id)
1192
+
1193
+ async def list_tasks(
1194
+ self,
1195
+ codebase_id: Optional[str] = None,
1196
+ status: Optional[AgentTaskStatus] = None,
1197
+ ) -> List[AgentTask]:
1198
+ """List tasks, optionally filtered by codebase or status. Loads from database."""
1199
+ # Load tasks from database (this also caches them in memory)
1200
+ status_str = status.value if status else None
1201
+ tasks = await self._load_tasks_from_db(
1202
+ codebase_id=codebase_id,
1203
+ status=status_str,
1204
+ limit=1000, # Reasonable limit
1205
+ )
1206
+
1207
+ # Sort by priority (desc) then created_at (asc)
1208
+ tasks.sort(key=lambda t: (-t.priority, t.created_at))
1209
+
1210
+ return tasks
1211
+
1212
+ async def get_next_pending_task(
1213
+ self, codebase_id: str
1214
+ ) -> Optional[AgentTask]:
1215
+ """Get the next pending task for a codebase."""
1216
+ pending = await self.list_tasks(
1217
+ codebase_id=codebase_id, status=AgentTaskStatus.PENDING
1218
+ )
1219
+ return pending[0] if pending else None
1220
+
1221
+ async def update_task_status(
1222
+ self,
1223
+ task_id: str,
1224
+ status: AgentTaskStatus,
1225
+ result: Optional[str] = None,
1226
+ error: Optional[str] = None,
1227
+ session_id: Optional[str] = None,
1228
+ ) -> Optional[AgentTask]:
1229
+ """Update task status."""
1230
+ task = self._tasks.get(task_id)
1231
+ if not task:
1232
+ # Task not in memory - try to load from database
1233
+ # (handles tasks created via direct DB writes, e.g., trigger endpoint)
1234
+ task = await self._load_task_from_db(task_id)
1235
+ if not task:
1236
+ return None
1237
+ # Cache it for future updates
1238
+ self._tasks[task_id] = task
1239
+
1240
+ task.status = status
1241
+
1242
+ # Idempotency: workers may send multiple RUNNING updates (e.g., once
1243
+ # to claim and later to attach session_id). Preserve the original
1244
+ # started/completed timestamps.
1245
+ if status == AgentTaskStatus.RUNNING:
1246
+ if task.started_at is None:
1247
+ task.started_at = datetime.utcnow()
1248
+ elif status in (
1249
+ AgentTaskStatus.COMPLETED,
1250
+ AgentTaskStatus.FAILED,
1251
+ AgentTaskStatus.CANCELLED,
1252
+ ):
1253
+ if task.completed_at is None:
1254
+ task.completed_at = datetime.utcnow()
1255
+
1256
+ # Allow workers (or the control plane) to attach the active OpenCode
1257
+ # session ID for UI deep-linking and eager message sync.
1258
+ if session_id and session_id != task.session_id:
1259
+ task.session_id = session_id
1260
+
1261
+ if result:
1262
+ task.result = result
1263
+ if error:
1264
+ task.error = error
1265
+
1266
+ # Persist to database
1267
+ await self._save_task(task)
1268
+
1269
+ asyncio.create_task(self._notify_task_update(task))
1270
+
1271
+ return task
1272
+
1273
+ async def cancel_task(self, task_id: str) -> bool:
1274
+ """Cancel a pending task."""
1275
+ task = self._tasks.get(task_id)
1276
+ if not task:
1277
+ return False
1278
+
1279
+ if task.status not in (
1280
+ AgentTaskStatus.PENDING,
1281
+ AgentTaskStatus.ASSIGNED,
1282
+ ):
1283
+ return False
1284
+
1285
+ task.status = AgentTaskStatus.CANCELLED
1286
+ task.completed_at = datetime.utcnow()
1287
+
1288
+ # Persist to database
1289
+ await self._save_task(task)
1290
+
1291
+ asyncio.create_task(self._notify_task_update(task))
1292
+
1293
+ return True
1294
+
1295
+ def on_task_update(self, callback: Callable):
1296
+ """Register a callback for task updates."""
1297
+ self._on_task_update.append(callback)
1298
+
1299
+ async def _notify_task_update(self, task: AgentTask):
1300
+ """Notify registered callbacks of task update."""
1301
+ for callback in self._on_task_update:
1302
+ try:
1303
+ if asyncio.iscoroutinefunction(callback):
1304
+ await callback(task)
1305
+ else:
1306
+ callback(task)
1307
+ except Exception as e:
1308
+ logger.error(f'Error in task update callback: {e}')
1309
+
1310
+ # ========================================
1311
+ # Watch Mode (Persistent Agent Workers)
1312
+ # ========================================
1313
+
1314
+ async def start_watch_mode(
1315
+ self, codebase_id: str, interval: int = 5
1316
+ ) -> bool:
1317
+ """
1318
+ Start watch mode for a codebase - agent will poll for and execute tasks.
1319
+
1320
+ Args:
1321
+ codebase_id: ID of the codebase
1322
+ interval: Seconds between task checks
1323
+
1324
+ Returns:
1325
+ True if watch mode started successfully
1326
+ """
1327
+ codebase = self._codebases.get(codebase_id)
1328
+ if not codebase:
1329
+ logger.error(
1330
+ f'Cannot start watch mode: codebase {codebase_id} not found'
1331
+ )
1332
+ return False
1333
+
1334
+ if codebase_id in self._watch_tasks:
1335
+ logger.warning(f'Watch mode already running for {codebase.name}')
1336
+ return True
1337
+
1338
+ # For remote workers, watch mode is just a flag - the worker polls automatically
1339
+ if codebase.worker_id:
1340
+ codebase.watch_mode = True
1341
+ codebase.watch_interval = interval
1342
+ await self._update_codebase_status(codebase, AgentStatus.WATCHING)
1343
+ logger.info(
1344
+ f'Watch mode enabled for {codebase.name} (remote worker: {codebase.worker_id})'
1345
+ )
1346
+ await self._notify_status_change(codebase)
1347
+ return True
1348
+
1349
+ # For local execution, start the OpenCode server if not running
1350
+ if not codebase.opencode_port or codebase.status in (
1351
+ AgentStatus.IDLE,
1352
+ AgentStatus.STOPPED,
1353
+ ):
1354
+ try:
1355
+ await self._start_opencode_server(codebase)
1356
+ except Exception as e:
1357
+ logger.error(
1358
+ f'Failed to start OpenCode server for watch mode: {e}'
1359
+ )
1360
+ return False
1361
+
1362
+ codebase.watch_mode = True
1363
+ codebase.watch_interval = interval
1364
+ await self._update_codebase_status(codebase, AgentStatus.WATCHING)
1365
+
1366
+ # Start background task for local execution
1367
+ watch_task = asyncio.create_task(self._watch_loop(codebase_id))
1368
+ self._watch_tasks[codebase_id] = watch_task
1369
+
1370
+ logger.info(
1371
+ f'Started watch mode for {codebase.name} (interval: {interval}s)'
1372
+ )
1373
+ await self._notify_status_change(codebase)
1374
+
1375
+ return True
1376
+
1377
+ async def stop_watch_mode(self, codebase_id: str) -> bool:
1378
+ """Stop watch mode for a codebase."""
1379
+ codebase = self._codebases.get(codebase_id)
1380
+ if not codebase:
1381
+ return False
1382
+
1383
+ # For remote workers, just update the flag
1384
+ if codebase.worker_id:
1385
+ codebase.watch_mode = False
1386
+ await self._update_codebase_status(codebase, AgentStatus.IDLE)
1387
+ logger.info(
1388
+ f'Watch mode disabled for {codebase.name} (remote worker)'
1389
+ )
1390
+ await self._notify_status_change(codebase)
1391
+ return True
1392
+
1393
+ # Cancel local watch task
1394
+ watch_task = self._watch_tasks.get(codebase_id)
1395
+ if watch_task:
1396
+ watch_task.cancel()
1397
+ try:
1398
+ await watch_task
1399
+ except asyncio.CancelledError:
1400
+ pass
1401
+ del self._watch_tasks[codebase_id]
1402
+
1403
+ codebase.watch_mode = False
1404
+ await self._update_codebase_status(
1405
+ codebase,
1406
+ AgentStatus.RUNNING if codebase.opencode_port else AgentStatus.IDLE,
1407
+ )
1408
+
1409
+ logger.info(f'Stopped watch mode for {codebase.name}')
1410
+ await self._notify_status_change(codebase)
1411
+
1412
+ return True
1413
+
1414
+ async def _watch_loop(self, codebase_id: str):
1415
+ """Background loop that checks for and executes tasks."""
1416
+ codebase = self._codebases.get(codebase_id)
1417
+ if not codebase:
1418
+ return
1419
+
1420
+ logger.info(f'Watch loop started for {codebase.name}')
1421
+
1422
+ try:
1423
+ while True:
1424
+ # Check for pending tasks
1425
+ task = await self.get_next_pending_task(codebase_id)
1426
+
1427
+ if task:
1428
+ logger.info(f'Watch loop found task: {task.title}')
1429
+ await self._execute_task(task)
1430
+
1431
+ # Wait before next check
1432
+ await asyncio.sleep(codebase.watch_interval)
1433
+
1434
+ except asyncio.CancelledError:
1435
+ logger.info(f'Watch loop cancelled for {codebase.name}')
1436
+ raise
1437
+ except Exception as e:
1438
+ logger.error(f'Watch loop error for {codebase.name}: {e}')
1439
+ self._update_codebase_status(codebase, AgentStatus.ERROR)
1440
+
1441
+ async def _execute_task(self, task: AgentTask):
1442
+ """Execute a task using the OpenCode agent."""
1443
+ codebase = self._codebases.get(task.codebase_id)
1444
+ if not codebase:
1445
+ task.status = AgentTaskStatus.FAILED
1446
+ task.error = 'Codebase not found'
1447
+ return
1448
+
1449
+ # Update task status
1450
+ task.status = AgentTaskStatus.RUNNING
1451
+ task.started_at = datetime.utcnow()
1452
+ await self._notify_task_update(task)
1453
+
1454
+ # Update codebase status
1455
+ await self._update_codebase_status(codebase, AgentStatus.BUSY)
1456
+ await self._notify_status_change(codebase)
1457
+
1458
+ try:
1459
+ # Create trigger request
1460
+ request = AgentTriggerRequest(
1461
+ codebase_id=task.codebase_id,
1462
+ prompt=task.prompt,
1463
+ agent=task.agent_type,
1464
+ metadata=task.metadata,
1465
+ )
1466
+
1467
+ # Trigger the agent
1468
+ response = await self.trigger_agent(request)
1469
+
1470
+ if response.success:
1471
+ task.session_id = response.session_id
1472
+
1473
+ # Wait for agent to finish (poll status)
1474
+ await self._wait_for_agent_completion(task, codebase)
1475
+
1476
+ else:
1477
+ task.status = AgentTaskStatus.FAILED
1478
+ task.error = response.error
1479
+ task.completed_at = datetime.utcnow()
1480
+
1481
+ except Exception as e:
1482
+ logger.error(f'Task execution failed: {e}')
1483
+ task.status = AgentTaskStatus.FAILED
1484
+ task.error = str(e)
1485
+ task.completed_at = datetime.utcnow()
1486
+
1487
+ finally:
1488
+ # Restore codebase status if in watch mode
1489
+ if codebase.watch_mode:
1490
+ await self._update_codebase_status(
1491
+ codebase, AgentStatus.WATCHING
1492
+ )
1493
+ else:
1494
+ await self._update_codebase_status(
1495
+ codebase, AgentStatus.RUNNING
1496
+ )
1497
+
1498
+ await self._notify_status_change(codebase)
1499
+ await self._notify_task_update(task)
1500
+
1501
+ async def _wait_for_agent_completion(
1502
+ self, task: AgentTask, codebase: RegisteredCodebase, timeout: int = 600
1503
+ ):
1504
+ """Wait for an agent to complete its work."""
1505
+ if not codebase.opencode_port or not task.session_id:
1506
+ return
1507
+
1508
+ base_url = self._get_opencode_base_url(codebase.opencode_port)
1509
+ session = await self._get_session()
1510
+
1511
+ start_time = datetime.utcnow()
1512
+
1513
+ while True:
1514
+ # Check timeout
1515
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
1516
+ if elapsed > timeout:
1517
+ task.status = AgentTaskStatus.FAILED
1518
+ task.error = 'Timeout waiting for agent completion'
1519
+ task.completed_at = datetime.utcnow()
1520
+ return
1521
+
1522
+ try:
1523
+ # Check session status
1524
+ async with session.get(
1525
+ f'{base_url}/session/{task.session_id}',
1526
+ params={'directory': codebase.path},
1527
+ ) as resp:
1528
+ if resp.status == 200:
1529
+ data = await resp.json()
1530
+ status = data.get('status', {})
1531
+
1532
+ # Check if agent is idle (completed)
1533
+ if (
1534
+ status.get('idle', False)
1535
+ or status.get('status') == 'idle'
1536
+ ):
1537
+ task.status = AgentTaskStatus.COMPLETED
1538
+ task.completed_at = datetime.utcnow()
1539
+
1540
+ # Get last message as result
1541
+ async with session.get(
1542
+ f'{base_url}/session/{task.session_id}/message',
1543
+ params={'limit': 1, 'directory': codebase.path},
1544
+ ) as msg_resp:
1545
+ if msg_resp.status == 200:
1546
+ messages = await msg_resp.json()
1547
+ if messages:
1548
+ # Extract text content
1549
+ parts = messages[0].get('parts', [])
1550
+ text_parts = [
1551
+ p.get('text', '')
1552
+ for p in parts
1553
+ if p.get('type') == 'text'
1554
+ ]
1555
+ task.result = '\n'.join(text_parts)[
1556
+ :5000
1557
+ ] # Limit result size
1558
+
1559
+ return
1560
+
1561
+ except Exception as e:
1562
+ logger.debug(f'Error checking agent status: {e}')
1563
+
1564
+ # Wait before next check
1565
+ await asyncio.sleep(2)
1566
+
1567
+
1568
+ # Global bridge instance
1569
+ _bridge: Optional[OpenCodeBridge] = None
1570
+
1571
+
1572
+ def get_bridge() -> OpenCodeBridge:
1573
+ """Get the global OpenCode bridge instance."""
1574
+ global _bridge
1575
+ if _bridge is None:
1576
+ _bridge = OpenCodeBridge()
1577
+ return _bridge
1578
+
1579
+
1580
+ def init_bridge(
1581
+ opencode_bin: Optional[str] = None,
1582
+ default_port: int = None,
1583
+ auto_start: bool = True,
1584
+ opencode_host: Optional[str] = None,
1585
+ ) -> OpenCodeBridge:
1586
+ """Initialize the global OpenCode bridge instance."""
1587
+ global _bridge
1588
+ _bridge = OpenCodeBridge(
1589
+ opencode_bin=opencode_bin,
1590
+ default_port=default_port or OPENCODE_DEFAULT_PORT,
1591
+ auto_start=auto_start,
1592
+ opencode_host=opencode_host,
1593
+ )
1594
+ return _bridge