claude-mpm 5.6.10__py3-none-any.whl → 5.6.17__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.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (61) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/cli/commands/commander.py +173 -3
  3. claude_mpm/cli/parsers/commander_parser.py +41 -8
  4. claude_mpm/cli/startup.py +104 -1
  5. claude_mpm/cli/startup_display.py +2 -1
  6. claude_mpm/commander/__init__.py +6 -0
  7. claude_mpm/commander/adapters/__init__.py +32 -3
  8. claude_mpm/commander/adapters/auggie.py +260 -0
  9. claude_mpm/commander/adapters/base.py +98 -1
  10. claude_mpm/commander/adapters/claude_code.py +32 -1
  11. claude_mpm/commander/adapters/codex.py +237 -0
  12. claude_mpm/commander/adapters/example_usage.py +310 -0
  13. claude_mpm/commander/adapters/mpm.py +389 -0
  14. claude_mpm/commander/adapters/registry.py +204 -0
  15. claude_mpm/commander/api/app.py +32 -16
  16. claude_mpm/commander/api/routes/messages.py +11 -11
  17. claude_mpm/commander/api/routes/projects.py +20 -20
  18. claude_mpm/commander/api/routes/sessions.py +19 -21
  19. claude_mpm/commander/api/routes/work.py +86 -50
  20. claude_mpm/commander/api/schemas.py +4 -0
  21. claude_mpm/commander/chat/cli.py +4 -0
  22. claude_mpm/commander/core/__init__.py +10 -0
  23. claude_mpm/commander/core/block_manager.py +325 -0
  24. claude_mpm/commander/core/response_manager.py +323 -0
  25. claude_mpm/commander/daemon.py +206 -10
  26. claude_mpm/commander/env_loader.py +59 -0
  27. claude_mpm/commander/memory/__init__.py +45 -0
  28. claude_mpm/commander/memory/compression.py +347 -0
  29. claude_mpm/commander/memory/embeddings.py +230 -0
  30. claude_mpm/commander/memory/entities.py +310 -0
  31. claude_mpm/commander/memory/example_usage.py +290 -0
  32. claude_mpm/commander/memory/integration.py +325 -0
  33. claude_mpm/commander/memory/search.py +381 -0
  34. claude_mpm/commander/memory/store.py +657 -0
  35. claude_mpm/commander/registry.py +10 -4
  36. claude_mpm/commander/runtime/monitor.py +32 -2
  37. claude_mpm/commander/work/executor.py +38 -20
  38. claude_mpm/commander/workflow/event_handler.py +25 -3
  39. claude_mpm/core/claude_runner.py +143 -0
  40. claude_mpm/core/output_style_manager.py +34 -7
  41. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  42. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-314.pyc +0 -0
  43. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  44. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +0 -0
  45. claude_mpm/hooks/claude_hooks/event_handlers.py +22 -0
  46. claude_mpm/hooks/claude_hooks/hook_handler.py +0 -0
  47. claude_mpm/hooks/claude_hooks/memory_integration.py +0 -0
  48. claude_mpm/hooks/claude_hooks/response_tracking.py +0 -0
  49. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  50. claude_mpm/hooks/templates/pre_tool_use_template.py +0 -0
  51. claude_mpm/scripts/start_activity_logging.py +0 -0
  52. claude_mpm/skills/__init__.py +2 -1
  53. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  54. claude_mpm/skills/registry.py +295 -90
  55. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/METADATA +5 -3
  56. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/RECORD +55 -36
  57. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/WHEEL +0 -0
  58. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/entry_points.txt +0 -0
  59. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE +0 -0
  60. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  61. {claude_mpm-5.6.10.dist-info → claude_mpm-5.6.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,323 @@
1
+ """ResponseManager for centralized response routing and validation.
2
+
3
+ This module provides ResponseManager which handles response validation,
4
+ routing, and delivery to runtime sessions.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from ..events.manager import EventManager
13
+ from ..models.events import Event, EventType
14
+ from ..runtime.executor import RuntimeExecutor
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _utc_now() -> datetime:
20
+ """Return current UTC time with timezone info."""
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ @dataclass
25
+ class ResponseRoute:
26
+ """Encapsulates a validated response ready for delivery.
27
+
28
+ Attributes:
29
+ event: Event being responded to
30
+ response: User's response text
31
+ valid: Whether validation passed
32
+ validation_errors: List of validation error messages
33
+ timestamp: When the route was created
34
+ delivered: Whether response has been delivered
35
+ delivery_timestamp: When the response was delivered
36
+ """
37
+
38
+ event: Event
39
+ response: str
40
+ valid: bool
41
+ validation_errors: List[str] = field(default_factory=list)
42
+ timestamp: datetime = field(default_factory=_utc_now)
43
+ delivered: bool = False
44
+ delivery_timestamp: Optional[datetime] = None
45
+
46
+
47
+ class ResponseManager:
48
+ """Centralizes response validation, routing, and delivery.
49
+
50
+ Provides centralized response handling with validation and routing
51
+ capabilities for event responses.
52
+
53
+ Attributes:
54
+ event_manager: EventManager for retrieving events
55
+ runtime_executor: Optional RuntimeExecutor for response delivery
56
+ _response_history: History of all response attempts per event
57
+
58
+ Example:
59
+ >>> manager = ResponseManager(event_manager, runtime_executor)
60
+ >>> valid, errors = manager.validate_response(event, "staging")
61
+ >>> if valid:
62
+ ... route = manager.validate_and_route(event_id, "staging")
63
+ ... success = await manager.deliver_response(route)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ event_manager: EventManager,
69
+ runtime_executor: Optional[RuntimeExecutor] = None,
70
+ ) -> None:
71
+ """Initialize ResponseManager.
72
+
73
+ Args:
74
+ event_manager: EventManager instance for retrieving events
75
+ runtime_executor: Optional RuntimeExecutor for response delivery
76
+
77
+ Raises:
78
+ ValueError: If event_manager is None
79
+ """
80
+ if event_manager is None:
81
+ raise ValueError("EventManager cannot be None")
82
+
83
+ self.event_manager = event_manager
84
+ self.runtime_executor = runtime_executor
85
+ self._response_history: Dict[str, List[ResponseRoute]] = {}
86
+
87
+ logger.debug(
88
+ "ResponseManager initialized (runtime_executor: %s)",
89
+ "enabled" if runtime_executor else "disabled",
90
+ )
91
+
92
+ def validate_response(self, event: Event, response: str) -> Tuple[bool, List[str]]:
93
+ """Validate response against event constraints.
94
+
95
+ Validation rules:
96
+ 1. Empty responses: Not allowed for blocking events
97
+ 2. DECISION_NEEDED options: Response must match one of the options
98
+ 3. Response whitespace: Stripped before validation
99
+
100
+ Args:
101
+ event: Event being responded to
102
+ response: User's response
103
+
104
+ Returns:
105
+ Tuple of (is_valid, list_of_error_messages)
106
+
107
+ Example:
108
+ >>> valid, errors = manager.validate_response(event, "staging")
109
+ >>> if not valid:
110
+ ... for error in errors:
111
+ ... print(f"Validation error: {error}")
112
+ """
113
+ errors: List[str] = []
114
+
115
+ # Strip whitespace for validation
116
+ response_stripped = response.strip()
117
+
118
+ # Rule 1: Empty responses not allowed for blocking events
119
+ if event.is_blocking and not response_stripped:
120
+ errors.append("Response cannot be empty for blocking events")
121
+
122
+ # Rule 2: DECISION_NEEDED events must use one of the provided options
123
+ if event.type == EventType.DECISION_NEEDED and event.options:
124
+ if response_stripped not in event.options:
125
+ errors.append(
126
+ f"Response must be one of: {', '.join(event.options)}. "
127
+ f"Got: '{response_stripped}'"
128
+ )
129
+
130
+ # Future validation rules can be added here:
131
+ # - Max length check
132
+ # - Format validation (e.g., regex patterns)
133
+ # - Custom validators per event type
134
+ # - Conditional validation based on event context
135
+
136
+ is_valid = len(errors) == 0
137
+ return is_valid, errors
138
+
139
+ def validate_and_route(
140
+ self, event_id: str, response: str
141
+ ) -> Optional[ResponseRoute]:
142
+ """Create a validated ResponseRoute for an event.
143
+
144
+ Retrieves the event, validates the response, and creates a ResponseRoute
145
+ with validation results.
146
+
147
+ Args:
148
+ event_id: ID of event to respond to
149
+ response: User's response
150
+
151
+ Returns:
152
+ ResponseRoute with validation results, or None if event not found
153
+
154
+ Example:
155
+ >>> route = manager.validate_and_route("evt_123", "staging")
156
+ >>> if route and route.valid:
157
+ ... await manager.deliver_response(route)
158
+ >>> elif route:
159
+ ... print(f"Validation failed: {route.validation_errors}")
160
+ """
161
+ # Get the event
162
+ event = self.event_manager.get(event_id)
163
+ if not event:
164
+ logger.warning("Event not found: %s", event_id)
165
+ return None
166
+
167
+ # Validate response
168
+ valid, errors = self.validate_response(event, response)
169
+
170
+ # Create route
171
+ route = ResponseRoute(
172
+ event=event,
173
+ response=response,
174
+ valid=valid,
175
+ validation_errors=errors,
176
+ )
177
+
178
+ logger.debug(
179
+ "Created route for event %s: valid=%s, errors=%s",
180
+ event_id,
181
+ valid,
182
+ errors,
183
+ )
184
+
185
+ return route
186
+
187
+ async def deliver_response(self, route: ResponseRoute) -> bool:
188
+ """Deliver a validated response to the runtime.
189
+
190
+ Records the response in event history and attempts delivery to the
191
+ runtime executor if available.
192
+
193
+ Args:
194
+ route: ResponseRoute to deliver
195
+
196
+ Returns:
197
+ True if delivery successful, False otherwise
198
+
199
+ Raises:
200
+ ValueError: If route validation failed
201
+
202
+ Example:
203
+ >>> route = manager.validate_and_route("evt_123", "yes")
204
+ >>> if route and route.valid:
205
+ ... success = await manager.deliver_response(route)
206
+ ... if success:
207
+ ... print("Response delivered successfully")
208
+ """
209
+ if not route.valid:
210
+ error_msg = "; ".join(route.validation_errors)
211
+ raise ValueError(f"Cannot deliver invalid response: {error_msg}")
212
+
213
+ # Mark route as delivered
214
+ route.delivered = True
215
+ route.delivery_timestamp = _utc_now()
216
+
217
+ # Track in history
218
+ self._add_to_history(route)
219
+
220
+ # For non-blocking events, no runtime delivery needed
221
+ if not route.event.is_blocking:
222
+ logger.debug(
223
+ "Event %s is non-blocking, no runtime delivery needed",
224
+ route.event.id,
225
+ )
226
+ return True
227
+
228
+ # Deliver to runtime if executor available
229
+ if not self.runtime_executor:
230
+ logger.warning(
231
+ "No runtime executor available, cannot deliver response for event %s",
232
+ route.event.id,
233
+ )
234
+ return False
235
+
236
+ # Note: Actual delivery is handled by EventHandler which has session context
237
+ # ResponseManager just validates and tracks responses
238
+ # The EventHandler will call executor.send_message() with session's active_pane
239
+ logger.info(
240
+ "Response validated and ready for delivery (event %s): %s",
241
+ route.event.id,
242
+ route.response[:50],
243
+ )
244
+ return True
245
+
246
+ def _add_to_history(self, route: ResponseRoute) -> None:
247
+ """Add response route to history tracking.
248
+
249
+ Args:
250
+ route: ResponseRoute to record
251
+ """
252
+ event_id = route.event.id
253
+ if event_id not in self._response_history:
254
+ self._response_history[event_id] = []
255
+
256
+ self._response_history[event_id].append(route)
257
+ logger.debug(
258
+ "Added response to history for event %s (total: %d)",
259
+ event_id,
260
+ len(self._response_history[event_id]),
261
+ )
262
+
263
+ def get_response_history(self, event_id: str) -> List[ResponseRoute]:
264
+ """Get all response attempts for an event (for audit trail).
265
+
266
+ Args:
267
+ event_id: Event ID to query
268
+
269
+ Returns:
270
+ List of ResponseRoute objects for this event (chronological order)
271
+
272
+ Example:
273
+ >>> history = manager.get_response_history("evt_123")
274
+ >>> for i, route in enumerate(history, 1):
275
+ ... status = "valid" if route.valid else "invalid"
276
+ ... print(f"Attempt {i} ({status}): {route.response}")
277
+ """
278
+ return self._response_history.get(event_id, []).copy()
279
+
280
+ def clear_history(self, event_id: str) -> int:
281
+ """Clear response history for an event.
282
+
283
+ Args:
284
+ event_id: Event ID to clear
285
+
286
+ Returns:
287
+ Number of history entries removed
288
+
289
+ Example:
290
+ >>> removed = manager.clear_history("evt_123")
291
+ >>> print(f"Cleared {removed} history entries")
292
+ """
293
+ history = self._response_history.pop(event_id, [])
294
+ count = len(history)
295
+ if count > 0:
296
+ logger.debug("Cleared %d history entries for event %s", count, event_id)
297
+ return count
298
+
299
+ def get_stats(self) -> Dict[str, Any]:
300
+ """Get statistics about response history.
301
+
302
+ Returns:
303
+ Dict with statistics about tracked responses
304
+
305
+ Example:
306
+ >>> stats = manager.get_stats()
307
+ >>> print(f"Total events with history: {stats['total_events']}")
308
+ >>> print(f"Total response attempts: {stats['total_responses']}")
309
+ """
310
+ total_events = len(self._response_history)
311
+ total_responses = sum(len(routes) for routes in self._response_history.values())
312
+ valid_responses = sum(
313
+ sum(1 for route in routes if route.valid)
314
+ for routes in self._response_history.values()
315
+ )
316
+ invalid_responses = total_responses - valid_responses
317
+
318
+ return {
319
+ "total_events": total_events,
320
+ "total_responses": total_responses,
321
+ "valid_responses": valid_responses,
322
+ "invalid_responses": invalid_responses,
323
+ }
@@ -15,12 +15,23 @@ from .api.app import (
15
15
  app,
16
16
  )
17
17
  from .config import DaemonConfig
18
+ from .core.block_manager import BlockManager
19
+ from .env_loader import load_env
18
20
  from .events.manager import EventManager
19
21
  from .inbox import Inbox
22
+ from .models.events import EventStatus
23
+ from .parsing.output_parser import OutputParser
20
24
  from .persistence import EventStore, StateStore
21
25
  from .project_session import ProjectSession, SessionState
22
26
  from .registry import ProjectRegistry
27
+ from .runtime.monitor import RuntimeMonitor
23
28
  from .tmux_orchestrator import TmuxOrchestrator
29
+ from .work.executor import WorkExecutor
30
+ from .work.queue import WorkQueue
31
+ from .workflow.event_handler import EventHandler
32
+
33
+ # Load environment variables at module import
34
+ load_env()
24
35
 
25
36
  logger = logging.getLogger(__name__)
26
37
 
@@ -38,6 +49,11 @@ class CommanderDaemon:
38
49
  event_manager: Event manager
39
50
  inbox: Event inbox
40
51
  sessions: Active project sessions by project_id
52
+ work_queues: Work queues by project_id
53
+ work_executors: Work executors by project_id
54
+ block_manager: Block manager for automatic work blocking
55
+ runtime_monitor: Runtime monitor for output monitoring
56
+ event_handler: Event handler for blocking event workflow
41
57
  state_store: StateStore for project/session persistence
42
58
  event_store: EventStore for event queue persistence
43
59
  running: Whether daemon is currently running
@@ -68,6 +84,8 @@ class CommanderDaemon:
68
84
  self.event_manager = EventManager()
69
85
  self.inbox = Inbox(self.event_manager, self.registry)
70
86
  self.sessions: Dict[str, ProjectSession] = {}
87
+ self.work_queues: Dict[str, WorkQueue] = {}
88
+ self.work_executors: Dict[str, WorkExecutor] = {}
71
89
  self._running = False
72
90
  self._server_task: Optional[asyncio.Task] = None
73
91
  self._main_loop_task: Optional[asyncio.Task] = None
@@ -76,6 +94,30 @@ class CommanderDaemon:
76
94
  self.state_store = StateStore(config.state_dir)
77
95
  self.event_store = EventStore(config.state_dir)
78
96
 
97
+ # Initialize BlockManager with work queues and executors
98
+ self.block_manager = BlockManager(
99
+ event_manager=self.event_manager,
100
+ work_queues=self.work_queues,
101
+ work_executors=self.work_executors,
102
+ )
103
+
104
+ # Initialize RuntimeMonitor with BlockManager
105
+ parser = OutputParser(self.event_manager)
106
+ self.runtime_monitor = RuntimeMonitor(
107
+ orchestrator=self.orchestrator,
108
+ parser=parser,
109
+ event_manager=self.event_manager,
110
+ poll_interval=config.poll_interval,
111
+ block_manager=self.block_manager,
112
+ )
113
+
114
+ # Initialize EventHandler with BlockManager
115
+ self.event_handler = EventHandler(
116
+ inbox=self.inbox,
117
+ session_manager=self.sessions,
118
+ block_manager=self.block_manager,
119
+ )
120
+
79
121
  # Configure logging
80
122
  logging.basicConfig(
81
123
  level=getattr(logging, config.log_level.upper()),
@@ -122,12 +164,16 @@ class CommanderDaemon:
122
164
  # Set up signal handlers
123
165
  self._setup_signal_handlers()
124
166
 
125
- # Inject global instances into API app
126
- global api_registry, api_tmux, api_event_manager, api_inbox
127
- api_registry = self.registry
128
- api_tmux = self.orchestrator
129
- api_event_manager = self.event_manager
130
- api_inbox = self.inbox
167
+ # Inject daemon instances into API app.state (BEFORE lifespan runs)
168
+ app.state.registry = self.registry
169
+ app.state.tmux = self.orchestrator
170
+ app.state.event_manager = self.event_manager
171
+ app.state.inbox = self.inbox
172
+ app.state.work_queues = self.work_queues
173
+ app.state.daemon_instance = self
174
+ app.state.session_manager = self.sessions
175
+ app.state.event_handler = self.event_handler
176
+ logger.info(f"Injected work_queues dict id: {id(self.work_queues)}")
131
177
 
132
178
  # Start API server in background
133
179
  logger.info(f"Starting API server on {self.config.host}:{self.config.port}")
@@ -171,6 +217,16 @@ class CommanderDaemon:
171
217
  except Exception as e:
172
218
  logger.error(f"Error stopping session {project_id}: {e}")
173
219
 
220
+ # Clear BlockManager project mappings
221
+ for project_id in list(self.work_queues.keys()):
222
+ try:
223
+ removed = self.block_manager.clear_project_mappings(project_id)
224
+ logger.debug(
225
+ f"Cleared {removed} work mappings for project {project_id}"
226
+ )
227
+ except Exception as e:
228
+ logger.error(f"Error clearing mappings for {project_id}: {e}")
229
+
174
230
  # Cancel main loop task
175
231
  if self._main_loop_task and not self._main_loop_task.done():
176
232
  self._main_loop_task.cancel()
@@ -210,9 +266,19 @@ class CommanderDaemon:
210
266
 
211
267
  while self._running:
212
268
  try:
213
- # TODO: Check for resolved events and resume sessions (Phase 2 Sprint 3)
214
- # TODO: Check each ProjectSession for runnable work (Phase 2 Sprint 2)
215
- # TODO: Spawn RuntimeExecutors for new work items (Phase 2 Sprint 1)
269
+ logger.info(f"🔄 Main loop iteration (running={self._running})")
270
+ logger.info(
271
+ f"work_queues dict id: {id(self.work_queues)}, keys: {list(self.work_queues.keys())}"
272
+ )
273
+
274
+ # Check for resolved events and resume sessions
275
+ await self._check_and_resume_sessions()
276
+
277
+ # Check each ProjectSession for runnable work
278
+ logger.info(
279
+ f"Checking for pending work across {len(self.work_queues)} queues"
280
+ )
281
+ await self._execute_pending_work()
216
282
 
217
283
  # Periodic state persistence
218
284
  current_time = asyncio.get_event_loop().time()
@@ -282,7 +348,26 @@ class CommanderDaemon:
282
348
  if project is None:
283
349
  raise ValueError(f"Project not found: {project_id}")
284
350
 
285
- session = ProjectSession(project, self.orchestrator)
351
+ # Create work queue for project if not exists
352
+ if project_id not in self.work_queues:
353
+ self.work_queues[project_id] = WorkQueue(project_id)
354
+ logger.debug(f"Created work queue for project {project_id}")
355
+
356
+ # Create work executor for project if not exists
357
+ if project_id not in self.work_executors:
358
+ from .runtime.executor import RuntimeExecutor
359
+
360
+ runtime_executor = RuntimeExecutor(self.orchestrator)
361
+ self.work_executors[project_id] = WorkExecutor(
362
+ runtime=runtime_executor, queue=self.work_queues[project_id]
363
+ )
364
+ logger.debug(f"Created work executor for project {project_id}")
365
+
366
+ session = ProjectSession(
367
+ project=project,
368
+ orchestrator=self.orchestrator,
369
+ monitor=self.runtime_monitor,
370
+ )
286
371
  self.sessions[project_id] = session
287
372
 
288
373
  logger.info(f"Created new session for project {project_id}")
@@ -363,6 +448,117 @@ class CommanderDaemon:
363
448
  except Exception as e:
364
449
  logger.error(f"Failed to save state: {e}", exc_info=True)
365
450
 
451
+ async def _check_and_resume_sessions(self) -> None:
452
+ """Check for resolved events and resume paused sessions.
453
+
454
+ Iterates through all paused sessions, checks if their blocking events
455
+ have been resolved, and resumes execution if ready.
456
+ """
457
+ for project_id, session in list(self.sessions.items()):
458
+ # Skip non-paused sessions
459
+ if session.state != SessionState.PAUSED:
460
+ continue
461
+
462
+ # Check if pause reason (event ID) is resolved
463
+ if not session.pause_reason:
464
+ logger.warning(f"Session {project_id} paused with no reason, resuming")
465
+ await session.resume()
466
+ continue
467
+
468
+ # Check if event is resolved
469
+ event = self.event_manager.get(session.pause_reason)
470
+ if event and event.status == EventStatus.RESOLVED:
471
+ logger.info(
472
+ f"Event {event.id} resolved, resuming session for {project_id}"
473
+ )
474
+ await session.resume()
475
+
476
+ # Unblock any work items that were blocked by this event
477
+ if project_id in self.work_executors:
478
+ executor = self.work_executors[project_id]
479
+ queue = self.work_queues[project_id]
480
+
481
+ # Find work items blocked by this event
482
+ blocked_items = [
483
+ item
484
+ for item in queue.list()
485
+ if item.state.value == "blocked"
486
+ and item.metadata.get("block_reason") == event.id
487
+ ]
488
+
489
+ for item in blocked_items:
490
+ await executor.handle_unblock(item.id)
491
+ logger.info(f"Unblocked work item {item.id}")
492
+
493
+ async def _execute_pending_work(self) -> None:
494
+ """Execute pending work for all ready sessions.
495
+
496
+ Scans all work queues for pending work. For projects with work but no session,
497
+ auto-creates a session. Then executes the next available work item via WorkExecutor.
498
+ """
499
+ # First pass: Auto-create and start sessions for projects with pending work
500
+ for project_id, queue in list(self.work_queues.items()):
501
+ logger.info(
502
+ f"Checking queue for {project_id}: pending={queue.pending_count}"
503
+ )
504
+ # Skip if no pending work
505
+ if queue.pending_count == 0:
506
+ continue
507
+
508
+ # Auto-create session if needed
509
+ if project_id not in self.sessions:
510
+ try:
511
+ logger.info(
512
+ f"Auto-creating session for project {project_id} with pending work"
513
+ )
514
+ session = self.get_or_create_session(project_id)
515
+
516
+ # Start the session so it's ready for work
517
+ if session.state.value == "idle":
518
+ logger.info(f"Auto-starting session for {project_id}")
519
+ await session.start()
520
+ except Exception as e:
521
+ logger.error(
522
+ f"Failed to auto-create/start session for {project_id}: {e}",
523
+ exc_info=True,
524
+ )
525
+ continue
526
+
527
+ # Second pass: Execute work for ready sessions
528
+ for project_id, session in list(self.sessions.items()):
529
+ # Skip sessions that aren't ready for work
530
+ if not session.is_ready():
531
+ continue
532
+
533
+ # Skip if no work queue exists
534
+ if project_id not in self.work_queues:
535
+ continue
536
+
537
+ # Get work executor for project
538
+ executor = self.work_executors.get(project_id)
539
+ if not executor:
540
+ logger.warning(
541
+ f"No work executor found for project {project_id}, skipping"
542
+ )
543
+ continue
544
+
545
+ # Check if there's work available
546
+ queue = self.work_queues[project_id]
547
+ if queue.pending_count == 0:
548
+ continue
549
+
550
+ # Try to execute next work item
551
+ try:
552
+ # Pass the session's active pane for execution
553
+ executed = await executor.execute_next(pane_target=session.active_pane)
554
+ if executed:
555
+ logger.info(f"Started work execution for project {project_id}")
556
+ except Exception as e:
557
+ logger.error(
558
+ f"Error executing work for project {project_id}: {e}",
559
+ exc_info=True,
560
+ )
561
+
366
562
 
367
563
  async def main(config: Optional[DaemonConfig] = None) -> None:
368
564
  """Main entry point for running the daemon.
@@ -0,0 +1,59 @@
1
+ """Environment variable loader for Commander.
2
+
3
+ This module handles automatic loading of .env and .env.local files
4
+ at Commander startup. Environment files are loaded with the following precedence:
5
+ 1. Existing environment variables (not overridden)
6
+ 2. .env.local (local overrides)
7
+ 3. .env (defaults)
8
+
9
+ Example:
10
+ >>> from claude_mpm.commander.env_loader import load_env
11
+ >>> load_env()
12
+ # Automatically loads .env.local and .env from project root
13
+ """
14
+
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ from dotenv import load_dotenv
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def load_env() -> None:
24
+ """Load environment variables from .env and .env.local files.
25
+
26
+ Searches for .env and .env.local in the project root directory
27
+ (parent of src/claude_mpm). Files are loaded with override=False,
28
+ meaning existing environment variables take precedence.
29
+
30
+ Precedence (highest to lowest):
31
+ 1. Existing environment variables
32
+ 2. .env.local
33
+ 3. .env
34
+
35
+ Example:
36
+ >>> load_env()
37
+ # Loads .env.local and .env if they exist
38
+ """
39
+ # Find project root (parent of src/claude_mpm)
40
+ # Current file: src/claude_mpm/commander/env_loader.py
41
+ # Project root: ../../../ (3 levels up)
42
+ current_file = Path(__file__)
43
+ project_root = current_file.parent.parent.parent.parent
44
+
45
+ # Try loading .env.local first (higher priority)
46
+ env_local = project_root / ".env.local"
47
+ if env_local.exists():
48
+ load_dotenv(env_local, override=False)
49
+ logger.debug(f"Loaded environment from {env_local}")
50
+
51
+ # Then load .env (lower priority)
52
+ env_file = project_root / ".env"
53
+ if env_file.exists():
54
+ load_dotenv(env_file, override=False)
55
+ logger.debug(f"Loaded environment from {env_file}")
56
+
57
+ # Log if neither file exists
58
+ if not env_local.exists() and not env_file.exists():
59
+ logger.debug("No .env or .env.local files found in project root")