claude-mpm 5.4.85__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 (322) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/CLAUDE_MPM_OUTPUT_STYLE.md +8 -5
  3. claude_mpm/agents/{CLAUDE_MPM_FOUNDERS_OUTPUT_STYLE.md → CLAUDE_MPM_RESEARCH_OUTPUT_STYLE.md} +14 -6
  4. claude_mpm/agents/PM_INSTRUCTIONS.md +109 -706
  5. claude_mpm/agents/WORKFLOW.md +2 -0
  6. claude_mpm/agents/templates/circuit-breakers.md +26 -17
  7. claude_mpm/auth/__init__.py +35 -0
  8. claude_mpm/auth/callback_server.py +328 -0
  9. claude_mpm/auth/models.py +104 -0
  10. claude_mpm/auth/oauth_manager.py +266 -0
  11. claude_mpm/auth/providers/__init__.py +12 -0
  12. claude_mpm/auth/providers/base.py +165 -0
  13. claude_mpm/auth/providers/google.py +261 -0
  14. claude_mpm/auth/token_storage.py +252 -0
  15. claude_mpm/cli/commands/autotodos.py +566 -0
  16. claude_mpm/cli/commands/commander.py +216 -0
  17. claude_mpm/cli/commands/hook_errors.py +60 -60
  18. claude_mpm/cli/commands/mcp.py +29 -17
  19. claude_mpm/cli/commands/mcp_command_router.py +39 -0
  20. claude_mpm/cli/commands/mcp_service_commands.py +304 -0
  21. claude_mpm/cli/commands/monitor.py +2 -2
  22. claude_mpm/cli/commands/mpm_init/core.py +2 -2
  23. claude_mpm/cli/commands/oauth.py +481 -0
  24. claude_mpm/cli/commands/run.py +35 -3
  25. claude_mpm/cli/commands/skill_source.py +51 -2
  26. claude_mpm/cli/commands/skills.py +5 -3
  27. claude_mpm/cli/executor.py +128 -16
  28. claude_mpm/cli/helpers.py +1 -1
  29. claude_mpm/cli/parsers/base_parser.py +84 -1
  30. claude_mpm/cli/parsers/commander_parser.py +116 -0
  31. claude_mpm/cli/parsers/mcp_parser.py +79 -0
  32. claude_mpm/cli/parsers/oauth_parser.py +165 -0
  33. claude_mpm/cli/parsers/run_parser.py +10 -0
  34. claude_mpm/cli/parsers/skill_source_parser.py +4 -0
  35. claude_mpm/cli/parsers/skills_parser.py +5 -0
  36. claude_mpm/cli/startup.py +345 -40
  37. claude_mpm/cli/startup_display.py +76 -7
  38. claude_mpm/cli/startup_logging.py +2 -2
  39. claude_mpm/cli/startup_migrations.py +236 -0
  40. claude_mpm/cli/utils.py +7 -3
  41. claude_mpm/commander/__init__.py +78 -0
  42. claude_mpm/commander/adapters/__init__.py +60 -0
  43. claude_mpm/commander/adapters/auggie.py +260 -0
  44. claude_mpm/commander/adapters/base.py +288 -0
  45. claude_mpm/commander/adapters/claude_code.py +392 -0
  46. claude_mpm/commander/adapters/codex.py +237 -0
  47. claude_mpm/commander/adapters/communication.py +366 -0
  48. claude_mpm/commander/adapters/example_usage.py +310 -0
  49. claude_mpm/commander/adapters/mpm.py +389 -0
  50. claude_mpm/commander/adapters/registry.py +204 -0
  51. claude_mpm/commander/api/__init__.py +16 -0
  52. claude_mpm/commander/api/app.py +121 -0
  53. claude_mpm/commander/api/errors.py +133 -0
  54. claude_mpm/commander/api/routes/__init__.py +8 -0
  55. claude_mpm/commander/api/routes/events.py +184 -0
  56. claude_mpm/commander/api/routes/inbox.py +171 -0
  57. claude_mpm/commander/api/routes/messages.py +148 -0
  58. claude_mpm/commander/api/routes/projects.py +271 -0
  59. claude_mpm/commander/api/routes/sessions.py +226 -0
  60. claude_mpm/commander/api/routes/work.py +296 -0
  61. claude_mpm/commander/api/schemas.py +186 -0
  62. claude_mpm/commander/chat/__init__.py +7 -0
  63. claude_mpm/commander/chat/cli.py +149 -0
  64. claude_mpm/commander/chat/commands.py +124 -0
  65. claude_mpm/commander/chat/repl.py +1957 -0
  66. claude_mpm/commander/config.py +51 -0
  67. claude_mpm/commander/config_loader.py +115 -0
  68. claude_mpm/commander/core/__init__.py +10 -0
  69. claude_mpm/commander/core/block_manager.py +325 -0
  70. claude_mpm/commander/core/response_manager.py +323 -0
  71. claude_mpm/commander/daemon.py +603 -0
  72. claude_mpm/commander/env_loader.py +59 -0
  73. claude_mpm/commander/events/__init__.py +26 -0
  74. claude_mpm/commander/events/manager.py +392 -0
  75. claude_mpm/commander/frameworks/__init__.py +12 -0
  76. claude_mpm/commander/frameworks/base.py +233 -0
  77. claude_mpm/commander/frameworks/claude_code.py +58 -0
  78. claude_mpm/commander/frameworks/mpm.py +57 -0
  79. claude_mpm/commander/git/__init__.py +5 -0
  80. claude_mpm/commander/git/worktree_manager.py +212 -0
  81. claude_mpm/commander/inbox/__init__.py +16 -0
  82. claude_mpm/commander/inbox/dedup.py +128 -0
  83. claude_mpm/commander/inbox/inbox.py +224 -0
  84. claude_mpm/commander/inbox/models.py +70 -0
  85. claude_mpm/commander/instance_manager.py +868 -0
  86. claude_mpm/commander/llm/__init__.py +6 -0
  87. claude_mpm/commander/llm/openrouter_client.py +167 -0
  88. claude_mpm/commander/llm/summarizer.py +70 -0
  89. claude_mpm/commander/memory/__init__.py +45 -0
  90. claude_mpm/commander/memory/compression.py +347 -0
  91. claude_mpm/commander/memory/embeddings.py +230 -0
  92. claude_mpm/commander/memory/entities.py +310 -0
  93. claude_mpm/commander/memory/example_usage.py +290 -0
  94. claude_mpm/commander/memory/integration.py +325 -0
  95. claude_mpm/commander/memory/search.py +381 -0
  96. claude_mpm/commander/memory/store.py +657 -0
  97. claude_mpm/commander/models/__init__.py +18 -0
  98. claude_mpm/commander/models/events.py +127 -0
  99. claude_mpm/commander/models/project.py +162 -0
  100. claude_mpm/commander/models/work.py +214 -0
  101. claude_mpm/commander/parsing/__init__.py +20 -0
  102. claude_mpm/commander/parsing/extractor.py +132 -0
  103. claude_mpm/commander/parsing/output_parser.py +270 -0
  104. claude_mpm/commander/parsing/patterns.py +100 -0
  105. claude_mpm/commander/persistence/__init__.py +11 -0
  106. claude_mpm/commander/persistence/event_store.py +274 -0
  107. claude_mpm/commander/persistence/state_store.py +403 -0
  108. claude_mpm/commander/persistence/work_store.py +164 -0
  109. claude_mpm/commander/polling/__init__.py +13 -0
  110. claude_mpm/commander/polling/event_detector.py +104 -0
  111. claude_mpm/commander/polling/output_buffer.py +49 -0
  112. claude_mpm/commander/polling/output_poller.py +153 -0
  113. claude_mpm/commander/project_session.py +268 -0
  114. claude_mpm/commander/proxy/__init__.py +12 -0
  115. claude_mpm/commander/proxy/formatter.py +89 -0
  116. claude_mpm/commander/proxy/output_handler.py +191 -0
  117. claude_mpm/commander/proxy/relay.py +155 -0
  118. claude_mpm/commander/registry.py +410 -0
  119. claude_mpm/commander/runtime/__init__.py +10 -0
  120. claude_mpm/commander/runtime/executor.py +191 -0
  121. claude_mpm/commander/runtime/monitor.py +346 -0
  122. claude_mpm/commander/session/__init__.py +6 -0
  123. claude_mpm/commander/session/context.py +81 -0
  124. claude_mpm/commander/session/manager.py +59 -0
  125. claude_mpm/commander/tmux_orchestrator.py +362 -0
  126. claude_mpm/commander/web/__init__.py +1 -0
  127. claude_mpm/commander/work/__init__.py +30 -0
  128. claude_mpm/commander/work/executor.py +207 -0
  129. claude_mpm/commander/work/queue.py +405 -0
  130. claude_mpm/commander/workflow/__init__.py +27 -0
  131. claude_mpm/commander/workflow/event_handler.py +241 -0
  132. claude_mpm/commander/workflow/notifier.py +146 -0
  133. claude_mpm/commands/mpm-config.md +8 -0
  134. claude_mpm/commands/mpm-doctor.md +8 -0
  135. claude_mpm/commands/mpm-help.md +8 -0
  136. claude_mpm/commands/mpm-init.md +8 -0
  137. claude_mpm/commands/mpm-monitor.md +8 -0
  138. claude_mpm/commands/mpm-organize.md +8 -0
  139. claude_mpm/commands/mpm-postmortem.md +8 -0
  140. claude_mpm/commands/mpm-session-resume.md +9 -1
  141. claude_mpm/commands/mpm-status.md +8 -0
  142. claude_mpm/commands/mpm-ticket-view.md +8 -0
  143. claude_mpm/commands/mpm-version.md +8 -0
  144. claude_mpm/commands/mpm.md +8 -0
  145. claude_mpm/config/agent_presets.py +8 -7
  146. claude_mpm/config/skill_sources.py +16 -0
  147. claude_mpm/constants.py +5 -0
  148. claude_mpm/core/claude_runner.py +152 -0
  149. claude_mpm/core/config.py +35 -22
  150. claude_mpm/core/config_constants.py +74 -9
  151. claude_mpm/core/constants.py +56 -12
  152. claude_mpm/core/hook_manager.py +53 -4
  153. claude_mpm/core/interactive_session.py +5 -4
  154. claude_mpm/core/logger.py +26 -9
  155. claude_mpm/core/logging_utils.py +39 -13
  156. claude_mpm/core/network_config.py +148 -0
  157. claude_mpm/core/oneshot_session.py +7 -6
  158. claude_mpm/core/output_style_manager.py +52 -12
  159. claude_mpm/core/socketio_pool.py +47 -15
  160. claude_mpm/core/unified_config.py +10 -6
  161. claude_mpm/core/unified_paths.py +68 -80
  162. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.C33zOoyM.css +1 -0
  163. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.CW1J-YuA.css +1 -0
  164. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Cs_tUR18.js → 1WZnGYqX.js} +1 -1
  165. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CDuw-vjf.js → 67pF3qNn.js} +1 -1
  166. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{bTOqqlTd.js → 6RxdMKe4.js} +1 -1
  167. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DwBR2MJi.js → 8cZrfX0h.js} +1 -1
  168. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{ZGh7QtNv.js → 9a6T2nm-.js} +1 -1
  169. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{D9lljYKQ.js → B443AUzu.js} +1 -1
  170. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{RJiighC3.js → B8AwtY2H.js} +1 -1
  171. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{uuIeMWc-.js → BF15LAsF.js} +1 -1
  172. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{D3k0OPJN.js → BRcwIQNr.js} +1 -1
  173. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CyWMqx4W.js → BV6nKitt.js} +1 -1
  174. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CiIAseT4.js → BViJ8lZt.js} +5 -5
  175. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CBBdVcY8.js → BcQ-Q0FE.js} +1 -1
  176. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BovzEFCE.js → Bpyvgze_.js} +1 -1
  177. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BzTRqg-z.js +1 -0
  178. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C0Fr8dve.js +1 -0
  179. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{eNVUfhuA.js → C3rbW_a-.js} +1 -1
  180. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{GYwsonyD.js → C8WYN38h.js} +1 -1
  181. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BIF9m_hv.js → C9I8FlXH.js} +1 -1
  182. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B0uc0UOD.js → CIQcWgO2.js} +3 -3
  183. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Be7GpZd6.js → CIctN7YN.js} +1 -1
  184. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Bh0LDWpI.js → CKrS_JZW.js} +2 -2
  185. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DUrLdbGD.js → CR6P9C4A.js} +1 -1
  186. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B7xVLGWV.js → CRRR9MD_.js} +1 -1
  187. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CRcR2DqT.js +334 -0
  188. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Dhb8PKl3.js → CSXtMOf0.js} +1 -1
  189. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BPYeabCQ.js → CT-sbxSk.js} +1 -1
  190. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{sQeU3Y1z.js → CWm6DJsp.js} +1 -1
  191. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CnA0NrzZ.js → CpqQ1Kzn.js} +1 -1
  192. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C4B-KCzX.js → D2nGpDRe.js} +1 -1
  193. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DGkLK5U1.js → D9iCMida.js} +1 -1
  194. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{BofRWZRR.js → D9ykgMoY.js} +1 -1
  195. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DmxopI1J.js → DL2Ldur1.js} +1 -1
  196. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C30mlcqg.js → DPfltzjH.js} +1 -1
  197. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Vzk33B_K.js → DR8nis88.js} +2 -2
  198. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DI7hHRFL.js → DUliQN2b.js} +1 -1
  199. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C4JcI4KD.js → DXlhR01x.js} +1 -1
  200. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{bT1r9zLR.js → D_lyTybS.js} +1 -1
  201. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DZX00Y4g.js → DngoTTgh.js} +1 -1
  202. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CzZX-COe.js → DqkmHtDC.js} +1 -1
  203. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{B7RN905-.js → DsDh8EYs.js} +1 -1
  204. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DLVjFsZ3.js → DypDmXgd.js} +1 -1
  205. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{iEWssX7S.js → IPYC-LnN.js} +1 -1
  206. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/JTLiF7dt.js +24 -0
  207. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DaimHw_p.js → JpevfAFt.js} +1 -1
  208. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DY1XQ8fi.js → R8CEIRAd.js} +1 -1
  209. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Dle-35c7.js → Zxy7qc-l.js} +2 -2
  210. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/q9Hm6zAU.js +1 -0
  211. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{C_Usid8X.js → qtd3IeO4.js} +2 -2
  212. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CzeYkLYB.js → ulBFON_C.js} +2 -2
  213. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{Cfqx1Qun.js → wQVh1CoA.js} +1 -1
  214. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/{app.D6-I5TpK.js → app.Dr7t0z2J.js} +2 -2
  215. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.BGhZHUS3.js +1 -0
  216. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{0.m1gL8KXf.js → 0.RgBboRvH.js} +1 -1
  217. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{1.CgNOuw-d.js → 1.DG-KkbDf.js} +1 -1
  218. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.D_jnf-x6.js +1 -0
  219. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -1
  220. claude_mpm/dashboard/static/svelte-build/index.html +9 -9
  221. claude_mpm/experimental/cli_enhancements.py +2 -1
  222. claude_mpm/hooks/claude_hooks/INTEGRATION_EXAMPLE.md +243 -0
  223. claude_mpm/hooks/claude_hooks/README_AUTO_PAUSE.md +403 -0
  224. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +485 -0
  225. claude_mpm/hooks/claude_hooks/event_handlers.py +466 -136
  226. claude_mpm/hooks/claude_hooks/hook_handler.py +204 -104
  227. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +6 -11
  228. claude_mpm/hooks/claude_hooks/installer.py +291 -59
  229. claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
  230. claude_mpm/hooks/claude_hooks/response_tracking.py +43 -60
  231. claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
  232. claude_mpm/hooks/claude_hooks/services/connection_manager.py +41 -26
  233. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +38 -105
  234. claude_mpm/hooks/claude_hooks/services/container.py +326 -0
  235. claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
  236. claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
  237. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +75 -77
  238. claude_mpm/hooks/session_resume_hook.py +89 -1
  239. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  240. claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
  241. claude_mpm/init.py +22 -15
  242. claude_mpm/mcp/__init__.py +9 -0
  243. claude_mpm/mcp/google_workspace_server.py +610 -0
  244. claude_mpm/scripts/claude-hook-handler.sh +46 -19
  245. claude_mpm/services/agents/agent_recommendation_service.py +8 -8
  246. claude_mpm/services/agents/agent_selection_service.py +2 -2
  247. claude_mpm/services/agents/cache_git_manager.py +1 -1
  248. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +3 -0
  249. claude_mpm/services/agents/loading/framework_agent_loader.py +75 -2
  250. claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
  251. claude_mpm/services/cli/__init__.py +3 -0
  252. claude_mpm/services/cli/incremental_pause_manager.py +561 -0
  253. claude_mpm/services/cli/session_resume_helper.py +10 -2
  254. claude_mpm/services/command_deployment_service.py +44 -26
  255. claude_mpm/services/delegation_detector.py +175 -0
  256. claude_mpm/services/diagnostics/checks/agent_sources_check.py +30 -0
  257. claude_mpm/services/diagnostics/checks/configuration_check.py +24 -0
  258. claude_mpm/services/diagnostics/checks/installation_check.py +22 -0
  259. claude_mpm/services/diagnostics/checks/mcp_services_check.py +23 -0
  260. claude_mpm/services/diagnostics/doctor_reporter.py +31 -1
  261. claude_mpm/services/diagnostics/models.py +14 -1
  262. claude_mpm/services/event_log.py +325 -0
  263. claude_mpm/services/hook_installer_service.py +77 -8
  264. claude_mpm/services/infrastructure/__init__.py +4 -0
  265. claude_mpm/services/infrastructure/context_usage_tracker.py +291 -0
  266. claude_mpm/services/infrastructure/resume_log_generator.py +24 -5
  267. claude_mpm/services/mcp_config_manager.py +99 -19
  268. claude_mpm/services/mcp_service_registry.py +294 -0
  269. claude_mpm/services/monitor/daemon_manager.py +15 -4
  270. claude_mpm/services/monitor/management/lifecycle.py +8 -2
  271. claude_mpm/services/monitor/server.py +111 -16
  272. claude_mpm/services/pm_skills_deployer.py +261 -87
  273. claude_mpm/services/skills/git_skill_source_manager.py +130 -10
  274. claude_mpm/services/skills/selective_skill_deployer.py +142 -16
  275. claude_mpm/services/skills/skill_discovery_service.py +74 -4
  276. claude_mpm/services/skills_deployer.py +31 -5
  277. claude_mpm/services/socketio/handlers/hook.py +14 -7
  278. claude_mpm/services/socketio/server/main.py +12 -4
  279. claude_mpm/skills/__init__.py +2 -1
  280. claude_mpm/skills/bundled/pm/mpm/SKILL.md +38 -0
  281. claude_mpm/skills/bundled/pm/mpm-agent-update-workflow/SKILL.md +75 -0
  282. claude_mpm/skills/bundled/pm/mpm-circuit-breaker-enforcement/SKILL.md +476 -0
  283. claude_mpm/skills/bundled/pm/mpm-config/SKILL.md +29 -0
  284. claude_mpm/skills/bundled/pm/mpm-doctor/SKILL.md +53 -0
  285. claude_mpm/skills/bundled/pm/mpm-help/SKILL.md +35 -0
  286. claude_mpm/skills/bundled/pm/mpm-init/SKILL.md +125 -0
  287. claude_mpm/skills/bundled/pm/mpm-monitor/SKILL.md +32 -0
  288. claude_mpm/skills/bundled/pm/mpm-organize/SKILL.md +121 -0
  289. claude_mpm/skills/bundled/pm/mpm-postmortem/SKILL.md +22 -0
  290. claude_mpm/skills/bundled/pm/mpm-session-management/SKILL.md +312 -0
  291. claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
  292. claude_mpm/skills/bundled/pm/mpm-session-resume/SKILL.md +31 -0
  293. claude_mpm/skills/bundled/pm/mpm-status/SKILL.md +37 -0
  294. claude_mpm/skills/bundled/pm/{pm-teaching-mode → mpm-teaching-mode}/SKILL.md +2 -2
  295. claude_mpm/skills/bundled/pm/mpm-ticket-view/SKILL.md +110 -0
  296. claude_mpm/skills/bundled/pm/mpm-tool-usage-guide/SKILL.md +386 -0
  297. claude_mpm/skills/bundled/pm/mpm-version/SKILL.md +21 -0
  298. claude_mpm/skills/registry.py +295 -90
  299. claude_mpm/skills/skill_manager.py +4 -4
  300. claude_mpm-5.6.76.dist-info/METADATA +416 -0
  301. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/RECORD +312 -175
  302. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/WHEEL +1 -1
  303. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/entry_points.txt +2 -0
  304. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.DWzvg0-y.css +0 -1
  305. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.ThTw9_ym.css +0 -1
  306. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/4TdZjIqw.js +0 -1
  307. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/5shd3_w0.js +0 -24
  308. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BKjSRqUr.js +0 -1
  309. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Da0KfYnO.js +0 -1
  310. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Dfy6j1xT.js +0 -323
  311. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.NWzMBYRp.js +0 -1
  312. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.C0GcWctS.js +0 -1
  313. claude_mpm-5.4.85.dist-info/METADATA +0 -1023
  314. /claude_mpm/skills/bundled/pm/{pm-bug-reporting/pm-bug-reporting.md → mpm-bug-reporting/SKILL.md} +0 -0
  315. /claude_mpm/skills/bundled/pm/{pm-delegation-patterns → mpm-delegation-patterns}/SKILL.md +0 -0
  316. /claude_mpm/skills/bundled/pm/{pm-git-file-tracking → mpm-git-file-tracking}/SKILL.md +0 -0
  317. /claude_mpm/skills/bundled/pm/{pm-pr-workflow → mpm-pr-workflow}/SKILL.md +0 -0
  318. /claude_mpm/skills/bundled/pm/{pm-ticketing-integration → mpm-ticketing-integration}/SKILL.md +0 -0
  319. /claude_mpm/skills/bundled/pm/{pm-verification-protocols → mpm-verification-protocols}/SKILL.md +0 -0
  320. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE +0 -0
  321. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  322. {claude_mpm-5.4.85.dist-info → claude_mpm-5.6.76.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1957 @@
1
+ """Commander chat REPL interface."""
2
+
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
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Optional
14
+
15
+ from prompt_toolkit import PromptSession, prompt as pt_prompt
16
+ from prompt_toolkit.completion import Completer, Completion
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
+
94
+
95
+ from claude_mpm.commander.instance_manager import InstanceManager
96
+ from claude_mpm.commander.llm.openrouter_client import OpenRouterClient
97
+ from claude_mpm.commander.models.events import EventType
98
+ from claude_mpm.commander.proxy.relay import OutputRelay
99
+ from claude_mpm.commander.session.manager import SessionManager
100
+
101
+ from .commands import Command, CommandParser, CommandType
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
+
205
+
206
+ class CommanderREPL:
207
+ """Interactive REPL for Commander mode."""
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
+
248
+ def __init__(
249
+ self,
250
+ instance_manager: InstanceManager,
251
+ session_manager: SessionManager,
252
+ output_relay: Optional[OutputRelay] = None,
253
+ llm_client: Optional[OpenRouterClient] = None,
254
+ event_manager: Optional["EventManager"] = None,
255
+ ):
256
+ """Initialize REPL.
257
+
258
+ Args:
259
+ instance_manager: Manages Claude instances.
260
+ session_manager: Manages chat session state.
261
+ output_relay: Optional relay for instance output.
262
+ llm_client: Optional OpenRouter client for chat.
263
+ event_manager: Optional event manager for notifications.
264
+ """
265
+ self.instances = instance_manager
266
+ self.session = session_manager
267
+ self.relay = output_relay
268
+ self.llm = llm_client
269
+ self.event_manager = event_manager
270
+ self.parser = CommandParser()
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
298
+
299
+ async def run(self) -> None:
300
+ """Start the REPL loop."""
301
+ self._running = True
302
+ self._print_welcome()
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
+
320
+ # Setup history file
321
+ history_path = Path.home() / ".claude-mpm" / "commander_history"
322
+ history_path.parent.mkdir(parents=True, exist_ok=True)
323
+
324
+ # Create completer for slash commands and instance names
325
+ completer = CommandCompleter(self._get_instance_names)
326
+
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()
355
+ try:
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}")
367
+
368
+ self._print("\nGoodbye!")
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
+
414
+ async def _handle_input(self, input_text: str) -> None:
415
+ """Handle user input - command or natural language.
416
+
417
+ Args:
418
+ input_text: User input string.
419
+ """
420
+ if not input_text:
421
+ return
422
+
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)
443
+ if 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)
494
+ else:
495
+ # Default to chat - send to connected instance
496
+ await self._send_to_instance(input_text)
497
+
498
+ async def _execute_command(
499
+ self, cmd: Command, target_instance: Optional[str] = None
500
+ ) -> None:
501
+ """Execute a built-in command.
502
+
503
+ Args:
504
+ cmd: Parsed command.
505
+ target_instance: Optional target instance name for @instance prefix.
506
+ """
507
+ handlers = {
508
+ CommandType.LIST: self._cmd_list,
509
+ CommandType.START: self._cmd_start,
510
+ CommandType.STOP: self._cmd_stop,
511
+ CommandType.CLOSE: self._cmd_close,
512
+ CommandType.REGISTER: self._cmd_register,
513
+ CommandType.CONNECT: self._cmd_connect,
514
+ CommandType.DISCONNECT: self._cmd_disconnect,
515
+ CommandType.SAVED: self._cmd_saved,
516
+ CommandType.FORGET: self._cmd_forget,
517
+ CommandType.STATUS: self._cmd_status,
518
+ CommandType.HELP: self._cmd_help,
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,
523
+ }
524
+ handler = handlers.get(cmd.type)
525
+ if handler:
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)
531
+
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."
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
683
+ git_info = f" [{inst.git_branch}]" if inst.git_branch else ""
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}")
706
+
707
+ async def _cmd_start(self, args: list[str]) -> None:
708
+ """Start instance: start <name> OR start <path> [--framework cc|mpm] [--name name]."""
709
+ if not args:
710
+ self._print("Usage: start <name> (for registered instances)")
711
+ self._print(" start <path> [--framework cc|mpm] [--name name]")
712
+ return
713
+
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
734
+ project_path = Path(args[0]).expanduser().resolve()
735
+ framework = "cc" # default
736
+ name = project_path.name # default
737
+
738
+ # Parse optional flags
739
+ i = 1
740
+ while i < len(args):
741
+ if args[i] == "--framework" and i + 1 < len(args):
742
+ framework = args[i + 1]
743
+ i += 2
744
+ elif args[i] == "--name" and i + 1 < len(args):
745
+ name = args[i + 1]
746
+ i += 2
747
+ else:
748
+ i += 1
749
+
750
+ # Validate path
751
+ if not project_path.exists():
752
+ self._print(f"Error: Path does not exist: {project_path}")
753
+ return
754
+
755
+ if not project_path.is_dir():
756
+ self._print(f"Error: Path is not a directory: {project_path}")
757
+ return
758
+
759
+ # Register and start instance (creates worktree for git repos)
760
+ try:
761
+ instance = await self.instances.register_instance(
762
+ str(project_path), framework, name
763
+ )
764
+ self._print(f"Started instance '{name}' ({framework}) at {project_path}")
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)
776
+ except Exception as e:
777
+ self._print(f"Error starting instance: {e}")
778
+
779
+ async def _cmd_stop(
780
+ self, args: list[str], target_instance: Optional[str] = None
781
+ ) -> None:
782
+ """Stop an instance.
783
+
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]
800
+
801
+ try:
802
+ await self.instances.stop_instance(name)
803
+ self._print(f"Stopped instance '{name}'")
804
+
805
+ # Disconnect if we were connected
806
+ if self.session.context.connected_instance == name:
807
+ self.session.disconnect()
808
+ except Exception as e:
809
+ self._print(f"Error stopping instance: {e}")
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
+
873
+ async def _cmd_connect(self, args: list[str]) -> None:
874
+ """Connect to an instance: connect <name>.
875
+
876
+ If instance is not running but has saved registration, start it first.
877
+ """
878
+ if not args:
879
+ self._print("Usage: connect <instance-name>")
880
+ return
881
+
882
+ name = args[0]
883
+ inst = self.instances.get_instance(name)
884
+
885
+ if not inst:
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
908
+
909
+ self.session.connect_to(name)
910
+ self._print(f"Connected to {name}")
911
+
912
+ async def _cmd_disconnect(self, args: list[str]) -> None:
913
+ """Disconnect from current instance."""
914
+ if not self.session.context.is_connected:
915
+ self._print("Not connected to any instance")
916
+ return
917
+
918
+ name = self.session.context.connected_instance
919
+ self.session.disconnect()
920
+ self._print(f"Disconnected from {name}")
921
+
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
947
+ if self.session.context.is_connected:
948
+ name = self.session.context.connected_instance
949
+ inst = self.instances.get_instance(name)
950
+ if inst:
951
+ self._print(f"Connected to: {name}")
952
+ self._print(f" Framework: {inst.framework}")
953
+ self._print(f" Project: {inst.project_path}")
954
+ if inst.git_branch:
955
+ self._print(f" Git: {inst.git_branch} ({inst.git_status})")
956
+ self._print(f" Tmux: {inst.tmux_session}:{inst.pane_target}")
957
+ else:
958
+ self._print(f"Connected to: {name} (instance no longer exists)")
959
+ else:
960
+ self._print("Not connected to any instance")
961
+
962
+ self._print(f"Messages in history: {len(self.session.context.messages)}")
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
+
1041
+ async def _cmd_help(self, args: list[str]) -> None:
1042
+ """Show help message."""
1043
+ help_text = """
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
1072
+
1073
+ Natural Language:
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.
1079
+
1080
+ Examples:
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
1095
+ """
1096
+ self._print(help_text)
1097
+
1098
+ async def _cmd_exit(self, args: list[str]) -> None:
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
+
1108
+ self._running = False
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
+
1614
+ async def _send_to_instance(self, message: str) -> None:
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.
1619
+
1620
+ Args:
1621
+ message: User message to send.
1622
+ """
1623
+ # Check if instance is connected and ready
1624
+ if not self.session.context.is_connected:
1625
+ self._print("Not connected to any instance. Use 'connect <name>' first.")
1626
+ return
1627
+
1628
+ name = self.session.context.connected_instance
1629
+ inst = self.instances.get_instance(name)
1630
+ if not inst:
1631
+ self._print(f"Instance '{name}' no longer exists")
1632
+ self.session.disconnect()
1633
+ return
1634
+
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)
1643
+ self.session.add_user_message(message)
1644
+
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:
1650
+ try:
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
1705
+ except Exception as 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}")
1767
+
1768
+ def _get_prompt(self) -> str:
1769
+ """Get prompt string.
1770
+
1771
+ Returns:
1772
+ Prompt string for input, showing instance name when connected.
1773
+ """
1774
+ connected = self.session.context.connected_instance
1775
+ if connected:
1776
+ return f"Commander ({connected})> "
1777
+ return "Commander> "
1778
+
1779
+ def _print(self, msg: str) -> None:
1780
+ """Print message to console.
1781
+
1782
+ Args:
1783
+ msg: Message to print.
1784
+ """
1785
+ print(msg)
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
+
1921
+ def _print_welcome(self) -> None:
1922
+ """Print welcome message."""
1923
+ print("╔══════════════════════════════════════════╗")
1924
+ print("║ MPM Commander - Interactive Mode ║")
1925
+ print("╚══════════════════════════════════════════╝")
1926
+ print("Type '/help' for commands, or natural language to chat.")
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