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