@aliwey/bmo 2.0.0

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 (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
package/cli.py ADDED
@@ -0,0 +1,1129 @@
1
+ """
2
+ BMO Interactive CLI Client
3
+ Provides a premium terminal experience inspired by high-end AI agents (Claude Code, Aider).
4
+ Integrates with the unified BMOEngine backend.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import sys
10
+ import threading
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.completion import WordCompleter
16
+ from prompt_toolkit.styles import Style
17
+ from prompt_toolkit.application import Application
18
+ from prompt_toolkit.key_binding import KeyBindings
19
+ from prompt_toolkit.layout import Layout, HSplit, VSplit, Window
20
+ from prompt_toolkit.layout.controls import FormattedTextControl, BufferControl
21
+ from prompt_toolkit.buffer import Buffer
22
+ from prompt_toolkit.widgets import Frame
23
+
24
+
25
+ from core.bmo_engine import BMOEngine
26
+ from rich.panel import Panel
27
+ from core.goal_runner import GoalRunner
28
+ from core.budget_tracker import BudgetTracker
29
+ from core.cli_renderer import (
30
+ print_banner,
31
+ print_assistant_message,
32
+ print_system_status,
33
+ print_sessions_list,
34
+ print_error,
35
+ print_success,
36
+ print_info,
37
+ bmo_spinner
38
+ )
39
+
40
+ from config.settings import OWNER_ID
41
+ # Configuration: Default admin user chat ID
42
+ CHAT_ID = int(os.getenv("TELEGRAM_BOT_TOKEN_ADMIN_ID", str(OWNER_ID)))
43
+ USER_ID = CHAT_ID
44
+ USERNAME = "cli_user"
45
+
46
+ # CLI Commands lists for autocomplete
47
+ COMMANDS = [
48
+ "/help",
49
+ "/new",
50
+ "/sessions",
51
+ "/model",
52
+ "/clear",
53
+ "/status",
54
+ "/share",
55
+ "/exit",
56
+ "/goal",
57
+ "/budget",
58
+ "/bfp",
59
+ "/web",
60
+ "/interrupt",
61
+ "/stop",
62
+ ]
63
+
64
+ completer = WordCompleter(COMMANDS, ignore_case=True)
65
+
66
+ # Styling for the prompt_toolkit interface
67
+ cli_style = Style.from_dict({
68
+ "bottom-toolbar": "bg:#222222 #00ffff bold",
69
+ "bottom-toolbar.text": "#ffffff",
70
+ "prompt": "#00ffff bold",
71
+ })
72
+
73
+ from prompt_toolkit.filters import Condition
74
+
75
+ class ModelSelectorTUI:
76
+ def __init__(self, raw_providers, favorites, save_fav_callback):
77
+ self.raw_providers = raw_providers
78
+ self.favorites = set(favorites)
79
+ self.save_fav_callback = save_fav_callback
80
+
81
+ self.provider_list = []
82
+ for p in raw_providers:
83
+ p_models = []
84
+ raw_models = p.get("models", {})
85
+ if isinstance(raw_models, dict):
86
+ for m_id, m_data in raw_models.items():
87
+ p_models.append({
88
+ "id": m_id,
89
+ "name": m_data.get("name", m_id) if isinstance(m_data, dict) else m_id
90
+ })
91
+ elif isinstance(raw_models, list):
92
+ for m in raw_models:
93
+ p_models.append({
94
+ "id": m.get("id") if isinstance(m, dict) else m,
95
+ "name": m.get("name") or m.get("id") if isinstance(m, dict) else m
96
+ })
97
+ if p_models:
98
+ self.provider_list.append({
99
+ "id": p.get("id"),
100
+ "name": p.get("name") or p.get("id"),
101
+ "models": p_models
102
+ })
103
+
104
+ self.filtered_providers = list(self.provider_list)
105
+ self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
106
+
107
+ self.selected_prov_idx = 0
108
+ self.selected_model_idx = 0
109
+ self.focus_pane = "search" # "search", "providers", or "models"
110
+ self.search_text = ""
111
+ self.selected_model_id = None
112
+ self.selected_provider_id = None
113
+
114
+ # Build UI components
115
+ self.search_buffer = Buffer()
116
+
117
+ @self.search_buffer.on_text_changed.add_handler
118
+ def _on_text_changed(buf):
119
+ self.search_text = buf.text.lower()
120
+ self.filtered_providers = []
121
+ for p in self.provider_list:
122
+ if self.search_text in p["name"].lower() or self.search_text in p["id"].lower():
123
+ self.filtered_providers.append(p)
124
+ # Sort: favorites first, then alphabetically by name
125
+ self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
126
+ self.selected_prov_idx = 0
127
+ self.selected_model_idx = 0
128
+
129
+ self.kb = KeyBindings()
130
+ self._setup_keybindings()
131
+
132
+ def _setup_keybindings(self):
133
+ in_search = Condition(lambda: self.focus_pane == "search")
134
+ in_providers = Condition(lambda: self.focus_pane == "providers")
135
+ in_models = Condition(lambda: self.focus_pane == "models")
136
+
137
+ # Global exit bindings
138
+ @self.kb.add("c-c")
139
+ def _global_exit(event):
140
+ event.app.exit(result=None)
141
+
142
+ # Search Pane Keybindings
143
+ @self.kb.add("escape", filter=in_search)
144
+ def _search_exit(event):
145
+ event.app.exit(result=None)
146
+
147
+ @self.kb.add("down", filter=in_search)
148
+ @self.kb.add("enter", filter=in_search)
149
+ @self.kb.add("tab", filter=in_search)
150
+ def _search_go_to_providers(event):
151
+ if self.filtered_providers:
152
+ self.focus_pane = "providers"
153
+ event.app.layout.focus(self.providers_window)
154
+
155
+ # Providers Pane Keybindings
156
+ @self.kb.add("escape", filter=in_providers)
157
+ @self.kb.add("left", filter=in_providers)
158
+ def _providers_exit(event):
159
+ self.focus_pane = "search"
160
+ event.app.layout.focus(self.search_window)
161
+
162
+ @self.kb.add("up", filter=in_providers)
163
+ def _providers_up(event):
164
+ if self.filtered_providers:
165
+ self.selected_prov_idx = (self.selected_prov_idx - 1) % len(self.filtered_providers)
166
+ self.selected_model_idx = 0
167
+
168
+ @self.kb.add("down", filter=in_providers)
169
+ def _providers_down(event):
170
+ if self.filtered_providers:
171
+ self.selected_prov_idx = (self.selected_prov_idx + 1) % len(self.filtered_providers)
172
+ self.selected_model_idx = 0
173
+
174
+ @self.kb.add("space", filter=in_providers)
175
+ def _providers_toggle_fav(event):
176
+ if self.filtered_providers:
177
+ prov = self.filtered_providers[self.selected_prov_idx]
178
+ prov_id = prov["id"]
179
+ if prov_id in self.favorites:
180
+ self.favorites.remove(prov_id)
181
+ else:
182
+ self.favorites.add(prov_id)
183
+ self.save_fav_callback(list(self.favorites))
184
+
185
+ # Re-sort list and update index to stay focused on same item
186
+ self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
187
+ for idx, p in enumerate(self.filtered_providers):
188
+ if p["id"] == prov_id:
189
+ self.selected_prov_idx = idx
190
+ break
191
+
192
+ @self.kb.add("enter", filter=in_providers)
193
+ @self.kb.add("right", filter=in_providers)
194
+ @self.kb.add("tab", filter=in_providers)
195
+ def _providers_select(event):
196
+ if self.filtered_providers:
197
+ prov = self.filtered_providers[self.selected_prov_idx]
198
+ if prov["models"]:
199
+ self.focus_pane = "models"
200
+ self.selected_model_idx = 0
201
+ event.app.layout.focus(self.models_window)
202
+
203
+ # Models Pane Keybindings
204
+ @self.kb.add("escape", filter=in_models)
205
+ @self.kb.add("left", filter=in_models)
206
+ @self.kb.add("tab", filter=in_models)
207
+ def _models_back(event):
208
+ self.focus_pane = "providers"
209
+ event.app.layout.focus(self.providers_window)
210
+
211
+ @self.kb.add("up", filter=in_models)
212
+ def _models_up(event):
213
+ if self.filtered_providers:
214
+ prov = self.filtered_providers[self.selected_prov_idx]
215
+ if prov["models"]:
216
+ self.selected_model_idx = (self.selected_model_idx - 1) % len(prov["models"])
217
+
218
+ @self.kb.add("down", filter=in_models)
219
+ def _models_down(event):
220
+ if self.filtered_providers:
221
+ prov = self.filtered_providers[self.selected_prov_idx]
222
+ if prov["models"]:
223
+ self.selected_model_idx = (self.selected_model_idx + 1) % len(prov["models"])
224
+
225
+ @self.kb.add("enter", filter=in_models)
226
+ def _models_select(event):
227
+ if self.filtered_providers:
228
+ prov = self.filtered_providers[self.selected_prov_idx]
229
+ if prov["models"]:
230
+ model = prov["models"][self.selected_model_idx]
231
+ self.selected_provider_id = prov["id"]
232
+ self.selected_model_id = model["id"]
233
+ event.app.exit(result=(self.selected_provider_id, self.selected_model_id))
234
+
235
+ def _get_visible_slice(self, items, selected_idx, max_visible=12):
236
+ total = len(items)
237
+ if total <= max_visible:
238
+ return items, 0
239
+
240
+ half = max_visible // 2
241
+ start = selected_idx - half
242
+ if start < 0:
243
+ start = 0
244
+ elif start + max_visible > total:
245
+ start = total - max_visible
246
+
247
+ end = start + max_visible
248
+ return items[start:end], start
249
+
250
+ def _render_providers(self):
251
+ if not self.filtered_providers:
252
+ return [("", " No matching providers found.\n")]
253
+
254
+ visible_providers, start_idx = self._get_visible_slice(
255
+ self.filtered_providers,
256
+ self.selected_prov_idx,
257
+ max_visible=15
258
+ )
259
+
260
+ lines = []
261
+ if start_idx > 0:
262
+ lines.append(("class:scroll-indicator", " ▲ ... more providers above ...\n"))
263
+
264
+ for idx, prov in enumerate(visible_providers):
265
+ actual_idx = start_idx + idx
266
+ is_selected = (actual_idx == self.selected_prov_idx)
267
+ is_fav = prov["id"] in self.favorites
268
+
269
+ prefix = " > " if is_selected else " "
270
+ fav_star = "⭐" if is_fav else " "
271
+
272
+ if is_selected:
273
+ if self.focus_pane == "providers":
274
+ style = "class:selected"
275
+ else:
276
+ style = "class:selected-inactive"
277
+ else:
278
+ style = ""
279
+
280
+ lines.append((style, f"{prefix}{fav_star} {prov['name']} ({prov['id']})\n"))
281
+
282
+ if start_idx + len(visible_providers) < len(self.filtered_providers):
283
+ lines.append(("class:scroll-indicator", " ▼ ... more providers below ...\n"))
284
+
285
+ return lines
286
+
287
+ def _render_models(self):
288
+ if not self.filtered_providers:
289
+ return [("", " No provider selected.\n")]
290
+
291
+ prov = self.filtered_providers[self.selected_prov_idx]
292
+ if not prov["models"]:
293
+ return [("", " No models available.\n")]
294
+
295
+ visible_models, start_idx = self._get_visible_slice(
296
+ prov["models"],
297
+ self.selected_model_idx,
298
+ max_visible=15
299
+ )
300
+
301
+ lines = []
302
+ if start_idx > 0:
303
+ lines.append(("class:scroll-indicator", " ▲ ... more models above ...\n"))
304
+
305
+ for idx, model in enumerate(visible_models):
306
+ actual_idx = start_idx + idx
307
+ is_selected = (actual_idx == self.selected_model_idx)
308
+
309
+ prefix = " > " if is_selected else " "
310
+ if is_selected:
311
+ if self.focus_pane == "models":
312
+ style = "class:selected"
313
+ else:
314
+ style = "class:selected-inactive"
315
+ else:
316
+ style = ""
317
+
318
+ lines.append((style, f"{prefix}{model['id']} ({model['name']})\n"))
319
+
320
+ if start_idx + len(visible_models) < len(prov["models"]):
321
+ lines.append(("class:scroll-indicator", " ▼ ... more models below ...\n"))
322
+
323
+ return lines
324
+
325
+ def _get_search_title(self):
326
+ if self.focus_pane == "search":
327
+ return [("class:frame.label.active", " Search Provider (Start typing...) ")]
328
+ return [("class:frame.label", " Search Provider ")]
329
+
330
+ def _get_providers_title(self):
331
+ if self.focus_pane == "providers":
332
+ return [("class:frame.label.active", " Providers List (⭐ Favorites) ")]
333
+ return [("class:frame.label", " Providers List ")]
334
+
335
+ def _get_models_title(self):
336
+ if self.focus_pane == "models":
337
+ return [("class:frame.label.active", " Available Models ")]
338
+ return [("class:frame.label", " Available Models ")]
339
+
340
+ def _render_status(self):
341
+ if self.focus_pane == "search":
342
+ return [("class:help", " [Down/Enter] Navigate Providers | [Esc] Cancel Selection")]
343
+ elif self.focus_pane == "providers":
344
+ return [("class:help", " [Up/Down] Choose Provider | [Space] Toggle Favorite ⭐ | [Enter/Right] Show Models | [Esc/Left] Back to Search")]
345
+ else:
346
+ return [("class:help", " [Up/Down] Choose Model | [Enter] Confirm Model Selection | [Esc/Left] Back to Providers")]
347
+
348
+ def get_app(self):
349
+ style = Style.from_dict({
350
+ "selected": "bg:#00ffff #000000 bold",
351
+ "selected-inactive": "bg:#333333 #ffffff italic",
352
+ "help": "bg:#222222 #00ffff bold",
353
+ "frame.label": "#888888",
354
+ "frame.label.active": "#00ffff bold",
355
+ "scroll-indicator": "#555555 italic",
356
+ })
357
+
358
+ self.search_window = Window(content=BufferControl(buffer=self.search_buffer), height=1)
359
+ self.providers_window = Window(content=FormattedTextControl(self._render_providers, focusable=True))
360
+ self.models_window = Window(content=FormattedTextControl(self._render_models, focusable=True))
361
+
362
+ self.layout = Layout(
363
+ HSplit([
364
+ Frame(self.search_window, title=self._get_search_title),
365
+ VSplit([
366
+ Frame(self.providers_window, title=self._get_providers_title),
367
+ Frame(self.models_window, title=self._get_models_title),
368
+ ]),
369
+ Window(content=FormattedTextControl(self._render_status), height=1),
370
+ ]),
371
+ focused_element=self.search_window
372
+ )
373
+
374
+ app = Application(
375
+ layout=self.layout,
376
+ key_bindings=self.kb,
377
+ style=style,
378
+ full_screen=True
379
+ )
380
+ return app
381
+
382
+ def is_bot_running():
383
+ import socket
384
+ try:
385
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
386
+ s.settimeout(0.2)
387
+ s.connect(("127.0.0.1", 4097))
388
+ return True
389
+ except Exception:
390
+ return False
391
+
392
+ def start_bot_background():
393
+ import subprocess
394
+ import sys
395
+ import os
396
+ creationflags = 0
397
+ if sys.platform == "win32":
398
+ creationflags = 0x08000000
399
+ try:
400
+ subprocess.Popen(
401
+ [sys.executable, "main.py"],
402
+ cwd=os.path.dirname(os.path.abspath(__file__)),
403
+ stdout=subprocess.DEVNULL,
404
+ stderr=subprocess.DEVNULL,
405
+ creationflags=creationflags,
406
+ close_fds=True
407
+ )
408
+ return True
409
+ except Exception:
410
+ return False
411
+
412
+ async def main():
413
+ # Automatically ensure the BMO Bot is running in the background
414
+ if not is_bot_running():
415
+ print_info("BMO Telegram Bot/MCP Server is not running. Starting it in the background...")
416
+ if start_bot_background():
417
+ # Wait briefly for server to bind to port 4097
418
+ for _ in range(10):
419
+ await asyncio.sleep(0.3)
420
+ if is_bot_running():
421
+ print_success("BMO Bot / MCP Server is online.")
422
+ break
423
+ else:
424
+ print_info("BMO Bot started but not responding yet. It will continue loading in the background.")
425
+ else:
426
+ print_error("Failed to start BMO Bot in the background.")
427
+
428
+ # Initialize Engine, GoalRunner, and BudgetTracker
429
+ engine = BMOEngine()
430
+ # CLI has its own async loop — disable the worker subprocess so all queries
431
+ # go through _send_direct. The worker is only needed for Telegram's sync handlers.
432
+ engine.client._worker = None
433
+ goal_runner = GoalRunner(engine)
434
+ budget_tracker = BudgetTracker()
435
+
436
+ # ── Start BFP Agent (independent of the worker, always start in CLI) ──
437
+ # BFP is gated inside ensure_worker() which returns early when _worker=None,
438
+ # so we start it explicitly here instead.
439
+ if not engine._bfp_started:
440
+ from config.settings import BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT
441
+ _relay = BFP_RELAY_URL or "http://localhost:9753"
442
+ asyncio.create_task(engine.bfp.start(
443
+ bfp_port=BFP_TRANSPORT_PORT,
444
+ a2a_port=BFP_A2A_PORT,
445
+ relay_url=_relay,
446
+ ))
447
+ engine._bfp_started = True
448
+ print_info(f"BFP Agent starting... (relay: {_relay})")
449
+
450
+ # Check OpenCode Connection
451
+ status = await engine.get_status()
452
+ connection_state = [status.get("connected", False)] # mutable so toolbar closure can reflect updates
453
+
454
+ # Ensure session exists
455
+ session = await engine.get_or_create_session(CHAT_ID, USER_ID, USERNAME)
456
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
457
+
458
+ # Print welcome banner
459
+ print_banner(model_id, connection_state[0])
460
+ print_info(f"Connected to SQLite database: {engine.storage.db_path}")
461
+ print_info(f"Active Session: [bold cyan]{session.title or session.session_id[:8]}[/bold cyan]")
462
+ print_info("Type /help to see all interactive commands.\n")
463
+
464
+ # Define bottom toolbar function
465
+ def get_toolbar_text():
466
+ conn_indicator = "● Online" if connection_state[0] else "● Offline"
467
+ curr_title = session.title or session.session_id[:8]
468
+ curr_mode = session.metadata.get("active_mode", "execute")
469
+
470
+ # Budget info
471
+ limit = budget_tracker.get_limit(session.session_id)
472
+ cost = budget_tracker.get_session_cost(session.session_id)
473
+ budget_str = f"${cost:.2f}/${limit:.2f}"
474
+
475
+ return [
476
+ ("class:bottom-toolbar", f" BMO CLI | Server: {conn_indicator} | Session: {curr_title} | Model: {model_id} | Mode: {curr_mode} | Budget: {budget_str} ")
477
+ ]
478
+
479
+ prompt_kb = KeyBindings()
480
+
481
+ @prompt_kb.add("c-c", eager=True)
482
+ def _clear_input_ctrl_c(event):
483
+ event.app.current_buffer.reset()
484
+
485
+ @prompt_kb.add("escape", eager=True)
486
+ def _clear_input_esc(event):
487
+ event.app.current_buffer.reset()
488
+
489
+ # Storage for collapsed pastes: placeholder → full text
490
+ _paste_store: dict[str, str] = {}
491
+ _paste_counter = [0]
492
+ PASTE_COLLAPSE_THRESHOLD = 5 # lines
493
+
494
+ from prompt_toolkit.keys import Keys
495
+ @prompt_kb.add(Keys.BracketedPaste, eager=True)
496
+ def _handle_paste(event):
497
+ """Collapse large pastes into a [Pasted text #N +M lines] placeholder."""
498
+ data = event.data
499
+ lines = data.splitlines()
500
+ if len(lines) >= PASTE_COLLAPSE_THRESHOLD:
501
+ _paste_counter[0] += 1
502
+ n = _paste_counter[0]
503
+ placeholder = f"[Pasted text #{n} +{len(lines)} lines]"
504
+ _paste_store[placeholder] = data
505
+ event.app.current_buffer.insert_text(placeholder)
506
+ else:
507
+ event.app.current_buffer.insert_text(data)
508
+
509
+ prompt_session = PromptSession(
510
+ message="BMO > ",
511
+ completer=completer,
512
+ bottom_toolbar=get_toolbar_text,
513
+ style=cli_style,
514
+ key_bindings=prompt_kb,
515
+ )
516
+ _PS = PromptSession # shorthand for sub-prompts — each call creates a fresh instance
517
+ _pending_inject: str = None # holds mid-run injection text to send on next iteration
518
+
519
+ while True:
520
+ try:
521
+ # Sync active session from database (in case Telegram changed it)
522
+ active_session = engine.storage.load_session(CHAT_ID)
523
+ if active_session and active_session.session_id != session.session_id:
524
+ session = active_session
525
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
526
+
527
+ # If an inject was requested during thinking, use it directly
528
+ # instead of waiting for new user input from the prompt.
529
+ if _pending_inject is not None:
530
+ user_input = _pending_inject
531
+ _pending_inject = None
532
+ else:
533
+ user_input = await prompt_session.prompt_async()
534
+ user_input = user_input.strip()
535
+
536
+ # Check again after user input to ensure we don't send to a stale session
537
+ active_session = engine.storage.load_session(CHAT_ID)
538
+ if active_session and active_session.session_id != session.session_id:
539
+ session = active_session
540
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
541
+ print_info(f"\n[System] Session synced with Telegram. Active session is now: {session.title}")
542
+
543
+ if not user_input:
544
+ continue
545
+
546
+ # Expand paste placeholders back to full text before sending.
547
+ # Handles two cases:
548
+ # 1. user_input IS the placeholder (simple paste + Enter)
549
+ # 2. user_input CONTAINS placeholder(s) embedded in longer text
550
+ if _paste_store:
551
+ if user_input in _paste_store:
552
+ # Simple case: the whole input is a placeholder
553
+ user_input = _paste_store.pop(user_input)
554
+ else:
555
+ # Complex case: scan for embedded placeholders
556
+ for placeholder, content in list(_paste_store.items()):
557
+ if placeholder in user_input:
558
+ user_input = user_input.replace(placeholder, content)
559
+ _paste_store.pop(placeholder, None)
560
+
561
+ # Command routing
562
+ if user_input.startswith("/"):
563
+ cmd_parts = user_input.split(" ", 1)
564
+ cmd = cmd_parts[0].lower()
565
+
566
+ if cmd in ("/exit", "/quit", "/q"):
567
+ print_success("Goodbye!")
568
+ break
569
+
570
+ elif cmd in ("/help", "/h"):
571
+ print_info("Available Commands:")
572
+ print_info(" /help or /h - Display this help message")
573
+ print_info(" /new or /n - Start a fresh conversation session")
574
+ print_info(" /sessions or /s - List recent conversation sessions and switch")
575
+ print_info(" /model or /m - Interactively choose provider and model")
576
+ print_info(" /clear or /c - Clear message history of current session")
577
+ print_info(" /status or /t - Display server connection status and stats")
578
+ print_info(" /web - Start/show webchat URL (Cloudflare tunnel)")
579
+ print_info(" /goal <task> - Run autonomous goal loop (updates Telegram bot)")
580
+ print_info(" /budget <limit> - Set session token cost budget limit in USD")
581
+ print_info(" /share - Export current session as .txt or .json file")
582
+ print_info(" /bfp status - Show BFP identity, capabilities, relay connection")
583
+ print_info(" /bfp find <cap> - Find agents by capability via relay")
584
+ print_info(" /bfp delegate <did> <task> - Send a task to another agent")
585
+ print_info(" /bfp talk <did> <msg> - Send a message to another agent")
586
+ print_info(" /exit or /q - Exit the CLI interface")
587
+
588
+ elif cmd in ("/new", "/n"):
589
+ print_info("Creating new session...")
590
+ session = await engine.create_new_session(CHAT_ID, USER_ID, USERNAME)
591
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
592
+ print_success(f"Started new session: {session.title}")
593
+
594
+ elif cmd in ("/clear", "/c"):
595
+ print_info("Clearing current session history...")
596
+ if await engine.clear_session_history(CHAT_ID):
597
+ print_success("Session message history cleared successfully.")
598
+ else:
599
+ print_error("Failed to clear session history.")
600
+
601
+ elif cmd in ("/status", "/t"):
602
+ status = await engine.get_status()
603
+ print_system_status(status, model_id)
604
+
605
+ elif cmd in ("/budget", "/b"):
606
+ if len(cmd_parts) < 2:
607
+ current_limit = budget_tracker.get_limit(session.session_id)
608
+ print_info(f"Current budget limit: ${current_limit:.2f}")
609
+ limit_input = (await _PS().prompt_async("Enter new budget limit in USD: ")).strip()
610
+ else:
611
+ limit_input = cmd_parts[1].strip()
612
+
613
+ try:
614
+ new_limit = float(limit_input)
615
+ budget_tracker.set_limit(session.session_id, new_limit)
616
+ print_success(f"Session budget set to: ${new_limit:.2f}")
617
+ except ValueError:
618
+ print_error("Invalid budget limit value.")
619
+
620
+ elif cmd in ("/goal", "/g"):
621
+ if len(cmd_parts) < 2:
622
+ objective = (await _PS().prompt_async("Enter autonomous objective goal: ")).strip()
623
+ else:
624
+ objective = cmd_parts[1].strip()
625
+
626
+ if not objective:
627
+ print_error("Objective cannot be empty.")
628
+ continue
629
+
630
+ await goal_runner.run_goal(CHAT_ID, USER_ID, objective)
631
+
632
+ elif cmd in ("/sessions", "/s"):
633
+ sessions = await engine.list_sessions(CHAT_ID)
634
+ if not sessions:
635
+ print_info("No saved sessions found.")
636
+ continue
637
+
638
+ print_sessions_list(sessions)
639
+
640
+ choice = (await _PS().prompt_async("\nEnter session index number to switch (or press Enter to cancel): ")).strip()
641
+ if choice.isdigit():
642
+ idx = int(choice) - 1
643
+ if 0 <= idx < len(sessions):
644
+ selected_sid = sessions[idx]["session_id"]
645
+ new_sess = await engine.switch_session(CHAT_ID, selected_sid)
646
+ if new_sess:
647
+ session = new_sess
648
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
649
+ print_success(f"Switched active session to: {session.title}")
650
+ else:
651
+ print_error("Failed to switch session.")
652
+ else:
653
+ print_error("Invalid session index.")
654
+
655
+ elif cmd in ("/model", "/m"):
656
+ raw_providers = await engine.list_models()
657
+ if not raw_providers:
658
+ print_error("Could not fetch available models from server.")
659
+ continue
660
+
661
+ import time
662
+ from models.chat_models import UserMemory
663
+ memory = engine.storage.load_memory(CHAT_ID)
664
+ if not memory:
665
+ memory = UserMemory(
666
+ user_id=USER_ID,
667
+ chat_id=CHAT_ID,
668
+ preferences={"favorite_providers": []},
669
+ knowledge={},
670
+ created_at=time.time(),
671
+ updated_at=time.time()
672
+ )
673
+ if "favorite_providers" not in memory.preferences:
674
+ memory.preferences["favorite_providers"] = []
675
+
676
+ def save_fav_callback(fav_list):
677
+ memory.preferences["favorite_providers"] = fav_list
678
+ memory.updated_at = time.time()
679
+ engine.storage.save_memory(memory)
680
+
681
+ tui = ModelSelectorTUI(
682
+ raw_providers=raw_providers,
683
+ favorites=memory.preferences["favorite_providers"],
684
+ save_fav_callback=save_fav_callback
685
+ )
686
+
687
+ app = tui.get_app()
688
+ result = await app.run_async()
689
+
690
+ if result:
691
+ selected_provider_id, selected_model_id = result
692
+ if await engine.set_model(CHAT_ID, selected_provider_id, selected_model_id):
693
+ provider_id, model_id = await engine.get_model_info(CHAT_ID)
694
+ print_success(f"Active model set to: {model_id} ({provider_id})")
695
+ else:
696
+ print_error("Failed to switch model.")
697
+ else:
698
+ print_info("Cancelled model selection.")
699
+
700
+ elif cmd in ("/share",):
701
+ current_session = engine.storage.get_session_by_id(session.session_id)
702
+ if not current_session or not current_session.messages:
703
+ print_error("No messages in current session to export.")
704
+ continue
705
+
706
+ print_info("Export format:")
707
+ print_info(" [1] Text file (.txt)")
708
+ print_info(" [2] JSON file (.json)")
709
+ fmt_choice = (await _PS().prompt_async("Choose [1/2] (default: 1): ")).strip()
710
+ fmt = "json" if fmt_choice == "2" else "txt"
711
+
712
+ import json as _json
713
+ from datetime import datetime as _dt
714
+
715
+ exports_dir = os.path.join(os.path.dirname(__file__), "data", "exports")
716
+ safe_title = (session.title or session.session_id[:8]).replace(" ", "_").replace("/", "-")
717
+ timestamp = _dt.now().strftime("%Y%m%d_%H%M%S")
718
+ filename = f"bmo_session_{safe_title}_{timestamp}.{fmt}"
719
+ out_path = os.path.join(exports_dir, filename)
720
+ os.makedirs(exports_dir, exist_ok=True)
721
+
722
+ # Messages are dicts with key "sender" (values: "user" or "assistant")
723
+ msgs = current_session.messages
724
+
725
+ def _sender(m):
726
+ if isinstance(m, dict):
727
+ return m.get("sender") or m.get("role", "")
728
+ return getattr(m, "sender", None) or getattr(m, "role", "")
729
+
730
+ def _content(m):
731
+ return m["content"] if isinstance(m, dict) else getattr(m, "content", "")
732
+
733
+ def _timestamp(m):
734
+ return m.get("timestamp") if isinstance(m, dict) else getattr(m, "timestamp", None)
735
+
736
+ if fmt == "json":
737
+ export_data = {
738
+ "session_id": session.session_id,
739
+ "title": session.title,
740
+ "exported_at": _dt.now().isoformat(),
741
+ "messages": [
742
+ {
743
+ "sender": _sender(m),
744
+ "content": _content(m),
745
+ "timestamp": _timestamp(m),
746
+ }
747
+ for m in msgs
748
+ ]
749
+ }
750
+ with open(out_path, "w", encoding="utf-8") as _f:
751
+ _json.dump(export_data, _f, ensure_ascii=False, indent=2)
752
+ else:
753
+ lines = [
754
+ "BMO Session Export",
755
+ f"Title : {session.title}",
756
+ f"ID : {session.session_id}",
757
+ f"Exported: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}",
758
+ "=" * 60,
759
+ "",
760
+ ]
761
+ for m in msgs:
762
+ role_label = "You" if _sender(m) == "user" else "BMO"
763
+ lines.append(f"[{role_label}]")
764
+ lines.append(_content(m))
765
+ lines.append("")
766
+ with open(out_path, "w", encoding="utf-8") as _f:
767
+ _f.write("\n".join(lines))
768
+
769
+ print_success(f"Session exported → {out_path}")
770
+
771
+ elif cmd == "/bfp":
772
+ parts = user_input.split(maxsplit=2)
773
+ subcmd = parts[1] if len(parts) > 1 else ""
774
+ if subcmd == "status":
775
+ s = engine.bfp.get_status()
776
+ print_info("── BFP Status ──")
777
+ print_info(f" DID : {s['did']}")
778
+ print_info(f" Running : {'Yes' if s['running'] else 'No'}")
779
+ print_info(f" Relay : {s['relay_url'] or 'Not connected'}")
780
+ print_info(f" Relay Status : {'Connected' if s['connected_to_relay'] else 'Disconnected'}")
781
+ print_info(f" Transport Port: {s['bfp_port'] or 'N/A'}")
782
+ print_info(f" Capabilities : {', '.join(s['capabilities'])}")
783
+
784
+ elif subcmd == "find":
785
+ if len(parts) < 3:
786
+ print_error("Usage: /bfp find <capability>")
787
+ continue
788
+ capability = parts[2]
789
+ if not engine.bfp.is_running:
790
+ print_error("BFP not started")
791
+ continue
792
+ try:
793
+ agents = await engine.bfp.find_agents(capability)
794
+ if not agents:
795
+ print_info(f"No agents found with capability: {capability}")
796
+ else:
797
+ print_info(f"Agents with '{capability}' capability ({len(agents)}):")
798
+ for a in agents:
799
+ name = a.get("name", a["did"][:30])
800
+ did = a["did"]
801
+ caps = ", ".join(a.get("capabilities", []))
802
+ print_info(f" {name}")
803
+ print_info(f" DID: {did}")
804
+ print_info(f" Capabilities: {caps}")
805
+ except Exception as e:
806
+ print_error(f"Discovery failed: {e}")
807
+
808
+ elif subcmd == "delegate":
809
+ if len(parts) < 4:
810
+ print_error("Usage: /bfp delegate <did> <task>")
811
+ continue
812
+ target_did = parts[2]
813
+ task_text = parts[3]
814
+ if not engine.bfp.is_running:
815
+ print_error("BFP not started")
816
+ continue
817
+ try:
818
+ print_info(f"Delegating task to {target_did[:40]}...")
819
+ result = await engine.bfp.delegate(target_did, {"query": task_text})
820
+ print_success(f"Result: {result.get('result', 'No result')}")
821
+ except Exception as e:
822
+ print_error(f"Delegation failed: {e}")
823
+
824
+ elif subcmd == "talk":
825
+ if len(parts) < 4:
826
+ print_error("Usage: /bfp talk <did> <message>")
827
+ continue
828
+ target_did = parts[2]
829
+ message = parts[3]
830
+ if not engine.bfp.is_running:
831
+ print_error("BFP not started")
832
+ continue
833
+ try:
834
+ print_info(f"Sending message to {target_did[:40]}...")
835
+ response = await engine.bfp.talk(target_did, message)
836
+ print_info(f"Response: {response}")
837
+ except Exception as e:
838
+ print_error(f"Talk failed: {e}")
839
+
840
+ else:
841
+ print_info("BFP Commands:")
842
+ print_info(" /bfp status - Show BFP identity and connection status")
843
+ print_info(" /bfp find <capability> - Find agents by capability via relay")
844
+ print_info(" /bfp delegate <did> <msg> - Send a task to another agent")
845
+ print_info(" /bfp talk <did> <msg> - Chat with another agent")
846
+
847
+ elif cmd == "/web":
848
+ # Check task registry for existing webchat/tunnel
849
+ import json as _json
850
+ from config.settings import BMO_HOME
851
+ _tasks_file = BMO_HOME / "data" / "background_tasks.json"
852
+ _existing_url = None
853
+ if _tasks_file.exists():
854
+ try:
855
+ _tasks = _json.loads(_tasks_file.read_text("utf-8"))
856
+ _existing_url = _tasks.get("webchat_tunnel_url")
857
+ except Exception:
858
+ pass
859
+
860
+ if _existing_url:
861
+ print_success(f"💬 Webchat is already running!")
862
+ print_info(f" Local: http://127.0.0.1:3456")
863
+ print_info(f" Public: {_existing_url}")
864
+ else:
865
+ print_info("⏳ Starting webchat + Cloudflare tunnel...")
866
+ try:
867
+ from tools.task_registry import get_active_tasks
868
+ # Check if webchat is actually already running on port 3456
869
+ import socket as _sock
870
+ _s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
871
+ _s.settimeout(0.5)
872
+ _webchat_up = _s.connect_ex(("127.0.0.1", 3456)) == 0
873
+ _s.close()
874
+
875
+ if _webchat_up:
876
+ # Start only the tunnel
877
+ tunnel_result = await engine.client._call_mcp_tool(
878
+ "tunnel_webchat", {"port": 3456}
879
+ ) if hasattr(engine.client, "_call_mcp_tool") else None
880
+ if tunnel_result:
881
+ print_success(f"🌐 Tunnel started: {tunnel_result}")
882
+ else:
883
+ print_info(" Webchat running locally: http://127.0.0.1:3456")
884
+ print_info(" To get a public URL, run: bmo web")
885
+ else:
886
+ print_info(" Webchat not running. Start it with: bmo web")
887
+ print_info(" (Run `bmo web` from your terminal)")
888
+ except Exception as _e:
889
+ print_error(f"Web command failed: {_e}")
890
+ print_info(" Alternative: run `bmo web` from your terminal")
891
+
892
+ else:
893
+ print_error(f"Unknown command: {cmd}")
894
+ continue
895
+
896
+ # Check budget limit before sending
897
+ if budget_tracker.is_exceeded(session.session_id):
898
+ print_error("⚠️ Session budget limit exceeded. Increase budget via /budget to continue.")
899
+ continue
900
+
901
+ # Offline guard — return immediately if server is unreachable
902
+ if not connection_state[0]:
903
+ alive = await engine.client.is_alive()
904
+ connection_state[0] = alive
905
+ if not alive:
906
+ print_error(
907
+ "BMO is offline — OpenCode server not reachable.\n"
908
+ " Start it with: opencode serve --port 4800"
909
+ )
910
+ continue
911
+
912
+ # Regular message — stream response
913
+ import time as _time
914
+ from core.cli_renderer import console
915
+
916
+ # ── Dual-channel input system ────────────────────────────────────
917
+ # Channel 1: Ctrl+C / ESC → hard cancel
918
+ # Channel 2: keystroke + Enter → mid-run injection (abort + new message)
919
+ _cancel_event = threading.Event()
920
+ _inject_event = threading.Event()
921
+ _inject_buffer = [] # chars typed during thinking
922
+ _spinner_active = [True] # shared flag for permission prompt suspension
923
+
924
+ def _key_watcher():
925
+ """Reads keystrokes during thinking. Supports abort and mid-run injection."""
926
+ try:
927
+ import msvcrt
928
+ while not _cancel_event.is_set() and not _inject_event.is_set():
929
+ if not _spinner_active[0]:
930
+ import time as _t; _t.sleep(0.1)
931
+ continue
932
+ if msvcrt.kbhit():
933
+ ch = msvcrt.getwch()
934
+ if ch in ('\x03', '\x1b'): # Ctrl+C or ESC → hard cancel
935
+ _cancel_event.set()
936
+ return
937
+ elif ch in ('\r', '\n'): # Enter → inject buffer as new message
938
+ if _inject_buffer:
939
+ _inject_event.set()
940
+ return
941
+ elif ch == '\x08': # Backspace
942
+ if _inject_buffer:
943
+ _inject_buffer.pop()
944
+ sys.stdout.write('\b \b')
945
+ sys.stdout.flush()
946
+ elif ch.isprintable():
947
+ _inject_buffer.append(ch)
948
+ sys.stdout.write(ch)
949
+ sys.stdout.flush()
950
+ import time as _t; _t.sleep(0.05)
951
+ except Exception:
952
+ pass
953
+
954
+ threading.Thread(target=_key_watcher, daemon=True).start()
955
+
956
+ _current_activity = ["thinking..."]
957
+
958
+ def on_token(partial: str):
959
+ pass # no live display — kept for API compatibility
960
+
961
+ def on_activity(kind: str, detail: str):
962
+ if kind == "thinking":
963
+ return # Skip full thinking blocks to avoid clutter
964
+ _current_activity[0] = detail
965
+
966
+ async def on_permission(perm_id: str, perm_type: str, patterns: list) -> str:
967
+ """Called when the OpenCode server requests permission. Pauses spinner and prompts user."""
968
+ _spinner_active[0] = False
969
+ sys.stdout.write("\r" + " " * 80 + "\r")
970
+ sys.stdout.flush()
971
+ panel = Panel(
972
+ f"[bold yellow]⚠️ BMO SECURITY PERMISSION REQUEST[/bold yellow]\n\n"
973
+ f"[white]Type:[/white] [bold]{perm_type}[/bold]\n"
974
+ f"[white]Scope:[/white] [code]{', '.join(patterns[:3]) if patterns else '*'}[/code]\n\n"
975
+ f"[dim]Choose an option (Auto-approves 'allow' after 7 minutes of inactivity):[/dim]\n"
976
+ f" [bold cyan]a[/bold cyan] → Allow once\n"
977
+ f" [bold cyan]d[/bold cyan] → Deny\n"
978
+ f" [bold cyan]y[/bold cyan] → Yes, always (save to vault)",
979
+ border_style="yellow",
980
+ width=70,
981
+ )
982
+ console.print(panel)
983
+
984
+ try:
985
+ # 7 minutes timeout (420 seconds)
986
+ choice = await asyncio.wait_for(
987
+ _PS().prompt_async("Your choice [a/d/y]: "),
988
+ timeout=420.0
989
+ )
990
+ choice = choice.strip().lower()
991
+ except asyncio.TimeoutError:
992
+ from datetime import datetime as _dt
993
+ print_info("\n⚠️ Permission request timed out after 7 minutes of inactivity. Auto-approving once to avoid freezing.")
994
+ # Log the auto-approval to logs/permissions.log
995
+ try:
996
+ log_dir = os.path.join(os.path.dirname(__file__), "logs")
997
+ os.makedirs(log_dir, exist_ok=True)
998
+ with open(os.path.join(log_dir, "permissions.log"), "a", encoding="utf-8") as lf:
999
+ lf.write(f"[{_dt.now().isoformat()}] AUTO-APPROVED (Timeout): type={perm_type}, scope={patterns}\n")
1000
+ except Exception:
1001
+ pass
1002
+ choice = "allow"
1003
+
1004
+ if choice in ("a", "allow"):
1005
+ reply = "allow"
1006
+ elif choice in ("y", "yes", "always"):
1007
+ reply = "always"
1008
+ else:
1009
+ reply = "deny"
1010
+ _spinner_active[0] = True
1011
+ sys.stdout.write(f" {_DIM}BMO is thinking...{_RESET}\n")
1012
+ sys.stdout.flush()
1013
+ return reply
1014
+
1015
+ stream_task = asyncio.ensure_future(
1016
+ engine.send_message_streaming(
1017
+ CHAT_ID, USER_ID, user_input,
1018
+ interface="cli", on_token=on_token, on_activity=on_activity,
1019
+ on_permission=on_permission,
1020
+ )
1021
+ )
1022
+ start_t = _time.monotonic()
1023
+ response = None
1024
+ elapsed_total = 0.0
1025
+ _dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1026
+ _tick = 0
1027
+ _DIM = "\x1b[2m"
1028
+ _CYAN = "\x1b[36m"
1029
+ _RESET = "\x1b[0m"
1030
+ sys.stdout.write(f" {_DIM}BMO is thinking... (Ctrl+C/ESC=cancel | type+Enter=inject){_RESET}\n")
1031
+ sys.stdout.flush()
1032
+ _inj_prompt_shown = False
1033
+ try:
1034
+ while not stream_task.done():
1035
+ if _cancel_event.is_set():
1036
+ raise asyncio.CancelledError("cancelled via Ctrl+C / ESC")
1037
+ if _inject_event.is_set():
1038
+ raise asyncio.CancelledError("inject")
1039
+ await asyncio.sleep(0.1)
1040
+ if _spinner_active[0]:
1041
+ elapsed_total = _time.monotonic() - start_t
1042
+ _tick += 1
1043
+ if _tick % 10 == 0:
1044
+ injected_preview = ''.join(_inject_buffer)
1045
+ act = _current_activity[0]
1046
+ import re
1047
+ act_clean = re.sub(r'<[^>]+>', '', act)
1048
+ if len(act_clean) > 40:
1049
+ act_clean = act_clean[:37] + "..."
1050
+ if injected_preview:
1051
+ line = f"\r {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET} {_CYAN}↩ {injected_preview}{_RESET} "
1052
+ else:
1053
+ line = f"\r {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET} "
1054
+ sys.stdout.write(line + " " * max(0, 80 - len(line)))
1055
+ sys.stdout.flush()
1056
+ elapsed_total = _time.monotonic() - start_t
1057
+ sys.stdout.write("\r" + " " * 70 + "\r")
1058
+ sys.stdout.flush()
1059
+ response = await stream_task
1060
+ except (KeyboardInterrupt, asyncio.CancelledError) as exc:
1061
+ stream_task.cancel()
1062
+ _cancel_event.set()
1063
+ # ── Abort OpenCode server-side stream ──
1064
+ _oc_sid = session.metadata.get("opencode_session_id")
1065
+ if _oc_sid:
1066
+ try:
1067
+ await engine.client.abort_session(_oc_sid)
1068
+ logger.info("CLI abort_session(%s) sent", _oc_sid)
1069
+ except Exception:
1070
+ pass
1071
+ elapsed_total = _time.monotonic() - start_t
1072
+ response = None
1073
+ sys.stdout.write("\r" + " " * 70 + "\r")
1074
+ sys.stdout.flush()
1075
+
1076
+ # ── Mid-run injection: queue the typed text for next iteration ──
1077
+ injected_text = ''.join(_inject_buffer).strip()
1078
+ if injected_text and str(exc) == "inject":
1079
+ await engine.reset_opencode_session(CHAT_ID)
1080
+ console.print(f"\n [bold cyan]↩ Injecting:[/bold cyan] [italic]{injected_text}[/italic]\n")
1081
+ _pending_inject = injected_text # picked up at top of next loop iteration
1082
+ continue # skip cancel message, go straight to sending
1083
+ finally:
1084
+ _cancel_event.set()
1085
+
1086
+ connection_state[0] = engine.client.is_connected
1087
+
1088
+ if response is None:
1089
+ console.print("\n [bold yellow]⚡ Request cancelled.[/bold yellow] [dim](New OpenCode session will be used for next message)[/dim]\n")
1090
+ await engine.reset_opencode_session(CHAT_ID)
1091
+ continue
1092
+
1093
+ budget_tracker.record_usage(session.session_id, model_id, len(user_input) * 4, len(response) * 4)
1094
+ print_assistant_message(response, elapsed_total)
1095
+
1096
+ except (KeyboardInterrupt, asyncio.CancelledError):
1097
+ continue
1098
+ except EOFError:
1099
+ print_success("\nGoodbye!")
1100
+ break
1101
+ except Exception as e:
1102
+ print_error(f"An unexpected error occurred: {e}")
1103
+
1104
+ # Cleanup: ensure worker subprocess and httpx client are closed
1105
+ # before the event loop tears down. Prevents unclosed
1106
+ # _ProactorBasePipeTransport warnings on /exit.
1107
+ try:
1108
+ if engine.worker_manager and engine.worker_manager.is_alive:
1109
+ await engine.worker_manager.shutdown()
1110
+ except Exception:
1111
+ pass
1112
+ try:
1113
+ if hasattr(engine.client._http, 'aclose'):
1114
+ await engine.client._http.aclose()
1115
+ except Exception:
1116
+ pass
1117
+
1118
+ def main_run():
1119
+ import warnings
1120
+ warnings.filterwarnings("ignore", category=ResourceWarning)
1121
+ # Suppress Windows asyncio pipe ValueError on exit (closed pipe repr noise)
1122
+ warnings.filterwarnings("ignore", message=".*I/O operation on closed pipe.*")
1123
+ try:
1124
+ asyncio.run(main())
1125
+ except KeyboardInterrupt:
1126
+ sys.exit(0)
1127
+
1128
+ if __name__ == "__main__":
1129
+ main_run()