claude-mpm 5.6.1__py3-none-any.whl → 5.6.76__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 (131) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
  3. claude_mpm/auth/__init__.py +35 -0
  4. claude_mpm/auth/callback_server.py +328 -0
  5. claude_mpm/auth/models.py +104 -0
  6. claude_mpm/auth/oauth_manager.py +266 -0
  7. claude_mpm/auth/providers/__init__.py +12 -0
  8. claude_mpm/auth/providers/base.py +165 -0
  9. claude_mpm/auth/providers/google.py +261 -0
  10. claude_mpm/auth/token_storage.py +252 -0
  11. claude_mpm/cli/commands/commander.py +174 -4
  12. claude_mpm/cli/commands/mcp.py +29 -17
  13. claude_mpm/cli/commands/mcp_command_router.py +39 -0
  14. claude_mpm/cli/commands/mcp_service_commands.py +304 -0
  15. claude_mpm/cli/commands/oauth.py +481 -0
  16. claude_mpm/cli/commands/skill_source.py +51 -2
  17. claude_mpm/cli/commands/skills.py +5 -3
  18. claude_mpm/cli/executor.py +9 -0
  19. claude_mpm/cli/helpers.py +1 -1
  20. claude_mpm/cli/parsers/base_parser.py +13 -0
  21. claude_mpm/cli/parsers/commander_parser.py +43 -10
  22. claude_mpm/cli/parsers/mcp_parser.py +79 -0
  23. claude_mpm/cli/parsers/oauth_parser.py +165 -0
  24. claude_mpm/cli/parsers/skill_source_parser.py +4 -0
  25. claude_mpm/cli/parsers/skills_parser.py +5 -0
  26. claude_mpm/cli/startup.py +300 -33
  27. claude_mpm/cli/startup_display.py +4 -2
  28. claude_mpm/cli/startup_migrations.py +236 -0
  29. claude_mpm/commander/__init__.py +6 -0
  30. claude_mpm/commander/adapters/__init__.py +32 -3
  31. claude_mpm/commander/adapters/auggie.py +260 -0
  32. claude_mpm/commander/adapters/base.py +98 -1
  33. claude_mpm/commander/adapters/claude_code.py +32 -1
  34. claude_mpm/commander/adapters/codex.py +237 -0
  35. claude_mpm/commander/adapters/example_usage.py +310 -0
  36. claude_mpm/commander/adapters/mpm.py +389 -0
  37. claude_mpm/commander/adapters/registry.py +204 -0
  38. claude_mpm/commander/api/app.py +32 -16
  39. claude_mpm/commander/api/errors.py +21 -0
  40. claude_mpm/commander/api/routes/messages.py +11 -11
  41. claude_mpm/commander/api/routes/projects.py +20 -20
  42. claude_mpm/commander/api/routes/sessions.py +37 -26
  43. claude_mpm/commander/api/routes/work.py +86 -50
  44. claude_mpm/commander/api/schemas.py +4 -0
  45. claude_mpm/commander/chat/cli.py +47 -5
  46. claude_mpm/commander/chat/commands.py +44 -16
  47. claude_mpm/commander/chat/repl.py +1729 -82
  48. claude_mpm/commander/config.py +5 -3
  49. claude_mpm/commander/core/__init__.py +10 -0
  50. claude_mpm/commander/core/block_manager.py +325 -0
  51. claude_mpm/commander/core/response_manager.py +323 -0
  52. claude_mpm/commander/daemon.py +215 -10
  53. claude_mpm/commander/env_loader.py +59 -0
  54. claude_mpm/commander/events/manager.py +61 -1
  55. claude_mpm/commander/frameworks/base.py +91 -1
  56. claude_mpm/commander/frameworks/mpm.py +9 -14
  57. claude_mpm/commander/git/__init__.py +5 -0
  58. claude_mpm/commander/git/worktree_manager.py +212 -0
  59. claude_mpm/commander/instance_manager.py +546 -15
  60. claude_mpm/commander/memory/__init__.py +45 -0
  61. claude_mpm/commander/memory/compression.py +347 -0
  62. claude_mpm/commander/memory/embeddings.py +230 -0
  63. claude_mpm/commander/memory/entities.py +310 -0
  64. claude_mpm/commander/memory/example_usage.py +290 -0
  65. claude_mpm/commander/memory/integration.py +325 -0
  66. claude_mpm/commander/memory/search.py +381 -0
  67. claude_mpm/commander/memory/store.py +657 -0
  68. claude_mpm/commander/models/events.py +6 -0
  69. claude_mpm/commander/persistence/state_store.py +95 -1
  70. claude_mpm/commander/registry.py +10 -4
  71. claude_mpm/commander/runtime/monitor.py +32 -2
  72. claude_mpm/commander/tmux_orchestrator.py +3 -2
  73. claude_mpm/commander/work/executor.py +38 -20
  74. claude_mpm/commander/workflow/event_handler.py +25 -3
  75. claude_mpm/config/skill_sources.py +16 -0
  76. claude_mpm/constants.py +5 -0
  77. claude_mpm/core/claude_runner.py +152 -0
  78. claude_mpm/core/config.py +30 -22
  79. claude_mpm/core/config_constants.py +74 -9
  80. claude_mpm/core/constants.py +56 -12
  81. claude_mpm/core/hook_manager.py +2 -1
  82. claude_mpm/core/interactive_session.py +5 -4
  83. claude_mpm/core/logger.py +16 -2
  84. claude_mpm/core/logging_utils.py +40 -16
  85. claude_mpm/core/network_config.py +148 -0
  86. claude_mpm/core/oneshot_session.py +7 -6
  87. claude_mpm/core/output_style_manager.py +37 -7
  88. claude_mpm/core/socketio_pool.py +47 -15
  89. claude_mpm/core/unified_paths.py +68 -80
  90. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
  91. claude_mpm/hooks/claude_hooks/event_handlers.py +285 -194
  92. claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
  93. claude_mpm/hooks/claude_hooks/installer.py +222 -54
  94. claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
  95. claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
  96. claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
  97. claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
  98. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
  99. claude_mpm/hooks/claude_hooks/services/container.py +326 -0
  100. claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
  101. claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
  102. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
  103. claude_mpm/hooks/session_resume_hook.py +22 -18
  104. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  105. claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
  106. claude_mpm/init.py +21 -14
  107. claude_mpm/mcp/__init__.py +9 -0
  108. claude_mpm/mcp/google_workspace_server.py +610 -0
  109. claude_mpm/scripts/claude-hook-handler.sh +10 -9
  110. claude_mpm/services/agents/agent_selection_service.py +2 -2
  111. claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
  112. claude_mpm/services/command_deployment_service.py +44 -26
  113. claude_mpm/services/hook_installer_service.py +77 -8
  114. claude_mpm/services/mcp_config_manager.py +99 -19
  115. claude_mpm/services/mcp_service_registry.py +294 -0
  116. claude_mpm/services/monitor/server.py +6 -1
  117. claude_mpm/services/pm_skills_deployer.py +5 -3
  118. claude_mpm/services/skills/git_skill_source_manager.py +79 -8
  119. claude_mpm/services/skills/selective_skill_deployer.py +28 -0
  120. claude_mpm/services/skills/skill_discovery_service.py +17 -1
  121. claude_mpm/services/skills_deployer.py +31 -5
  122. claude_mpm/skills/__init__.py +2 -1
  123. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  124. claude_mpm/skills/registry.py +295 -90
  125. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/METADATA +28 -3
  126. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/RECORD +131 -93
  127. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/WHEEL +1 -1
  128. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/entry_points.txt +2 -0
  129. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE +0 -0
  130. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  131. {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/top_level.txt +0 -0
@@ -1,29 +1,257 @@
1
1
  """Commander chat REPL interface."""
2
2
 
3
3
  import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
4
12
  from pathlib import Path
5
- from typing import Optional
13
+ from typing import TYPE_CHECKING, Optional
6
14
 
7
- from prompt_toolkit import PromptSession
15
+ from prompt_toolkit import PromptSession, prompt as pt_prompt
16
+ from prompt_toolkit.completion import Completer, Completion
8
17
  from prompt_toolkit.history import FileHistory
18
+ from prompt_toolkit.patch_stdout import patch_stdout
19
+
20
+
21
+ class RequestStatus(Enum):
22
+ """Status of a pending request."""
23
+
24
+ QUEUED = "queued"
25
+ SENDING = "sending"
26
+ WAITING = "waiting"
27
+ STARTING = "starting" # Instance starting up
28
+ COMPLETED = "completed"
29
+ ERROR = "error"
30
+
31
+
32
+ class RequestType(Enum):
33
+ """Type of pending request."""
34
+
35
+ MESSAGE = "message" # Message to instance
36
+ STARTUP = "startup" # Instance startup/ready wait
37
+
38
+
39
+ @dataclass
40
+ class PendingRequest:
41
+ """Tracks an in-flight request to an instance."""
42
+
43
+ id: str
44
+ target: str # Instance name
45
+ message: str
46
+ request_type: RequestType = RequestType.MESSAGE
47
+ status: RequestStatus = RequestStatus.QUEUED
48
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
49
+ response: Optional[str] = None
50
+ error: Optional[str] = None
51
+
52
+ def elapsed_seconds(self) -> int:
53
+ """Get elapsed time since request was created."""
54
+ return int((datetime.now(timezone.utc) - self.created_at).total_seconds())
55
+
56
+ def display_message(self, max_len: int = 40) -> str:
57
+ """Get truncated message for display."""
58
+ msg = self.message.replace("\n", " ")
59
+ if len(msg) > max_len:
60
+ return msg[: max_len - 3] + "..."
61
+ return msg
62
+
63
+
64
+ @dataclass
65
+ class SavedRegistration:
66
+ """A saved instance registration for persistence."""
67
+
68
+ name: str
69
+ path: str
70
+ framework: str # "cc" or "mpm"
71
+ registered_at: str # ISO timestamp
72
+
73
+ def to_dict(self) -> dict:
74
+ """Convert to dictionary for JSON serialization."""
75
+ return {
76
+ "name": self.name,
77
+ "path": self.path,
78
+ "framework": self.framework,
79
+ "registered_at": self.registered_at,
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> "SavedRegistration":
84
+ """Create from dictionary."""
85
+ return cls(
86
+ name=data["name"],
87
+ path=data["path"],
88
+ framework=data["framework"],
89
+ registered_at=data.get(
90
+ "registered_at", datetime.now(timezone.utc).isoformat()
91
+ ),
92
+ )
93
+
9
94
 
10
95
  from claude_mpm.commander.instance_manager import InstanceManager
11
96
  from claude_mpm.commander.llm.openrouter_client import OpenRouterClient
97
+ from claude_mpm.commander.models.events import EventType
12
98
  from claude_mpm.commander.proxy.relay import OutputRelay
13
99
  from claude_mpm.commander.session.manager import SessionManager
14
100
 
15
101
  from .commands import Command, CommandParser, CommandType
16
102
 
103
+ if TYPE_CHECKING:
104
+ from claude_mpm.commander.events.manager import EventManager
105
+ from claude_mpm.commander.models.events import Event
106
+
107
+
108
+ class CommandCompleter(Completer):
109
+ """Autocomplete for slash commands and instance names."""
110
+
111
+ COMMANDS = [
112
+ ("register", "Register and start a new instance"),
113
+ ("start", "Start a registered instance"),
114
+ ("stop", "Stop a running instance"),
115
+ ("close", "Close instance and merge worktree"),
116
+ ("connect", "Connect to instance (starts from saved if needed)"),
117
+ ("disconnect", "Disconnect from current instance"),
118
+ ("switch", "Switch to another instance"),
119
+ ("list", "List all instances"),
120
+ ("ls", "List all instances (alias)"),
121
+ ("saved", "List saved registrations"),
122
+ ("forget", "Remove a saved registration"),
123
+ ("status", "Show connection status"),
124
+ ("send", "Send literal text to tmux session"),
125
+ ("cleanup", "Clean up orphan tmux panes"),
126
+ ("help", "Show help"),
127
+ ("exit", "Exit commander"),
128
+ ("quit", "Exit commander (alias)"),
129
+ ("q", "Exit commander (alias)"),
130
+ ]
131
+
132
+ def __init__(self, get_instances_func):
133
+ """Initialize with function to get instance names.
134
+
135
+ Args:
136
+ get_instances_func: Callable that returns list of instance names.
137
+ """
138
+ self.get_instances = get_instances_func
139
+
140
+ def get_completions(self, document, complete_event):
141
+ """Generate completions for the current input.
142
+
143
+ Args:
144
+ document: The document being edited.
145
+ complete_event: The completion event.
146
+
147
+ Yields:
148
+ Completion objects for matching commands or instance names.
149
+ """
150
+ text = document.text_before_cursor
151
+
152
+ # Complete slash commands
153
+ if text.startswith("/"):
154
+ cmd_text = text[1:].lower()
155
+ # Check if we're completing command args (has space after command)
156
+ if " " in cmd_text:
157
+ # Complete instance names after certain commands
158
+ parts = cmd_text.split()
159
+ cmd = parts[0]
160
+ partial = parts[-1] if len(parts) > 1 else ""
161
+ if cmd in ("start", "stop", "close", "connect", "switch"):
162
+ yield from self._complete_instance_names(partial)
163
+ else:
164
+ # Complete command names
165
+ for cmd, desc in self.COMMANDS:
166
+ if cmd.startswith(cmd_text):
167
+ yield Completion(
168
+ cmd,
169
+ start_position=-len(cmd_text),
170
+ display_meta=desc,
171
+ )
172
+
173
+ # Complete instance names after @ prefix
174
+ elif text.startswith("@"):
175
+ partial = text[1:]
176
+ yield from self._complete_instance_names(partial)
177
+
178
+ # Complete instance names inside parentheses
179
+ elif text.startswith("("):
180
+ # Extract partial name, stripping ) and : if present
181
+ partial = text[1:].rstrip("):")
182
+ yield from self._complete_instance_names(partial)
183
+
184
+ def _complete_instance_names(self, partial: str):
185
+ """Generate completions for instance names.
186
+
187
+ Args:
188
+ partial: Partial instance name typed so far.
189
+
190
+ Yields:
191
+ Completion objects for matching instance names.
192
+ """
193
+ try:
194
+ instances = self.get_instances()
195
+ for name in instances:
196
+ if name.lower().startswith(partial.lower()):
197
+ yield Completion(
198
+ name,
199
+ start_position=-len(partial),
200
+ display_meta="instance",
201
+ )
202
+ except Exception: # nosec B110 - Graceful fallback if instance lookup fails
203
+ pass
204
+
17
205
 
18
206
  class CommanderREPL:
19
207
  """Interactive REPL for Commander mode."""
20
208
 
209
+ CAPABILITIES_CONTEXT = """
210
+ MPM Commander Capabilities:
211
+
212
+ INSTANCE MANAGEMENT (use / prefix):
213
+ - /list, /ls: Show all running Claude Code instances with their status
214
+ - /register <path> <framework> <name>: Register, start, and auto-connect (creates worktree)
215
+ - /start <name>: Start a registered instance by name
216
+ - /start <path> [--framework cc|mpm] [--name name]: Start new instance (creates worktree)
217
+ - /stop <name>: Stop a running instance (keeps worktree)
218
+ - /close <name> [--no-merge]: Close instance, merge worktree to main, and cleanup
219
+ - /connect <name>: Connect to a specific instance for interactive chat
220
+ - /switch <name>: Alias for /connect
221
+ - /disconnect: Disconnect from current instance
222
+ - /status: Show current connection status
223
+
224
+ DIRECT MESSAGING (both syntaxes work the same):
225
+ - @<name> <message>: Send message directly to any instance
226
+ - (<name>) <message>: Same as @name (parentheses syntax)
227
+ - Instance names appear in responses: @myapp: response summary...
228
+
229
+ WHEN CONNECTED:
230
+ - Send natural language messages to Claude (no / prefix)
231
+ - Receive streaming responses
232
+ - Access instance memory and context
233
+ - Execute multi-turn conversations
234
+
235
+ BUILT-IN COMMANDS:
236
+ - /help: Show available commands
237
+ - /exit, /quit, /q: Exit Commander
238
+
239
+ FEATURES:
240
+ - Real-time streaming responses
241
+ - Direct @mention messaging to any instance
242
+ - Worktree isolation and merge workflow
243
+ - Instance discovery via daemon
244
+ - Automatic reconnection handling
245
+ - Session context preservation
246
+ """
247
+
21
248
  def __init__(
22
249
  self,
23
250
  instance_manager: InstanceManager,
24
251
  session_manager: SessionManager,
25
252
  output_relay: Optional[OutputRelay] = None,
26
253
  llm_client: Optional[OpenRouterClient] = None,
254
+ event_manager: Optional["EventManager"] = None,
27
255
  ):
28
256
  """Initialize REPL.
29
257
 
@@ -32,36 +260,157 @@ class CommanderREPL:
32
260
  session_manager: Manages chat session state.
33
261
  output_relay: Optional relay for instance output.
34
262
  llm_client: Optional OpenRouter client for chat.
263
+ event_manager: Optional event manager for notifications.
35
264
  """
36
265
  self.instances = instance_manager
37
266
  self.session = session_manager
38
267
  self.relay = output_relay
39
268
  self.llm = llm_client
269
+ self.event_manager = event_manager
40
270
  self.parser = CommandParser()
41
271
  self._running = False
272
+ self._instance_ready: dict[str, bool] = {}
273
+
274
+ # Async request tracking
275
+ self._pending_requests: dict[str, PendingRequest] = {}
276
+ self._request_queue: asyncio.Queue[PendingRequest] = asyncio.Queue()
277
+ self._response_task: Optional[asyncio.Task] = None
278
+ self._startup_tasks: dict[str, asyncio.Task] = {} # Background startup tasks
279
+ self._stdout_context = None # For patch_stdout
280
+
281
+ # Bottom toolbar status for spinners
282
+ self._toolbar_status = ""
283
+ self.prompt_session: Optional[PromptSession] = None
284
+
285
+ # Persistent registration config
286
+ self._config_dir = Path.cwd() / ".claude-mpm" / "commander"
287
+ self._config_file = self._config_dir / "registrations.json"
288
+ self._saved_registrations: dict[str, SavedRegistration] = {}
289
+ self._load_registrations()
290
+
291
+ def _get_bottom_toolbar(self) -> str:
292
+ """Get bottom toolbar status for prompt_toolkit.
293
+
294
+ Returns:
295
+ Status string for display in toolbar, or empty string if no status.
296
+ """
297
+ return self._toolbar_status
42
298
 
43
299
  async def run(self) -> None:
44
300
  """Start the REPL loop."""
45
301
  self._running = True
46
302
  self._print_welcome()
47
303
 
304
+ # Wire up EventManager to InstanceManager
305
+ if self.event_manager and self.instances:
306
+ self.instances.set_event_manager(self.event_manager)
307
+
308
+ # Subscribe to instance lifecycle events
309
+ if self.event_manager:
310
+ self.event_manager.subscribe(
311
+ EventType.INSTANCE_STARTING, self._on_instance_event
312
+ )
313
+ self.event_manager.subscribe(
314
+ EventType.INSTANCE_READY, self._on_instance_event
315
+ )
316
+ self.event_manager.subscribe(
317
+ EventType.INSTANCE_ERROR, self._on_instance_event
318
+ )
319
+
48
320
  # Setup history file
49
321
  history_path = Path.home() / ".claude-mpm" / "commander_history"
50
322
  history_path.parent.mkdir(parents=True, exist_ok=True)
51
323
 
52
- prompt = PromptSession(history=FileHistory(str(history_path)))
324
+ # Create completer for slash commands and instance names
325
+ completer = CommandCompleter(self._get_instance_names)
53
326
 
54
- while self._running:
327
+ self.prompt_session = PromptSession(
328
+ history=FileHistory(str(history_path)),
329
+ completer=completer,
330
+ complete_while_typing=False, # Only complete on Tab
331
+ bottom_toolbar=self._get_bottom_toolbar,
332
+ )
333
+
334
+ # Start background response processor
335
+ self._response_task = asyncio.create_task(self._process_responses())
336
+
337
+ # Use patch_stdout to allow printing above prompt
338
+ with patch_stdout():
339
+ while self._running:
340
+ try:
341
+ # Show pending requests status above prompt
342
+ self._render_pending_status()
343
+ user_input = await self.prompt_session.prompt_async(
344
+ self._get_prompt
345
+ )
346
+ await self._handle_input(user_input.strip())
347
+ except KeyboardInterrupt:
348
+ continue
349
+ except EOFError:
350
+ break
351
+
352
+ # Cleanup
353
+ if self._response_task:
354
+ self._response_task.cancel()
55
355
  try:
56
- user_input = await asyncio.to_thread(prompt.prompt, self._get_prompt())
57
- await self._handle_input(user_input.strip())
58
- except KeyboardInterrupt:
59
- continue
60
- except EOFError:
61
- break
356
+ await self._response_task
357
+ except asyncio.CancelledError:
358
+ pass
359
+
360
+ # Stop all running instances before exiting
361
+ instances_to_stop = self.instances.list_instances()
362
+ for instance in instances_to_stop:
363
+ try:
364
+ await self.instances.stop_instance(instance.name)
365
+ except Exception as e:
366
+ self._print(f"Warning: Failed to stop '{instance.name}': {e}")
62
367
 
63
368
  self._print("\nGoodbye!")
64
369
 
370
+ def _load_registrations(self) -> None:
371
+ """Load saved registrations from config file."""
372
+ if not self._config_file.exists():
373
+ return
374
+ try:
375
+ with self._config_file.open() as f:
376
+ data = json.load(f)
377
+ for reg_data in data.get("registrations", []):
378
+ reg = SavedRegistration.from_dict(reg_data)
379
+ self._saved_registrations[reg.name] = reg
380
+ except (json.JSONDecodeError, KeyError, OSError):
381
+ # Ignore corrupt/unreadable config
382
+ pass
383
+
384
+ def _save_registrations(self) -> None:
385
+ """Save registrations to config file."""
386
+ self._config_dir.mkdir(parents=True, exist_ok=True)
387
+ data = {
388
+ "registrations": [
389
+ reg.to_dict() for reg in self._saved_registrations.values()
390
+ ]
391
+ }
392
+ with self._config_file.open("w") as f:
393
+ json.dump(data, f, indent=2)
394
+
395
+ def _save_registration(self, name: str, path: str, framework: str) -> None:
396
+ """Save a single registration."""
397
+ reg = SavedRegistration(
398
+ name=name,
399
+ path=path,
400
+ framework=framework,
401
+ registered_at=datetime.now(timezone.utc).isoformat(),
402
+ )
403
+ self._saved_registrations[name] = reg
404
+ self._save_registrations()
405
+
406
+ def _forget_registration(self, name: str) -> bool:
407
+ """Remove a saved registration. Returns True if removed."""
408
+ if name in self._saved_registrations:
409
+ del self._saved_registrations[name]
410
+ self._save_registrations()
411
+ return True
412
+ return False
413
+
65
414
  async def _handle_input(self, input_text: str) -> None:
66
415
  """Handle user input - command or natural language.
67
416
 
@@ -71,57 +420,317 @@ class CommanderREPL:
71
420
  if not input_text:
72
421
  return
73
422
 
74
- # Check if it's a built-in command
75
- command = self.parser.parse(input_text)
423
+ # Parse @instance prefix for slash commands
424
+ target_instance = None
425
+ remaining_text = input_text
426
+ if input_text.startswith("@"):
427
+ parts = input_text.split(None, 1)
428
+ if len(parts) >= 1:
429
+ target_instance = parts[0][1:] # Remove @ prefix
430
+ remaining_text = parts[1] if len(parts) > 1 else ""
431
+
432
+ # Check for direct @mention message (if no slash command follows)
433
+ if target_instance and not remaining_text.startswith("/"):
434
+ mention = self._parse_mention(input_text)
435
+ if mention:
436
+ target, message = mention
437
+ await self._cmd_message_instance(target, message)
438
+ return
439
+
440
+ # Check if it's a built-in slash command (parse the remaining text)
441
+ command_text = remaining_text if target_instance else input_text
442
+ command = self.parser.parse(command_text)
76
443
  if command:
77
- await self._execute_command(command)
444
+ await self._execute_command(command, target_instance=target_instance)
445
+ return
446
+
447
+ # If we had @instance prefix but no command, it was a message
448
+ if target_instance:
449
+ message = remaining_text
450
+ if message:
451
+ await self._cmd_message_instance(target_instance, message)
452
+ else:
453
+ self._print(
454
+ f"Instance '{target_instance}' prefix requires a message or command"
455
+ )
456
+ return
457
+
458
+ # Use LLM to classify natural language input
459
+ intent_result = await self._classify_intent_llm(input_text)
460
+ intent = intent_result.get("intent", "chat")
461
+ args = intent_result.get("args", {})
462
+
463
+ # Handle command intents detected by LLM
464
+ if intent == "register":
465
+ await self._cmd_register_from_args(args)
466
+ elif intent == "start":
467
+ await self._cmd_start_from_args(args)
468
+ elif intent == "stop":
469
+ await self._cmd_stop_from_args(args)
470
+ elif intent in {"connect", "switch"}:
471
+ await self._cmd_connect_from_args(args)
472
+ elif intent == "disconnect":
473
+ await self._cmd_disconnect([])
474
+ elif intent == "list":
475
+ await self._cmd_list([])
476
+ elif intent == "status":
477
+ await self._cmd_status([])
478
+ elif intent == "help":
479
+ await self._cmd_help([])
480
+ elif intent == "exit":
481
+ await self._cmd_exit([])
482
+ elif intent == "capabilities":
483
+ await self._handle_capabilities(input_text)
484
+ elif intent == "greeting":
485
+ self._handle_greeting()
486
+ elif intent == "message":
487
+ # Handle @mention detected by LLM
488
+ target = args.get("target")
489
+ message = args.get("message")
490
+ if target and message:
491
+ await self._cmd_message_instance(target, message)
492
+ else:
493
+ await self._send_to_instance(input_text)
78
494
  else:
79
- # Natural language - send to connected instance
495
+ # Default to chat - send to connected instance
80
496
  await self._send_to_instance(input_text)
81
497
 
82
- async def _execute_command(self, cmd: Command) -> None:
498
+ async def _execute_command(
499
+ self, cmd: Command, target_instance: Optional[str] = None
500
+ ) -> None:
83
501
  """Execute a built-in command.
84
502
 
85
503
  Args:
86
504
  cmd: Parsed command.
505
+ target_instance: Optional target instance name for @instance prefix.
87
506
  """
88
507
  handlers = {
89
508
  CommandType.LIST: self._cmd_list,
90
509
  CommandType.START: self._cmd_start,
91
510
  CommandType.STOP: self._cmd_stop,
511
+ CommandType.CLOSE: self._cmd_close,
512
+ CommandType.REGISTER: self._cmd_register,
92
513
  CommandType.CONNECT: self._cmd_connect,
93
514
  CommandType.DISCONNECT: self._cmd_disconnect,
515
+ CommandType.SAVED: self._cmd_saved,
516
+ CommandType.FORGET: self._cmd_forget,
94
517
  CommandType.STATUS: self._cmd_status,
95
518
  CommandType.HELP: self._cmd_help,
96
519
  CommandType.EXIT: self._cmd_exit,
520
+ CommandType.MPM_OAUTH: self._cmd_oauth,
521
+ CommandType.CLEANUP: self._cmd_cleanup,
522
+ CommandType.SEND: self._cmd_send,
97
523
  }
98
524
  handler = handlers.get(cmd.type)
99
525
  if handler:
100
- await handler(cmd.args)
526
+ # For target-specific commands, pass target_instance if provided
527
+ if cmd.type in {CommandType.STATUS, CommandType.SEND, CommandType.STOP}:
528
+ await handler(cmd.args, target_instance=target_instance)
529
+ else:
530
+ await handler(cmd.args)
101
531
 
102
- async def _cmd_list(self, args: list[str]) -> None:
103
- """List active instances."""
104
- instances = self.instances.list_instances()
105
- if not instances:
106
- self._print("No active instances.")
107
- else:
108
- self._print("Active instances:")
109
- for inst in instances:
110
- status = (
111
- "→" if inst.name == self.session.context.connected_instance else " "
532
+ def _classify_intent(self, text: str) -> str:
533
+ """Classify user input intent.
534
+
535
+ Args:
536
+ text: User input text.
537
+
538
+ Returns:
539
+ Intent type: 'greeting', 'capabilities', or 'chat'.
540
+ """
541
+ t = text.lower().strip()
542
+ if any(t.startswith(g) for g in ["hello", "hi", "hey", "howdy"]):
543
+ return "greeting"
544
+ if any(p in t for p in ["what can you", "can you", "help me", "how do i"]):
545
+ return "capabilities"
546
+ return "chat"
547
+
548
+ def _parse_mention(self, text: str) -> tuple[str, str] | None:
549
+ """Parse @name or (name) message patterns - both work the same.
550
+
551
+ Both syntaxes are equivalent:
552
+ @name message
553
+ (name) message
554
+ (name): message
555
+
556
+ Args:
557
+ text: User input text.
558
+
559
+ Returns:
560
+ Tuple of (target_name, message) if pattern matches, None otherwise.
561
+ """
562
+ # @name message
563
+ match = re.match(r"^@(\w+)\s+(.+)$", text.strip())
564
+ if match:
565
+ return match.group(1), match.group(2)
566
+
567
+ # (name): message or (name) message - same behavior as @name
568
+ match = re.match(r"^\((\w+)\):?\s*(.+)$", text.strip())
569
+ if match:
570
+ return match.group(1), match.group(2)
571
+
572
+ return None
573
+
574
+ async def _classify_intent_llm(self, text: str) -> dict:
575
+ """Use LLM to classify user intent.
576
+
577
+ Args:
578
+ text: User input text.
579
+
580
+ Returns:
581
+ Dict with 'intent' and 'args' keys.
582
+ """
583
+ if not self.llm:
584
+ return {"intent": "chat", "args": {}}
585
+
586
+ system_prompt = """Classify user intent. Return JSON only.
587
+
588
+ Commands available:
589
+ - register: Register new instance (needs: path, framework, name)
590
+ - start: Start registered instance (needs: name)
591
+ - stop: Stop instance (needs: name)
592
+ - connect: Connect to instance (needs: name)
593
+ - disconnect: Disconnect from current instance
594
+ - switch: Switch to different instance (needs: name)
595
+ - list: List instances
596
+ - status: Show status
597
+ - help: Show help
598
+ - exit: Exit commander
599
+
600
+ If user wants a command, extract arguments.
601
+ If user is chatting/asking questions, intent is "chat".
602
+
603
+ Examples:
604
+ "register my project at ~/foo as myapp using mpm" -> {"intent":"register","args":{"path":"~/foo","framework":"mpm","name":"myapp"}}
605
+ "start myapp" -> {"intent":"start","args":{"name":"myapp"}}
606
+ "stop the server" -> {"intent":"stop","args":{"name":null}}
607
+ "list instances" -> {"intent":"list","args":{}}
608
+ "hello how are you" -> {"intent":"chat","args":{}}
609
+ "what can you do" -> {"intent":"capabilities","args":{}}
610
+ "@izzie show me the code" -> {"intent":"message","args":{"target":"izzie","message":"show me the code"}}
611
+ "(myapp): what's the status" -> {"intent":"message","args":{"target":"myapp","message":"what's the status"}}
612
+
613
+ Return ONLY valid JSON."""
614
+
615
+ try:
616
+ messages = [{"role": "user", "content": f"Classify: {text}"}]
617
+ response = await self.llm.chat(messages, system=system_prompt)
618
+ return json.loads(response.strip())
619
+ except (json.JSONDecodeError, Exception): # nosec B110 - Graceful fallback
620
+ return {"intent": "chat", "args": {}}
621
+
622
+ def _handle_greeting(self) -> None:
623
+ """Handle greeting intent."""
624
+ self._print(
625
+ "Hello! I'm MPM Commander. Type '/help' for commands, or '/list' to see instances."
626
+ )
627
+
628
+ async def _handle_capabilities(self, query: str = "") -> None:
629
+ """Answer questions about capabilities, using LLM if available.
630
+
631
+ Args:
632
+ query: Optional user query about capabilities.
633
+ """
634
+ if query and self.llm:
635
+ try:
636
+ messages = [
637
+ {
638
+ "role": "user",
639
+ "content": f"Based on these capabilities:\n{self.CAPABILITIES_CONTEXT}\n\nUser asks: {query}",
640
+ }
641
+ ]
642
+ system = (
643
+ "Answer concisely about MPM Commander capabilities. "
644
+ "If asked about something not in the capabilities, say so."
112
645
  )
646
+ response = await self.llm.chat(messages, system=system)
647
+ self._print(response)
648
+ return
649
+ except Exception: # nosec B110 - Graceful fallback to static output
650
+ pass
651
+ # Fallback to static output
652
+ self._print(self.CAPABILITIES_CONTEXT)
653
+
654
+ async def _cmd_list(self, args: list[str]) -> None:
655
+ """List instances: both running and saved registrations.
656
+
657
+ Shows:
658
+ - Running instances with status (connected, ready, or connecting)
659
+ - Saved registrations that are not currently running
660
+ """
661
+ running_instances = self.instances.list_instances()
662
+ running_names = {inst.name for inst in running_instances}
663
+ saved_registrations = self._saved_registrations
664
+
665
+ # Collect all unique names
666
+ all_names = set(running_names) | set(saved_registrations.keys())
667
+
668
+ if not all_names:
669
+ self._print("No instances (running or saved).")
670
+ self._print("Use '/register <path> <framework> <name>' to create one.")
671
+ return
672
+
673
+ # Build output
674
+ self._print("Sessions:")
675
+
676
+ # Display in order: running first, then saved
677
+ for name in sorted(all_names):
678
+ inst = next((i for i in running_instances if i.name == name), None)
679
+ is_connected = inst and name == self.session.context.connected_instance
680
+
681
+ if inst:
682
+ # Running instance
113
683
  git_info = f" [{inst.git_branch}]" if inst.git_branch else ""
114
- self._print(
115
- f" {status} {inst.name} ({inst.framework}){git_info} - {inst.project_path}"
116
- )
684
+
685
+ # Determine status
686
+ if is_connected:
687
+ instance_status = "connected"
688
+ elif inst.ready:
689
+ instance_status = "ready"
690
+ else:
691
+ instance_status = "starting"
692
+
693
+ # Format with right-aligned path
694
+ line = f" {name} (running, {instance_status})"
695
+ path_display = f"{inst.project_path}{git_info}"
696
+ # Pad to align paths
697
+ padding = max(1, 40 - len(line))
698
+ self._print(f"{line}{' ' * padding}{path_display}")
699
+ else:
700
+ # Saved registration (not running)
701
+ reg = saved_registrations[name]
702
+ line = f" {name} (saved)"
703
+ # Pad to align paths
704
+ padding = max(1, 40 - len(line))
705
+ self._print(f"{line}{' ' * padding}{reg.path}")
117
706
 
118
707
  async def _cmd_start(self, args: list[str]) -> None:
119
- """Start a new instance: start <path> [--framework cc|mpm] [--name name]."""
708
+ """Start instance: start <name> OR start <path> [--framework cc|mpm] [--name name]."""
120
709
  if not args:
121
- self._print("Usage: start <path> [--framework cc|mpm] [--name name]")
710
+ self._print("Usage: start <name> (for registered instances)")
711
+ self._print(" start <path> [--framework cc|mpm] [--name name]")
122
712
  return
123
713
 
124
- # Parse arguments
714
+ # Check if first arg is a registered instance name (no path separators)
715
+ if len(args) == 1 and "/" not in args[0] and not args[0].startswith("~"):
716
+ name = args[0]
717
+ try:
718
+ instance = await self.instances.start_by_name(name)
719
+ if instance:
720
+ self._print(f"Started registered instance '{name}'")
721
+ self._print(
722
+ f" Tmux: {instance.tmux_session}:{instance.pane_target}"
723
+ )
724
+ else:
725
+ self._print(f"No registered instance named '{name}'")
726
+ self._print(
727
+ "Use 'register <path> <framework> <name>' to register first"
728
+ )
729
+ except Exception as e:
730
+ self._print(f"Error starting instance: {e}")
731
+ return
732
+
733
+ # Path-based start logic
125
734
  project_path = Path(args[0]).expanduser().resolve()
126
735
  framework = "cc" # default
127
736
  name = project_path.name # default
@@ -147,23 +756,47 @@ class CommanderREPL:
147
756
  self._print(f"Error: Path is not a directory: {project_path}")
148
757
  return
149
758
 
150
- # Start instance
759
+ # Register and start instance (creates worktree for git repos)
151
760
  try:
152
- instance = await self.instances.start_instance(
153
- name, project_path, framework
761
+ instance = await self.instances.register_instance(
762
+ str(project_path), framework, name
154
763
  )
155
764
  self._print(f"Started instance '{name}' ({framework}) at {project_path}")
156
- self._print(f"Tmux: {instance.tmux_session}:{instance.pane_target}")
765
+ self._print(f" Tmux: {instance.tmux_session}:{instance.pane_target}")
766
+
767
+ # Check if worktree was created
768
+ if self.instances._state_store:
769
+ registered = self.instances._state_store.get_instance(name)
770
+ if registered and registered.use_worktree and registered.worktree_path:
771
+ self._print(f" Worktree: {registered.worktree_path}")
772
+ self._print(f" Branch: {registered.worktree_branch}")
773
+
774
+ # Spawn background task to wait for ready (non-blocking with spinner)
775
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
157
776
  except Exception as e:
158
777
  self._print(f"Error starting instance: {e}")
159
778
 
160
- async def _cmd_stop(self, args: list[str]) -> None:
161
- """Stop an instance: stop <name>."""
162
- if not args:
163
- self._print("Usage: stop <instance-name>")
164
- return
779
+ async def _cmd_stop(
780
+ self, args: list[str], target_instance: Optional[str] = None
781
+ ) -> None:
782
+ """Stop an instance.
165
783
 
166
- name = args[0]
784
+ Usage:
785
+ /stop <name> # Stop by name
786
+ @instance /stop # Stop specific instance via @instance prefix
787
+
788
+ Args:
789
+ args: Command arguments (instance name, if not using @instance prefix).
790
+ target_instance: Optional target instance name from @instance prefix.
791
+ """
792
+ # Use target instance if provided via @instance prefix
793
+ if target_instance:
794
+ name = target_instance
795
+ else:
796
+ if not args:
797
+ self._print("Usage: stop <instance-name>")
798
+ return
799
+ name = args[0]
167
800
 
168
801
  try:
169
802
  await self.instances.stop_instance(name)
@@ -175,17 +808,103 @@ class CommanderREPL:
175
808
  except Exception as e:
176
809
  self._print(f"Error stopping instance: {e}")
177
810
 
811
+ async def _cmd_close(self, args: list[str]) -> None:
812
+ """Close instance: merge worktree to main and end session.
813
+
814
+ Usage: /close <name> [--no-merge]
815
+ """
816
+ if not args:
817
+ self._print("Usage: /close <name> [--no-merge]")
818
+ return
819
+
820
+ name = args[0]
821
+ merge = "--no-merge" not in args
822
+
823
+ # Disconnect if we were connected
824
+ if self.session.context.connected_instance == name:
825
+ self.session.disconnect()
826
+
827
+ success, msg = await self.instances.close_instance(name, merge=merge)
828
+ if success:
829
+ self._print(f"Closed '{name}'")
830
+ if merge:
831
+ self._print(" Worktree merged to main")
832
+ else:
833
+ self._print(f"Error: {msg}")
834
+
835
+ async def _cmd_register(self, args: list[str]) -> None:
836
+ """Register and start an instance: register <path> <framework> <name>."""
837
+ if len(args) < 3:
838
+ self._print("Usage: register <path> <framework> <name>")
839
+ self._print(" framework: cc (Claude Code) or mpm")
840
+ return
841
+
842
+ path, framework, name = args[0], args[1], args[2]
843
+ path = Path(path).expanduser().resolve()
844
+
845
+ if framework not in ("cc", "mpm"):
846
+ self._print(f"Unknown framework: {framework}. Use 'cc' or 'mpm'")
847
+ return
848
+
849
+ # Validate path
850
+ if not path.exists():
851
+ self._print(f"Error: Path does not exist: {path}")
852
+ return
853
+
854
+ if not path.is_dir():
855
+ self._print(f"Error: Path is not a directory: {path}")
856
+ return
857
+
858
+ try:
859
+ instance = await self.instances.register_instance(
860
+ str(path), framework, name
861
+ )
862
+ self._print(f"Registered and started '{name}' ({framework}) at {path}")
863
+ self._print(f" Tmux: {instance.tmux_session}:{instance.pane_target}")
864
+
865
+ # Save registration for persistence
866
+ self._save_registration(name, str(path), framework)
867
+
868
+ # Spawn background task to wait for ready (non-blocking with spinner)
869
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
870
+ except Exception as e:
871
+ self._print(f"Failed to register: {e}")
872
+
178
873
  async def _cmd_connect(self, args: list[str]) -> None:
179
- """Connect to an instance: connect <name>."""
874
+ """Connect to an instance: connect <name>.
875
+
876
+ If instance is not running but has saved registration, start it first.
877
+ """
180
878
  if not args:
181
879
  self._print("Usage: connect <instance-name>")
182
880
  return
183
881
 
184
882
  name = args[0]
185
883
  inst = self.instances.get_instance(name)
884
+
186
885
  if not inst:
187
- self._print(f"Instance '{name}' not found")
188
- return
886
+ # Check if we have a saved registration
887
+ saved = self._saved_registrations.get(name)
888
+ if saved:
889
+ self._print(f"Starting '{name}' from saved config...")
890
+ try:
891
+ instance = await self.instances.register_instance(
892
+ saved.path, saved.framework, name
893
+ )
894
+ self._print(f"Started '{name}' ({saved.framework}) at {saved.path}")
895
+ self._print(
896
+ f" Tmux: {instance.tmux_session}:{instance.pane_target}"
897
+ )
898
+ # Spawn background task to wait for ready (non-blocking with spinner)
899
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
900
+ return
901
+ except Exception as e:
902
+ self._print(f"Failed to start from saved config: {e}")
903
+ return
904
+ else:
905
+ self._print(f"Instance '{name}' not found")
906
+ self._print(" Use /saved to see saved registrations")
907
+ return
189
908
 
190
909
  self.session.connect_to(name)
191
910
  self._print(f"Connected to {name}")
@@ -200,8 +919,31 @@ class CommanderREPL:
200
919
  self.session.disconnect()
201
920
  self._print(f"Disconnected from {name}")
202
921
 
203
- async def _cmd_status(self, args: list[str]) -> None:
204
- """Show status of current session."""
922
+ async def _cmd_status(
923
+ self, args: list[str], target_instance: Optional[str] = None
924
+ ) -> None:
925
+ """Show status of current session or a specific instance.
926
+
927
+ Args:
928
+ args: Command arguments (unused if target_instance is provided).
929
+ target_instance: Optional target instance name from @instance prefix.
930
+ """
931
+ # Use target instance if provided via @instance prefix
932
+ if target_instance:
933
+ inst = self.instances.get_instance(target_instance)
934
+ if not inst:
935
+ self._print(f"Instance '{target_instance}' not found")
936
+ return
937
+ self._print(f"Status of {target_instance}:")
938
+ self._print(f" Framework: {inst.framework}")
939
+ self._print(f" Project: {inst.project_path}")
940
+ if inst.git_branch:
941
+ self._print(f" Git: {inst.git_branch} ({inst.git_status})")
942
+ self._print(f" Tmux: {inst.tmux_session}:{inst.pane_target}")
943
+ self._print(f" Ready: {'Yes' if inst.ready else 'No'}")
944
+ return
945
+
946
+ # Default behavior - show connected instance status
205
947
  if self.session.context.is_connected:
206
948
  name = self.session.context.connected_instance
207
949
  inst = self.instances.get_instance(name)
@@ -219,44 +961,666 @@ class CommanderREPL:
219
961
 
220
962
  self._print(f"Messages in history: {len(self.session.context.messages)}")
221
963
 
964
+ async def _cmd_send(
965
+ self, args: list[str], target_instance: Optional[str] = None
966
+ ) -> None:
967
+ """Send literal text directly to a tmux session.
968
+
969
+ Usage:
970
+ /send /help # Send to connected instance
971
+ /send /mpm-status
972
+ /send ls -la
973
+ @instance /send /help # Send to specific instance
974
+
975
+ The text (including slash commands) is sent verbatim to the pane.
976
+
977
+ Args:
978
+ args: Command arguments (the text to send).
979
+ target_instance: Optional target instance name from @instance prefix.
980
+ """
981
+ if not args:
982
+ self._print("Usage: /send <text>")
983
+ self._print("Send literal text to the tmux session")
984
+ return
985
+
986
+ # Determine target instance
987
+ if target_instance:
988
+ instance_name = target_instance
989
+ inst = self.instances.get_instance(instance_name)
990
+ if not inst:
991
+ self._print(f"Instance '{instance_name}' not found")
992
+ return
993
+ else:
994
+ if not self.session.context.is_connected:
995
+ self._print("Not connected to any instance")
996
+ return
997
+ instance_name = self.session.context.connected_instance
998
+ inst = self.instances.get_instance(instance_name)
999
+ if not inst:
1000
+ self._print(f"Instance '{instance_name}' no longer exists")
1001
+ return
1002
+
1003
+ # Reconstruct the full text from args
1004
+ text = " ".join(args)
1005
+ pane_target = f"{inst.tmux_session}:{inst.pane_target}"
1006
+
1007
+ try:
1008
+ success = self.instances.orchestrator.send_keys(pane_target, text)
1009
+ if success:
1010
+ self._print(f"Sent to {instance_name}: {text}")
1011
+ else:
1012
+ self._print(f"Failed to send to {instance_name}")
1013
+ except Exception as e:
1014
+ self._print(f"Error sending to {instance_name}: {e}")
1015
+
1016
+ async def _cmd_saved(self, args: list[str]) -> None:
1017
+ """List saved registrations."""
1018
+ if not self._saved_registrations:
1019
+ self._print("No saved registrations")
1020
+ self._print(" Use /register to create one")
1021
+ return
1022
+
1023
+ self._print("Saved registrations:")
1024
+ for reg in self._saved_registrations.values():
1025
+ running = self.instances.get_instance(reg.name) is not None
1026
+ status = " (running)" if running else ""
1027
+ self._print(f" {reg.name}: {reg.path} [{reg.framework}]{status}")
1028
+
1029
+ async def _cmd_forget(self, args: list[str]) -> None:
1030
+ """Remove a saved registration: forget <name>."""
1031
+ if not args:
1032
+ self._print("Usage: forget <name>")
1033
+ return
1034
+
1035
+ name = args[0]
1036
+ if self._forget_registration(name):
1037
+ self._print(f"Removed saved registration '{name}'")
1038
+ else:
1039
+ self._print(f"No saved registration named '{name}'")
1040
+
222
1041
  async def _cmd_help(self, args: list[str]) -> None:
223
1042
  """Show help message."""
224
1043
  help_text = """
225
- Commander Commands:
226
- list, ls, instances List active instances
227
- start <path> Start new instance at path
228
- --framework <cc|mpm> Specify framework (default: cc)
229
- --name <name> Specify instance name (default: dir name)
230
- stop <name> Stop an instance
231
- connect <name> Connect to an instance
232
- disconnect Disconnect from current instance
233
- status Show current session status
234
- help Show this help message
235
- exit, quit, q Exit Commander
1044
+ Commander Commands (use / prefix):
1045
+ /register <path> <framework> <name>
1046
+ Register, start, and auto-connect (creates worktree)
1047
+ /connect <name> Connect to instance (starts from saved config if needed)
1048
+ /switch <name> Alias for /connect
1049
+ /disconnect Disconnect from current instance
1050
+ /start <name> Start a registered instance by name
1051
+ /start <path> Start new instance (creates worktree for git repos)
1052
+ /stop <name> Stop an instance (keeps worktree)
1053
+ /close <name> [--no-merge]
1054
+ Close instance: merge worktree to main and cleanup
1055
+ /list, /ls List active instances
1056
+ /saved List saved registrations
1057
+ /forget <name> Remove a saved registration
1058
+ /status Show current session status
1059
+ /send <text> Send literal text directly to connected tmux session
1060
+ /cleanup [--force] Clean up orphan tmux panes (--force to kill them)
1061
+ /help Show this help message
1062
+ /exit, /quit, /q Exit Commander
1063
+
1064
+ Direct Messaging (both syntaxes work the same):
1065
+ @<name> <message> Send message to specific instance
1066
+ (<name>) <message> Same as @name (parentheses syntax)
1067
+
1068
+ Instance-Targeted Commands (@ prefix):
1069
+ @<name> /status Show status of specific instance
1070
+ @<name> /send <text> Send text to specific instance (no connection needed)
1071
+ @<name> /stop Stop specific instance
236
1072
 
237
1073
  Natural Language:
238
- When connected to an instance, any input that is not a built-in
239
- command will be sent to the connected instance as a message.
1074
+ Any input without / prefix is sent to the connected instance.
1075
+
1076
+ Git Worktree Isolation:
1077
+ When starting instances in git repos, a worktree is created on a
1078
+ session-specific branch. Use /close to merge changes back to main.
240
1079
 
241
1080
  Examples:
242
- start ~/myproject --framework cc --name myapp
243
- connect myapp
244
- Fix the authentication bug in login.py
245
- disconnect
246
- exit
1081
+ /register ~/myproject cc myapp # Register, start, and connect
1082
+ /start ~/myproject # Start with auto-detected name
1083
+ /start myapp # Start registered instance
1084
+ /close myapp # Merge worktree to main and cleanup
1085
+ /close myapp --no-merge # Cleanup without merging
1086
+ /cleanup # Show orphan panes
1087
+ /cleanup --force # Kill orphan panes
1088
+ @myapp show me the code # Direct message to myapp
1089
+ (izzie) what's the status # Same as @izzie
1090
+ @duetto /status # Check status of duetto instance
1091
+ @mpm /send /help # Send /help to mpm instance
1092
+ @duetto /stop # Stop duetto without connecting
1093
+ Fix the authentication bug # Send to connected instance
1094
+ /exit
247
1095
  """
248
1096
  self._print(help_text)
249
1097
 
250
1098
  async def _cmd_exit(self, args: list[str]) -> None:
251
- """Exit the REPL."""
1099
+ """Exit the REPL and stop all running instances."""
1100
+ # Stop all running instances before exiting
1101
+ instances_to_stop = self.instances.list_instances()
1102
+ for instance in instances_to_stop:
1103
+ try:
1104
+ await self.instances.stop_instance(instance.name)
1105
+ except Exception as e:
1106
+ self._print(f"Warning: Failed to stop '{instance.name}': {e}")
1107
+
252
1108
  self._running = False
253
1109
 
1110
+ async def _cmd_oauth(self, args: list[str]) -> None:
1111
+ """Handle OAuth command with subcommands.
1112
+
1113
+ Usage:
1114
+ /mpm-oauth - Show help
1115
+ /mpm-oauth list - List OAuth-capable services
1116
+ /mpm-oauth setup <service> - Set up OAuth for a service
1117
+ /mpm-oauth status <service> - Show token status
1118
+ /mpm-oauth revoke <service> - Revoke OAuth tokens
1119
+ /mpm-oauth refresh <service> - Refresh OAuth tokens
1120
+ """
1121
+ if not args:
1122
+ await self._cmd_oauth_help()
1123
+ return
1124
+
1125
+ subcommand = args[0].lower()
1126
+ subargs = args[1:] if len(args) > 1 else []
1127
+
1128
+ if subcommand == "help":
1129
+ await self._cmd_oauth_help()
1130
+ elif subcommand == "list":
1131
+ await self._cmd_oauth_list()
1132
+ elif subcommand == "setup":
1133
+ if not subargs:
1134
+ self._print("Usage: /mpm-oauth setup <service>")
1135
+ return
1136
+ await self._cmd_oauth_setup(subargs[0])
1137
+ elif subcommand == "status":
1138
+ if not subargs:
1139
+ self._print("Usage: /mpm-oauth status <service>")
1140
+ return
1141
+ await self._cmd_oauth_status(subargs[0])
1142
+ elif subcommand == "revoke":
1143
+ if not subargs:
1144
+ self._print("Usage: /mpm-oauth revoke <service>")
1145
+ return
1146
+ await self._cmd_oauth_revoke(subargs[0])
1147
+ elif subcommand == "refresh":
1148
+ if not subargs:
1149
+ self._print("Usage: /mpm-oauth refresh <service>")
1150
+ return
1151
+ await self._cmd_oauth_refresh(subargs[0])
1152
+ else:
1153
+ self._print(f"Unknown subcommand: {subcommand}")
1154
+ await self._cmd_oauth_help()
1155
+
1156
+ async def _cmd_oauth_help(self) -> None:
1157
+ """Print OAuth command help."""
1158
+ help_text = """
1159
+ OAuth Commands:
1160
+ /mpm-oauth list List OAuth-capable MCP services
1161
+ /mpm-oauth setup <service> Set up OAuth authentication for a service
1162
+ /mpm-oauth status <service> Show token status for a service
1163
+ /mpm-oauth revoke <service> Revoke OAuth tokens for a service
1164
+ /mpm-oauth refresh <service> Refresh OAuth tokens for a service
1165
+ /mpm-oauth help Show this help message
1166
+
1167
+ Examples:
1168
+ /mpm-oauth list
1169
+ /mpm-oauth setup google-drive
1170
+ /mpm-oauth status google-drive
1171
+ """
1172
+ self._print(help_text)
1173
+
1174
+ async def _cmd_oauth_list(self) -> None:
1175
+ """List OAuth-capable services from MCP registry."""
1176
+ try:
1177
+ from claude_mpm.services.mcp_service_registry import MCPServiceRegistry
1178
+
1179
+ registry = MCPServiceRegistry()
1180
+ services = registry.list_oauth_services()
1181
+
1182
+ if not services:
1183
+ self._print("No OAuth-capable services found.")
1184
+ return
1185
+
1186
+ self._print("OAuth-capable services:")
1187
+ for service in services:
1188
+ self._print(f" - {service}")
1189
+ except ImportError:
1190
+ self._print("MCP Service Registry not available.")
1191
+ except Exception as e:
1192
+ self._print(f"Error listing services: {e}")
1193
+
1194
+ def _load_oauth_credentials_from_env_files(self) -> tuple[str | None, str | None]:
1195
+ """Load OAuth credentials from .env files.
1196
+
1197
+ Checks .env.local first (user overrides), then .env.
1198
+ Returns tuple of (client_id, client_secret), either may be None.
1199
+ """
1200
+ client_id = None
1201
+ client_secret = None
1202
+
1203
+ # Priority order: .env.local first (user overrides), then .env
1204
+ env_files = [".env.local", ".env"]
1205
+
1206
+ for env_file in env_files:
1207
+ env_path = Path.cwd() / env_file
1208
+ if env_path.exists():
1209
+ try:
1210
+ with open(env_path) as f:
1211
+ for line in f:
1212
+ line = line.strip()
1213
+ # Skip empty lines and comments
1214
+ if not line or line.startswith("#"):
1215
+ continue
1216
+ if "=" in line:
1217
+ key, _, value = line.partition("=")
1218
+ key = key.strip()
1219
+ value = value.strip().strip('"').strip("'")
1220
+
1221
+ if key == "GOOGLE_OAUTH_CLIENT_ID" and not client_id:
1222
+ client_id = value
1223
+ elif (
1224
+ key == "GOOGLE_OAUTH_CLIENT_SECRET"
1225
+ and not client_secret
1226
+ ):
1227
+ client_secret = value
1228
+
1229
+ # If we found both, no need to check more files
1230
+ if client_id and client_secret:
1231
+ break
1232
+ except Exception: # nosec B110 - intentionally ignore .env file read errors
1233
+ # Silently ignore read errors
1234
+ pass
1235
+
1236
+ return client_id, client_secret
1237
+
1238
+ async def _cmd_oauth_setup(self, service_name: str) -> None:
1239
+ """Set up OAuth for a service.
1240
+
1241
+ Args:
1242
+ service_name: Name of the service to authenticate.
1243
+ """
1244
+ # Priority: 1) .env files, 2) environment variables, 3) interactive prompt
1245
+ # Check .env files first
1246
+ client_id, client_secret = self._load_oauth_credentials_from_env_files()
1247
+
1248
+ # Fall back to environment variables if not found in .env files
1249
+ if not client_id:
1250
+ client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID")
1251
+ if not client_secret:
1252
+ client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")
1253
+
1254
+ # If credentials missing, prompt for them interactively
1255
+ if not client_id or not client_secret:
1256
+ self._console.print(
1257
+ "\n[yellow]Google OAuth credentials not found.[/yellow]"
1258
+ )
1259
+ self._console.print(
1260
+ "Checked: .env.local, .env, and environment variables.\n"
1261
+ )
1262
+ self._console.print(
1263
+ "Get credentials from: https://console.cloud.google.com/apis/credentials\n"
1264
+ )
1265
+ self._console.print(
1266
+ "[dim]Tip: Add to .env.local for automatic loading:[/dim]"
1267
+ )
1268
+ self._console.print('[dim] GOOGLE_OAUTH_CLIENT_ID="your-client-id"[/dim]')
1269
+ self._console.print(
1270
+ '[dim] GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"[/dim]\n' # pragma: allowlist secret
1271
+ )
1272
+
1273
+ try:
1274
+ client_id = pt_prompt("Enter GOOGLE_OAUTH_CLIENT_ID: ")
1275
+ if not client_id.strip():
1276
+ self._print("Error: Client ID is required")
1277
+ return
1278
+
1279
+ client_secret = pt_prompt(
1280
+ "Enter GOOGLE_OAUTH_CLIENT_SECRET: ", is_password=True
1281
+ )
1282
+ if not client_secret.strip():
1283
+ self._print("Error: Client Secret is required")
1284
+ return
1285
+
1286
+ # Set in environment for this session
1287
+ os.environ["GOOGLE_OAUTH_CLIENT_ID"] = client_id.strip()
1288
+ os.environ["GOOGLE_OAUTH_CLIENT_SECRET"] = client_secret.strip()
1289
+ self._console.print(
1290
+ "\n[green]Credentials set for this session.[/green]"
1291
+ )
1292
+
1293
+ # Ask if user wants to save credentials
1294
+ save_response = pt_prompt(
1295
+ "\nSave credentials to shell profile? (y/n): "
1296
+ )
1297
+ if save_response.strip().lower() in ("y", "yes"):
1298
+ self._console.print("\nAdd these lines to your shell profile:")
1299
+ self._console.print(
1300
+ f' export GOOGLE_OAUTH_CLIENT_ID="{client_id.strip()}"'
1301
+ )
1302
+ self._console.print(
1303
+ f' export GOOGLE_OAUTH_CLIENT_SECRET="{client_secret.strip()}"'
1304
+ )
1305
+ self._console.print("")
1306
+
1307
+ except (EOFError, KeyboardInterrupt):
1308
+ self._print("\nCredential entry cancelled.")
1309
+ return
1310
+
1311
+ try:
1312
+ from claude_mpm.auth import OAuthManager
1313
+
1314
+ manager = OAuthManager()
1315
+
1316
+ self._print(f"Setting up OAuth for '{service_name}'...")
1317
+ self._print("Opening browser for authentication...")
1318
+ self._print("Callback server listening on http://localhost:8085/callback")
1319
+
1320
+ result = await manager.authenticate(service_name)
1321
+
1322
+ if result.success:
1323
+ self._print(f"OAuth setup complete for '{service_name}'")
1324
+ self._print(f" Token expires: {result.expires_at}")
1325
+ else:
1326
+ self._print(f"OAuth setup failed: {result.error}")
1327
+ except ImportError:
1328
+ self._print("OAuth module not available.")
1329
+ except Exception as e:
1330
+ self._print(f"Error during OAuth setup: {e}")
1331
+
1332
+ async def _cmd_oauth_status(self, service_name: str) -> None:
1333
+ """Show OAuth token status for a service.
1334
+
1335
+ Args:
1336
+ service_name: Name of the service to check.
1337
+ """
1338
+ try:
1339
+ from claude_mpm.auth import OAuthManager
1340
+
1341
+ manager = OAuthManager()
1342
+ status = await manager.get_status(service_name)
1343
+
1344
+ if status is None:
1345
+ self._print(f"No OAuth tokens found for '{service_name}'")
1346
+ return
1347
+
1348
+ self._print_token_status(service_name, status, stored=True)
1349
+ except ImportError:
1350
+ self._print("OAuth module not available.")
1351
+ except Exception as e:
1352
+ self._print(f"Error checking status: {e}")
1353
+
1354
+ async def _cmd_oauth_revoke(self, service_name: str) -> None:
1355
+ """Revoke OAuth tokens for a service.
1356
+
1357
+ Args:
1358
+ service_name: Name of the service to revoke.
1359
+ """
1360
+ try:
1361
+ from claude_mpm.auth import OAuthManager
1362
+
1363
+ manager = OAuthManager()
1364
+
1365
+ self._print(f"Revoking OAuth tokens for '{service_name}'...")
1366
+ result = await manager.revoke(service_name)
1367
+
1368
+ if result.success:
1369
+ self._print(f"OAuth tokens revoked for '{service_name}'")
1370
+ else:
1371
+ self._print(f"Failed to revoke: {result.error}")
1372
+ except ImportError:
1373
+ self._print("OAuth module not available.")
1374
+ except Exception as e:
1375
+ self._print(f"Error revoking tokens: {e}")
1376
+
1377
+ async def _cmd_oauth_refresh(self, service_name: str) -> None:
1378
+ """Refresh OAuth tokens for a service.
1379
+
1380
+ Args:
1381
+ service_name: Name of the service to refresh.
1382
+ """
1383
+ try:
1384
+ from claude_mpm.auth import OAuthManager
1385
+
1386
+ manager = OAuthManager()
1387
+
1388
+ self._print(f"Refreshing OAuth tokens for '{service_name}'...")
1389
+ result = await manager.refresh(service_name)
1390
+
1391
+ if result.success:
1392
+ self._print(f"OAuth tokens refreshed for '{service_name}'")
1393
+ self._print(f" New expiry: {result.expires_at}")
1394
+ else:
1395
+ self._print(f"Failed to refresh: {result.error}")
1396
+ except ImportError:
1397
+ self._print("OAuth module not available.")
1398
+ except Exception as e:
1399
+ self._print(f"Error refreshing tokens: {e}")
1400
+
1401
+ async def _cmd_cleanup(self, args: list[str]) -> None:
1402
+ """Clean up orphan tmux panes not in tracked instances.
1403
+
1404
+ Identifies all tmux panes in the commander session and removes those
1405
+ that are not associated with any tracked instance.
1406
+
1407
+ Usage:
1408
+ /cleanup - Show orphan panes without killing
1409
+ /cleanup --force - Kill orphan panes
1410
+ """
1411
+ force_kill = "--force" in args
1412
+
1413
+ # Get all panes in the commander session
1414
+ try:
1415
+ all_panes = self.instances.orchestrator.list_panes()
1416
+ except Exception as e:
1417
+ self._print(f"Error listing panes: {e}")
1418
+ return
1419
+
1420
+ # Get tracked instance pane targets
1421
+ tracked_instances = self.instances.list_instances()
1422
+ tracked_panes = {inst.pane_target for inst in tracked_instances}
1423
+
1424
+ # Find orphan panes (panes not in any tracked instance)
1425
+ orphan_panes = []
1426
+ for pane in all_panes:
1427
+ pane_id = pane["id"]
1428
+ session_pane_target = (
1429
+ f"{self.instances.orchestrator.session_name}:{pane_id}"
1430
+ )
1431
+
1432
+ # Skip if this pane is tracked
1433
+ if session_pane_target in tracked_panes:
1434
+ continue
1435
+
1436
+ orphan_panes.append((session_pane_target, pane["path"]))
1437
+
1438
+ if not orphan_panes:
1439
+ self._print("No orphan panes found.")
1440
+ return
1441
+
1442
+ # Display orphan panes
1443
+ self._print(f"Found {len(orphan_panes)} orphan pane(s):")
1444
+ for target, path in orphan_panes:
1445
+ self._print(f" - {target} ({path})")
1446
+
1447
+ if force_kill:
1448
+ # Kill orphan panes
1449
+ killed_count = 0
1450
+ for target, path in orphan_panes:
1451
+ try:
1452
+ self.instances.orchestrator.kill_pane(target)
1453
+ killed_count += 1
1454
+ self._print(f" Killed: {target}")
1455
+ except Exception as e:
1456
+ self._print(f" Error killing {target}: {e}")
1457
+
1458
+ self._print(f"\nCleaned up {killed_count} orphan pane(s).")
1459
+ else:
1460
+ self._print("\nUse '/cleanup --force' to remove these panes.")
1461
+
1462
+ def _print_token_status(
1463
+ self, name: str, status: dict, stored: bool = False
1464
+ ) -> None:
1465
+ """Print token status information.
1466
+
1467
+ Args:
1468
+ name: Service name.
1469
+ status: Status dict with token info.
1470
+ stored: Whether tokens are stored.
1471
+ """
1472
+ self._print(f"OAuth Status for '{name}':")
1473
+ self._print(f" Stored: {'Yes' if stored else 'No'}")
1474
+
1475
+ if status.get("valid"):
1476
+ self._print(" Status: Valid")
1477
+ else:
1478
+ self._print(" Status: Invalid/Expired")
1479
+
1480
+ if status.get("expires_at"):
1481
+ self._print(f" Expires: {status['expires_at']}")
1482
+
1483
+ if status.get("scopes"):
1484
+ self._print(f" Scopes: {', '.join(status['scopes'])}")
1485
+
1486
+ # Helper methods for LLM-extracted arguments
1487
+
1488
+ async def _cmd_register_from_args(self, args: dict) -> None:
1489
+ """Handle register command from LLM-extracted args.
1490
+
1491
+ Args:
1492
+ args: Dict with optional 'path', 'framework', 'name' keys.
1493
+ """
1494
+ path = args.get("path")
1495
+ framework = args.get("framework")
1496
+ name = args.get("name")
1497
+
1498
+ if not all([path, framework, name]):
1499
+ self._print("I need the path, framework, and name to register an instance.")
1500
+ self._print("Example: 'register ~/myproject as myapp using mpm'")
1501
+ return
1502
+
1503
+ await self._cmd_register([path, framework, name])
1504
+
1505
+ async def _cmd_start_from_args(self, args: dict) -> None:
1506
+ """Handle start command from LLM-extracted args.
1507
+
1508
+ Args:
1509
+ args: Dict with optional 'name' key.
1510
+ """
1511
+ name = args.get("name")
1512
+ if not name:
1513
+ # Try to infer from connected instance or list available
1514
+ instances = self.instances.list_instances()
1515
+ if len(instances) == 1:
1516
+ name = instances[0].name
1517
+ else:
1518
+ self._print("Which instance should I start?")
1519
+ await self._cmd_list([])
1520
+ return
1521
+
1522
+ await self._cmd_start([name])
1523
+
1524
+ async def _cmd_stop_from_args(self, args: dict) -> None:
1525
+ """Handle stop command from LLM-extracted args.
1526
+
1527
+ Args:
1528
+ args: Dict with optional 'name' key.
1529
+ """
1530
+ name = args.get("name")
1531
+ if not name:
1532
+ # Try to use connected instance
1533
+ if self.session.context.is_connected:
1534
+ name = self.session.context.connected_instance
1535
+ else:
1536
+ self._print("Which instance should I stop?")
1537
+ await self._cmd_list([])
1538
+ return
1539
+
1540
+ await self._cmd_stop([name])
1541
+
1542
+ async def _cmd_connect_from_args(self, args: dict) -> None:
1543
+ """Handle connect command from LLM-extracted args.
1544
+
1545
+ Args:
1546
+ args: Dict with optional 'name' key.
1547
+ """
1548
+ name = args.get("name")
1549
+ if not name:
1550
+ instances = self.instances.list_instances()
1551
+ if len(instances) == 1:
1552
+ name = instances[0].name
1553
+ else:
1554
+ self._print("Which instance should I connect to?")
1555
+ await self._cmd_list([])
1556
+ return
1557
+
1558
+ await self._cmd_connect([name])
1559
+
1560
+ async def _cmd_message_instance(self, target: str, message: str) -> None:
1561
+ """Send message to specific instance without connecting (non-blocking).
1562
+
1563
+ Enqueues the request and returns immediately. Response will appear
1564
+ above the prompt when it arrives.
1565
+
1566
+ Args:
1567
+ target: Instance name to message.
1568
+ message: Message to send.
1569
+ """
1570
+ # Check if instance exists
1571
+ inst = self.instances.get_instance(target)
1572
+ if not inst:
1573
+ # Try to start if registered
1574
+ try:
1575
+ inst = await self.instances.start_by_name(target)
1576
+ if inst:
1577
+ # Spawn background startup task (non-blocking)
1578
+ self._spawn_startup_task(target, auto_connect=False, timeout=30)
1579
+ self._print(
1580
+ f"Starting '{target}'... message will be sent when ready"
1581
+ )
1582
+ except Exception:
1583
+ inst = None
1584
+
1585
+ if not inst:
1586
+ self._print(
1587
+ f"Instance '{target}' not found. Use /list to see instances."
1588
+ )
1589
+ return
1590
+
1591
+ # Create and enqueue request (non-blocking)
1592
+ request = PendingRequest(
1593
+ id=str(uuid.uuid4())[:8],
1594
+ target=target,
1595
+ message=message,
1596
+ )
1597
+ self._pending_requests[request.id] = request
1598
+ await self._request_queue.put(request)
1599
+
1600
+ # Return immediately - response will be handled by _process_responses
1601
+
1602
+ def _display_response(self, instance_name: str, response: str) -> None:
1603
+ """Display response from instance above prompt.
1604
+
1605
+ Args:
1606
+ instance_name: Name of the instance that responded.
1607
+ response: Response content.
1608
+ """
1609
+ # Summarize if too long
1610
+ summary = response[:100] + "..." if len(response) > 100 else response
1611
+ summary = summary.replace("\n", " ")
1612
+ print(f"\n@{instance_name}: {summary}")
1613
+
254
1614
  async def _send_to_instance(self, message: str) -> None:
255
- """Send natural language to connected instance.
1615
+ """Send natural language to connected instance (non-blocking).
1616
+
1617
+ Enqueues the request and returns immediately. Response will appear
1618
+ above the prompt when it arrives.
256
1619
 
257
1620
  Args:
258
1621
  message: User message to send.
259
1622
  """
1623
+ # Check if instance is connected and ready
260
1624
  if not self.session.context.is_connected:
261
1625
  self._print("Not connected to any instance. Use 'connect <name>' first.")
262
1626
  return
@@ -268,29 +1632,148 @@ Examples:
268
1632
  self.session.disconnect()
269
1633
  return
270
1634
 
271
- self._print(f"[Sending to {name}...]")
272
- await self.instances.send_to_instance(name, message)
1635
+ # Create and enqueue request (non-blocking)
1636
+ request = PendingRequest(
1637
+ id=str(uuid.uuid4())[:8],
1638
+ target=name,
1639
+ message=message,
1640
+ )
1641
+ self._pending_requests[request.id] = request
1642
+ await self._request_queue.put(request)
273
1643
  self.session.add_user_message(message)
274
1644
 
275
- # Wait for and display response
276
- if self.relay:
1645
+ # Return immediately - response will be handled by _process_responses
1646
+
1647
+ async def _process_responses(self) -> None:
1648
+ """Background task that processes queued requests and waits for responses."""
1649
+ while self._running:
277
1650
  try:
278
- output = await self.relay.get_latest_output(
279
- name, inst.pane_target, context=message
280
- )
281
- self._print(f"\n[Response from {name}]:\n{output}")
282
- self.session.add_assistant_message(output)
1651
+ # Get next request from queue (with timeout to allow checking _running)
1652
+ try:
1653
+ request = await asyncio.wait_for(
1654
+ self._request_queue.get(), timeout=0.5
1655
+ )
1656
+ except asyncio.TimeoutError:
1657
+ continue
1658
+
1659
+ # Update status and send to instance
1660
+ request.status = RequestStatus.SENDING
1661
+ self._render_pending_status()
1662
+
1663
+ inst = self.instances.get_instance(request.target)
1664
+ if not inst:
1665
+ request.status = RequestStatus.ERROR
1666
+ request.error = f"Instance '{request.target}' no longer exists"
1667
+ print(f"\n[{request.target}] {request.error}")
1668
+ continue
1669
+
1670
+ # Send to instance
1671
+ await self.instances.send_to_instance(request.target, request.message)
1672
+ request.status = RequestStatus.WAITING
1673
+ self._render_pending_status()
1674
+
1675
+ # Give tmux time to process the message and produce output
1676
+ await asyncio.sleep(0.2)
1677
+
1678
+ # Wait for response
1679
+ if self.relay:
1680
+ try:
1681
+ output = await self.relay.get_latest_output(
1682
+ request.target, inst.pane_target, context=request.message
1683
+ )
1684
+ request.status = RequestStatus.COMPLETED
1685
+ request.response = output
1686
+
1687
+ # Display response above prompt
1688
+ self._display_response(request.target, output)
1689
+ self.session.add_assistant_message(output)
1690
+ except Exception as e:
1691
+ request.status = RequestStatus.ERROR
1692
+ request.error = str(e)
1693
+ print(f"\n[{request.target}] Error: {e}")
1694
+ else:
1695
+ # No relay available, simple send without response capture
1696
+ request.status = RequestStatus.COMPLETED
1697
+ print(f"\n[{request.target}] Message sent (no relay for response)")
1698
+
1699
+ # Remove from pending after a short delay
1700
+ await asyncio.sleep(0.5)
1701
+ self._pending_requests.pop(request.id, None)
1702
+
1703
+ except asyncio.CancelledError:
1704
+ break
283
1705
  except Exception as e:
284
- self._print(f"\n[Error getting response: {e}]")
1706
+ print(f"\nResponse processor error: {e}")
1707
+
1708
+ def _render_pending_status(self) -> None:
1709
+ """Render pending request status above the prompt."""
1710
+ pending = [
1711
+ r
1712
+ for r in self._pending_requests.values()
1713
+ if r.status not in (RequestStatus.COMPLETED, RequestStatus.ERROR)
1714
+ ]
1715
+ if not pending:
1716
+ return
1717
+
1718
+ # Build status line
1719
+ status_parts = []
1720
+ for req in pending:
1721
+ elapsed = req.elapsed_seconds()
1722
+ status_indicator = {
1723
+ RequestStatus.QUEUED: "...",
1724
+ RequestStatus.SENDING: ">>>",
1725
+ RequestStatus.WAITING: "...",
1726
+ RequestStatus.STARTING: "...",
1727
+ }.get(req.status, "?")
1728
+ status_parts.append(
1729
+ f"{status_indicator} [{req.target}] {req.display_message(30)} ({elapsed}s)"
1730
+ )
1731
+
1732
+ # Print above prompt (patch_stdout handles cursor positioning)
1733
+ for part in status_parts:
1734
+ print(part)
1735
+
1736
+ def _on_instance_event(self, event: "Event") -> None:
1737
+ """Handle instance lifecycle events with interrupt display.
1738
+
1739
+ Args:
1740
+ event: The event to handle.
1741
+ """
1742
+ if event.type == EventType.INSTANCE_STARTING:
1743
+ print(f"\n[Starting] {event.title}")
1744
+ elif event.type == EventType.INSTANCE_READY:
1745
+ metadata = event.context or {}
1746
+ instance_name = metadata.get("instance_name", "")
1747
+
1748
+ # Mark instance as ready
1749
+ if instance_name:
1750
+ self._instance_ready[instance_name] = True
1751
+
1752
+ if metadata.get("timeout"):
1753
+ print(f"\n[Warning] {event.title} (startup timeout, may still work)")
1754
+ else:
1755
+ print(f"\n[Ready] {event.title}")
1756
+
1757
+ # Show ready notification based on whether this is the connected instance
1758
+ if (
1759
+ instance_name
1760
+ and instance_name == self.session.context.connected_instance
1761
+ ):
1762
+ print(f"\n({instance_name}) ready")
1763
+ elif instance_name:
1764
+ print(f" Use @{instance_name} or /connect {instance_name}")
1765
+ elif event.type == EventType.INSTANCE_ERROR:
1766
+ print(f"\n[Error] {event.title}: {event.content}")
285
1767
 
286
1768
  def _get_prompt(self) -> str:
287
- """Get prompt string based on connection state.
1769
+ """Get prompt string.
288
1770
 
289
1771
  Returns:
290
- Prompt string for input.
1772
+ Prompt string for input, showing instance name when connected.
291
1773
  """
292
- if self.session.context.is_connected:
293
- return f"Commander ({self.session.context.connected_instance})> "
1774
+ connected = self.session.context.connected_instance
1775
+ if connected:
1776
+ return f"Commander ({connected})> "
294
1777
  return "Commander> "
295
1778
 
296
1779
  def _print(self, msg: str) -> None:
@@ -301,10 +1784,174 @@ Examples:
301
1784
  """
302
1785
  print(msg)
303
1786
 
1787
+ def _spawn_startup_task(
1788
+ self, name: str, auto_connect: bool = True, timeout: int = 30
1789
+ ) -> None:
1790
+ """Spawn a background task to wait for instance ready.
1791
+
1792
+ This returns immediately - the wait happens in the background.
1793
+ Prints status when starting and when complete.
1794
+
1795
+ Args:
1796
+ name: Instance name to wait for
1797
+ auto_connect: Whether to auto-connect when ready
1798
+ timeout: Maximum seconds to wait
1799
+ """
1800
+ # Print starting message (once)
1801
+ print(f"Waiting for '{name}' to be ready...")
1802
+
1803
+ # Spawn background task
1804
+ task = asyncio.create_task(
1805
+ self._wait_for_ready_background(name, auto_connect, timeout)
1806
+ )
1807
+ self._startup_tasks[name] = task
1808
+
1809
+ async def _wait_for_ready_background(
1810
+ self, name: str, auto_connect: bool, timeout: int
1811
+ ) -> None:
1812
+ """Background task that waits for instance ready.
1813
+
1814
+ Updates bottom toolbar with spinner animation, then prints result when done.
1815
+
1816
+ Args:
1817
+ name: Instance name to wait for
1818
+ auto_connect: Whether to auto-connect when ready
1819
+ timeout: Maximum seconds to wait
1820
+ """
1821
+ elapsed = 0.0
1822
+ interval = 0.1 # Update spinner every 100ms
1823
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1824
+ frame_idx = 0
1825
+
1826
+ try:
1827
+ while elapsed < timeout:
1828
+ inst = self.instances.get_instance(name)
1829
+ if inst and inst.ready:
1830
+ # Clear toolbar and print success
1831
+ self._toolbar_status = ""
1832
+ if self.prompt_session:
1833
+ self.prompt_session.app.invalidate()
1834
+ print(f"'{name}' ready ({int(elapsed)}s)")
1835
+
1836
+ if auto_connect:
1837
+ self.session.connect_to(name)
1838
+ print(f" Connected to '{name}'")
1839
+
1840
+ # Cleanup
1841
+ self._startup_tasks.pop(name, None)
1842
+ return
1843
+
1844
+ # Update toolbar with spinner frame
1845
+ frame = spinner_frames[frame_idx % len(spinner_frames)]
1846
+ self._toolbar_status = (
1847
+ f"{frame} Waiting for '{name}'... ({int(elapsed)}s)"
1848
+ )
1849
+ if self.prompt_session:
1850
+ self.prompt_session.app.invalidate()
1851
+ frame_idx += 1
1852
+
1853
+ await asyncio.sleep(interval)
1854
+ elapsed += interval
1855
+
1856
+ # Timeout - clear toolbar and show warning
1857
+ self._toolbar_status = ""
1858
+ if self.prompt_session:
1859
+ self.prompt_session.app.invalidate()
1860
+ print(f"'{name}' startup timeout ({timeout}s) - may still work")
1861
+
1862
+ # Still auto-connect on timeout (instance may become ready later)
1863
+ if auto_connect:
1864
+ self.session.connect_to(name)
1865
+ print(f" Connected to '{name}' (may not be fully ready)")
1866
+
1867
+ # Cleanup
1868
+ self._startup_tasks.pop(name, None)
1869
+
1870
+ except asyncio.CancelledError:
1871
+ self._toolbar_status = ""
1872
+ self._startup_tasks.pop(name, None)
1873
+ except Exception as e:
1874
+ self._toolbar_status = ""
1875
+ print(f"'{name}' startup error: {e}")
1876
+ self._startup_tasks.pop(name, None)
1877
+
1878
+ async def _wait_for_ready_with_spinner(self, name: str, timeout: int = 30) -> bool:
1879
+ """Wait for instance to be ready with animated spinner (BLOCKING).
1880
+
1881
+ NOTE: This method blocks. For non-blocking, use _spawn_startup_task().
1882
+
1883
+ Shows an animated waiting indicator that updates in place.
1884
+
1885
+ Args:
1886
+ name: Instance name to wait for
1887
+ timeout: Maximum seconds to wait
1888
+
1889
+ Returns:
1890
+ True if instance became ready, False on timeout
1891
+ """
1892
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1893
+ frame_idx = 0
1894
+ elapsed = 0.0
1895
+ interval = 0.1 # Update spinner every 100ms
1896
+
1897
+ while elapsed < timeout:
1898
+ inst = self.instances.get_instance(name)
1899
+ if inst and inst.ready:
1900
+ # Clear spinner line and show success
1901
+ sys.stdout.write(f"\r\033[K'{name}' ready\n")
1902
+ sys.stdout.flush()
1903
+ return True
1904
+
1905
+ # Show spinner with elapsed time
1906
+ frame = spinner_frames[frame_idx % len(spinner_frames)]
1907
+ sys.stdout.write(
1908
+ f"\r{frame} Waiting for '{name}' to be ready... ({int(elapsed)}s)"
1909
+ )
1910
+ sys.stdout.flush()
1911
+
1912
+ await asyncio.sleep(interval)
1913
+ elapsed += interval
1914
+ frame_idx += 1
1915
+
1916
+ # Timeout - clear spinner and show warning
1917
+ sys.stdout.write(f"\r\033[K'{name}' startup timeout (may still work)\n")
1918
+ sys.stdout.flush()
1919
+ return False
1920
+
304
1921
  def _print_welcome(self) -> None:
305
1922
  """Print welcome message."""
306
1923
  print("╔══════════════════════════════════════════╗")
307
1924
  print("║ MPM Commander - Interactive Mode ║")
308
1925
  print("╚══════════════════════════════════════════╝")
309
- print("Type 'help' for commands, or natural language to chat.")
1926
+ print("Type '/help' for commands, or natural language to chat.")
310
1927
  print()
1928
+
1929
+ def _get_instance_names(self) -> list[str]:
1930
+ """Get list of instance names for autocomplete.
1931
+
1932
+ Returns:
1933
+ List of instance names (running and registered).
1934
+ """
1935
+ names: list[str] = []
1936
+
1937
+ # Running instances
1938
+ if self.instances:
1939
+ try:
1940
+ for inst in self.instances.list_instances():
1941
+ if inst.name not in names:
1942
+ names.append(inst.name)
1943
+ except Exception: # nosec B110 - Graceful fallback
1944
+ pass
1945
+
1946
+ # Registered instances from state store
1947
+ if self.instances and hasattr(self.instances, "_state_store"):
1948
+ try:
1949
+ state_store = self.instances._state_store
1950
+ if state_store:
1951
+ for name in state_store.load_instances():
1952
+ if name not in names:
1953
+ names.append(name)
1954
+ except Exception: # nosec B110 - Graceful fallback
1955
+ pass
1956
+
1957
+ return names