monkeybot-cli 0.2.1__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 (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,817 @@
1
+ """monkeybot chat — gateway client (Textual TUI or plain fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import contextlib
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ from collections.abc import Coroutine
15
+ from pathlib import Path
16
+ from typing import Any, NamedTuple, TextIO
17
+
18
+ import httpx
19
+
20
+ from monkeybot_cli.chat_session import (
21
+ ChatSessionController,
22
+ ChatUiEvent,
23
+ HitlAnswer,
24
+ format_hitl_plain_prompt,
25
+ )
26
+ from monkeybot_cli.chat_status_bar import (
27
+ SessionUsageView,
28
+ format_context_ring,
29
+ format_context_ring_plain,
30
+ )
31
+ from monkeybot_cli.chat_tool_display import (
32
+ tool_display,
33
+ tool_spinner_prefix,
34
+ )
35
+ from monkeybot_cli.chat_tui import is_exit_command, run_chat_tui
36
+ from monkeybot_cli.config_resolve import (
37
+ load_agent_dotenv,
38
+ load_config_doc,
39
+ resolve_agent_root,
40
+ resolve_config,
41
+ )
42
+ from monkeybot_cli.gateway_health import wait_for_health as _wait_for_health
43
+ from monkeybot_cli.opensandbox_lifecycle import (
44
+ ensure_opensandbox_for_agent,
45
+ is_sandbox_enabled,
46
+ server_url_from_config,
47
+ )
48
+ from monkeybot_cli.runtime_python import DEFAULT_PORT, gateway_argv, resolve_runtime_python
49
+ from monkeybot_cli.terminal_markdown import MarkdownPlainStream
50
+
51
+ _DIM = "\x1b[2m"
52
+ _BOLD = "\x1b[1m"
53
+ _GREEN = "\x1b[32m"
54
+ _RED = "\x1b[31m"
55
+ _RESET = "\x1b[0m"
56
+ _USER_PROMPT = "🧑"
57
+ _ASSISTANT_PREFIX = "🐵 "
58
+ _CYAN = "\x1b[36m"
59
+ _UNDERLINE = "\x1b[4m"
60
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
61
+ _GROUNDING_SOURCES_MAX = 5
62
+
63
+ def _hyperlink(url: str, label: str) -> str:
64
+ """OSC 8 terminal hyperlink; falls back to a plain URL on non-TTY streams."""
65
+ if not url:
66
+ return label
67
+ if not sys.stdout.isatty():
68
+ return f"{label} ({url})" if label and label != url else url
69
+ return f"\x1b]8;;{url}\x1b\\{label or url}\x1b]8;;\x1b\\"
70
+
71
+
72
+ def _print_grounding(evt: Any) -> None:
73
+ if not getattr(evt, "sources", None) and not getattr(evt, "search_queries", None):
74
+ return
75
+ header = "grounded search"
76
+ queries = getattr(evt, "search_queries", None) or []
77
+ if queries:
78
+ header += " — " + ", ".join(f'"{q}"' for q in queries)
79
+ print(f"{_DIM} 🔎 {header}{_RESET}", flush=True)
80
+ sources = list(getattr(evt, "sources", None) or [])
81
+ for source in sources[:_GROUNDING_SOURCES_MAX]:
82
+ title = source.get("title", "").strip()
83
+ uri = source.get("uri", "").strip()
84
+ if not uri:
85
+ continue
86
+ link = _hyperlink(uri, title or uri)
87
+ print(f"{_DIM} {_CYAN}{_UNDERLINE}{link}{_RESET}", flush=True)
88
+ remaining = len(sources) - _GROUNDING_SOURCES_MAX
89
+ if remaining > 0:
90
+ print(f"{_DIM} … and {remaining} more{_RESET}", flush=True)
91
+
92
+
93
+ def use_textual_tui() -> bool:
94
+ if os.environ.get("MONKEYBOT_CHAT_PLAIN", "").strip().lower() in ("1", "true", "yes"):
95
+ return False
96
+ return sys.stdin.isatty() and sys.stdout.isatty()
97
+
98
+
99
+ class _SpinnerLine:
100
+ def __init__(self, prefix: str) -> None:
101
+ self._prefix = prefix
102
+ self._stop = asyncio.Event()
103
+ self._task: asyncio.Task[None] | None = None
104
+
105
+ def start(self) -> None:
106
+ self._task = asyncio.create_task(self._run())
107
+
108
+ async def clear(self) -> None:
109
+ self._stop.set()
110
+ if self._task is not None:
111
+ with contextlib.suppress(asyncio.CancelledError):
112
+ await self._task
113
+ sys.stdout.write("\r\x1b[2K")
114
+ sys.stdout.flush()
115
+
116
+ async def finish(self, line: str) -> None:
117
+ await self.clear()
118
+ sys.stdout.write(f"{line}\n")
119
+ sys.stdout.flush()
120
+
121
+ async def _run(self) -> None:
122
+ i = 0
123
+ while not self._stop.is_set():
124
+ frame = _SPINNER_FRAMES[i % len(_SPINNER_FRAMES)]
125
+ sys.stdout.write(f"\r{_DIM} {frame} {self._prefix}{_RESET}")
126
+ sys.stdout.flush()
127
+ i += 1
128
+ try:
129
+ await asyncio.wait_for(self._stop.wait(), timeout=0.08)
130
+ except TimeoutError:
131
+ pass
132
+
133
+
134
+ class _TurnActivity:
135
+ """Animated status lines for tools (plain path + unit tests)."""
136
+
137
+ def __init__(self) -> None:
138
+ self._line: _SpinnerLine | None = None
139
+ self._active_display = ""
140
+
141
+ async def _start(self, prefix: str) -> None:
142
+ if self._line is not None:
143
+ await self._line.clear()
144
+ self._line = _SpinnerLine(prefix)
145
+ self._line.start()
146
+
147
+ async def tool_started(
148
+ self,
149
+ tool: str,
150
+ label: str | None = None,
151
+ args: dict[str, object] | None = None,
152
+ *,
153
+ display: str | None = None,
154
+ prefix: str | None = None,
155
+ ) -> None:
156
+ # Compat: tests call tool_started(tool, label, args)
157
+ if display is None and label is not None and args is not None:
158
+ display = tool_display(tool, label, args)
159
+ prefix = tool_spinner_prefix(tool, label, args)
160
+ self._active_display = display or tool
161
+ await self._start(prefix or display or tool)
162
+
163
+ async def tool_finished(
164
+ self,
165
+ tool: str | None = None,
166
+ *,
167
+ error: str | None = None,
168
+ verbose: bool = False,
169
+ result: str = "",
170
+ ) -> None:
171
+ display = self._active_display or tool or "tool"
172
+ if error:
173
+ err = " ".join(error.split())
174
+ line = f"{_DIM} {_RED}✗{_RESET}{_DIM} {display} — {err}{_RESET}"
175
+ else:
176
+ line = f"{_DIM} {_GREEN}✓{_RESET}{_DIM} {display}{_RESET}"
177
+ self._active_display = ""
178
+ if self._line is not None:
179
+ await self._line.finish(line)
180
+ self._line = None
181
+ else:
182
+ sys.stdout.write(f"{line}\n")
183
+ sys.stdout.flush()
184
+ if verbose and (error or result):
185
+ print(f"{_DIM} {error or result}{_RESET}")
186
+
187
+ async def summarizing(self, tokens: int) -> None:
188
+ await self._start(f"summarizing context ({tokens:,} tokens)")
189
+
190
+ async def summarized(self, turns: int) -> None:
191
+ noun = "turn" if turns == 1 else "turns"
192
+ line = f"{_DIM} {_GREEN}✓{_RESET}{_DIM} summarized {turns} {noun}{_RESET}"
193
+ if self._line is not None:
194
+ await self._line.finish(line)
195
+ self._line = None
196
+ else:
197
+ sys.stdout.write(f"{line}\n")
198
+ sys.stdout.flush()
199
+
200
+ async def cancel(self) -> None:
201
+ if self._line is not None:
202
+ await self._line.clear()
203
+ self._line = None
204
+
205
+
206
+ def _finish_assistant_turn(
207
+ *,
208
+ md_stream: MarkdownPlainStream,
209
+ assistant_label_shown: bool,
210
+ show_usage: bool,
211
+ usage: Any | None = None,
212
+ error: str | None = None,
213
+ ) -> None:
214
+ if assistant_label_shown:
215
+ tail = md_stream.flush()
216
+ if tail:
217
+ print(tail, end="", flush=True)
218
+ if error:
219
+ print(f"\n{_RED}Error: {error}{_RESET}", flush=True)
220
+ elif assistant_label_shown or error:
221
+ print()
222
+ if show_usage and usage is not None:
223
+ print(
224
+ f"{_DIM}[usage] in={usage.get('input_tokens')} out={usage.get('output_tokens')} "
225
+ f"cost=${usage.get('cost_usd'):.4f} {usage.get('duration_ms')}ms{_RESET}"
226
+ )
227
+ print()
228
+
229
+
230
+ async def _read_line(prompt: str, interrupt: asyncio.Event) -> str | None:
231
+ if interrupt.is_set():
232
+ return None
233
+ loop = asyncio.get_running_loop()
234
+ future: asyncio.Future[str] = loop.create_future()
235
+
236
+ def _read() -> None:
237
+ try:
238
+ line = input(prompt)
239
+ loop.call_soon_threadsafe(future.set_result, line)
240
+ except EOFError:
241
+ loop.call_soon_threadsafe(future.set_result, "")
242
+
243
+ threading.Thread(target=_read, daemon=True).start()
244
+ interrupt_waiter = asyncio.create_task(interrupt.wait())
245
+ try:
246
+ done, pending = await asyncio.wait(
247
+ [future, interrupt_waiter],
248
+ return_when=asyncio.FIRST_COMPLETED,
249
+ )
250
+ for task in pending:
251
+ task.cancel()
252
+ if interrupt_waiter in done:
253
+ return None
254
+ return future.result()
255
+ except asyncio.CancelledError:
256
+ return None
257
+
258
+
259
+ def _port_from_config(config_path: Path | None) -> int:
260
+ if config_path is None:
261
+ return DEFAULT_PORT
262
+ _, doc = load_config_doc(str(config_path))
263
+ runtime = doc.get("runtime") if isinstance(doc.get("runtime"), dict) else {}
264
+ try:
265
+ return int(runtime.get("port", DEFAULT_PORT))
266
+ except (TypeError, ValueError):
267
+ return DEFAULT_PORT
268
+
269
+
270
+ def _resolve_base_url(args: argparse.Namespace, config_path: Path | None) -> str:
271
+ if args.url:
272
+ return args.url.rstrip("/")
273
+ port = args.port if args.port else _port_from_config(config_path)
274
+ return f"http://127.0.0.1:{port}"
275
+
276
+
277
+ def _model_banner_fields(
278
+ args: argparse.Namespace, config_path: Path | None
279
+ ) -> tuple[str, str]:
280
+ provider = (args.model_provider or "").strip()
281
+ model = (args.model_name or "").strip()
282
+ if config_path is not None and (not provider or not model):
283
+ _, doc = load_config_doc(str(config_path))
284
+ model_cfg = doc.get("model") if isinstance(doc.get("model"), dict) else {}
285
+ if not provider:
286
+ provider = str(model_cfg.get("provider") or "").strip()
287
+ if not model:
288
+ model = str(model_cfg.get("name") or "").strip()
289
+ return provider or "?", model or "?"
290
+
291
+
292
+ def _print_context_ring(usage: SessionUsageView) -> None:
293
+ """Print a context-window ring line for the plain path."""
294
+ if sys.stdout.isatty():
295
+ ring = format_context_ring(
296
+ estimated_prompt_tokens=usage.estimated_prompt_tokens,
297
+ last_prompt_tokens=usage.last_prompt_tokens,
298
+ context_window_tokens=usage.context_window_tokens,
299
+ summarization_threshold_tokens=usage.summarization_threshold_tokens,
300
+ )
301
+ print(ring, flush=True)
302
+ else:
303
+ print(format_context_ring_plain(usage), flush=True)
304
+
305
+
306
+ class _PlainRenderer:
307
+ """Print-based sink for the non-TTY / CI path."""
308
+
309
+ def __init__(self, *, animations_enabled: bool = True) -> None:
310
+ self.animations_enabled = animations_enabled
311
+ self.spinner = _SpinnerLine("thinking…")
312
+ self.activity = _TurnActivity()
313
+ self.md = MarkdownPlainStream()
314
+ self.assistant_open = False
315
+ self.hitl_prompt: str | None = None
316
+ self._pending_hitl: asyncio.Future[HitlAnswer] | None = None
317
+ self._last_usage: SessionUsageView | None = None
318
+ self._thinking_open = False
319
+ self._io_queue: asyncio.Queue[Coroutine[Any, Any, None] | None] = asyncio.Queue()
320
+ self._io_worker: asyncio.Task[None] | None = None
321
+
322
+ def start_io_worker(self) -> None:
323
+ self._io_worker = asyncio.create_task(self._drain_io())
324
+
325
+ async def stop_io_worker(self) -> None:
326
+ await self._io_queue.put(None)
327
+ if self._io_worker is not None:
328
+ with contextlib.suppress(asyncio.CancelledError):
329
+ await self._io_worker
330
+ self._io_worker = None
331
+
332
+ def _schedule(self, coro: Coroutine[Any, Any, None]) -> None:
333
+ self._io_queue.put_nowait(coro)
334
+
335
+ async def _drain_io(self) -> None:
336
+ while True:
337
+ item = await self._io_queue.get()
338
+ if item is None:
339
+ return
340
+ await item
341
+
342
+ def on_event(self, event: ChatUiEvent, controller: Any) -> None:
343
+ handler = getattr(self, f"_on_{event.kind}", None)
344
+ if handler is not None:
345
+ handler(event.payload, controller)
346
+
347
+ def _on_turn_started(self, _p: dict, _controller: Any) -> None:
348
+ self.assistant_open = False
349
+ self._thinking_open = False
350
+ self.md = MarkdownPlainStream()
351
+ print()
352
+ self.spinner = _SpinnerLine("thinking…")
353
+ if self.animations_enabled:
354
+ self.spinner.start()
355
+ else:
356
+ print(f"{_DIM} thinking…{_RESET}", flush=True)
357
+ self.activity = _TurnActivity()
358
+
359
+ def _on_thinking_clear(self, _p: dict, _controller: Any) -> None:
360
+ self._schedule(self.spinner.clear())
361
+ self._schedule(self.activity.cancel())
362
+
363
+ def _on_assistant_start(self, _p: dict, _controller: Any) -> None:
364
+ if self._thinking_open:
365
+ self._on_thinking_block_complete({}, _controller)
366
+ print(_ASSISTANT_PREFIX, end="", flush=True)
367
+ self.assistant_open = True
368
+
369
+ def _on_assistant_delta(self, p: dict, _controller: Any) -> None:
370
+ rendered = self.md.feed(str(p.get("delta", "")))
371
+ if rendered:
372
+ print(rendered, end="", flush=True)
373
+
374
+ def _on_tool_started(self, p: dict, _controller: Any) -> None:
375
+ tool = str(p.get("tool") or "tool")
376
+ label = str(p.get("label") or tool)
377
+ raw_args = p.get("args")
378
+ args = dict(raw_args) if isinstance(raw_args, dict) else {}
379
+ self._schedule(
380
+ self.activity.tool_started(
381
+ tool,
382
+ display=tool_display(tool, label, args),
383
+ prefix=tool_spinner_prefix(tool, label, args),
384
+ )
385
+ )
386
+
387
+ def _on_tool_finished(self, p: dict, _controller: Any) -> None:
388
+ self._schedule(
389
+ self.activity.tool_finished(
390
+ str(p.get("tool") or "tool"),
391
+ error=p.get("error"),
392
+ verbose=bool(p.get("verbose")),
393
+ result=str(p.get("result") or ""),
394
+ )
395
+ )
396
+
397
+ def _on_summarizing(self, p: dict, _controller: Any) -> None:
398
+ self._schedule(self.activity.summarizing(int(p.get("tokens") or 0)))
399
+
400
+ def _on_summarized(self, p: dict, _controller: Any) -> None:
401
+ self._schedule(self.activity.summarized(int(p.get("turns") or 0)))
402
+
403
+ def _on_grounding(self, p: dict, _controller: Any) -> None:
404
+ queries = p.get("search_queries") or []
405
+ header = "grounded search"
406
+ if queries:
407
+ header += " — " + ", ".join(f'"{q}"' for q in queries)
408
+ print(f"{_DIM} 🔎 {header}{_RESET}", flush=True)
409
+ for source in (p.get("sources") or [])[:5]:
410
+ if isinstance(source, dict) and source.get("uri"):
411
+ title = str(source.get("title") or "").strip()
412
+ uri = str(source.get("uri") or "").strip()
413
+ print(f"{_DIM} {_CYAN}{_UNDERLINE}{title or uri} ({uri}){_RESET}", flush=True)
414
+
415
+ def _on_turn_error(self, p: dict, _controller: Any) -> None:
416
+ _finish_assistant_turn(
417
+ md_stream=self.md,
418
+ assistant_label_shown=self.assistant_open,
419
+ show_usage=False,
420
+ error=str(p.get("error") or ""),
421
+ )
422
+
423
+ def _on_turn_complete(self, p: dict, _controller: Any) -> None:
424
+ _finish_assistant_turn(
425
+ md_stream=self.md,
426
+ assistant_label_shown=self.assistant_open,
427
+ show_usage=bool(p.get("usage")),
428
+ usage=p.get("usage"),
429
+ )
430
+
431
+ def _on_turn_aborted(self, p: dict, _controller: Any) -> None:
432
+ cancel_ok = p.get("cancel_ok")
433
+ if cancel_ok is False:
434
+ print(f"{_DIM}Turn aborted locally (cancel failed){_RESET}\n", flush=True)
435
+ else:
436
+ print(
437
+ f"{_DIM}Turn aborted — cancel sent; server may still finish{_RESET}\n",
438
+ flush=True,
439
+ )
440
+
441
+ def _on_hitl_required(self, p: dict, _controller: Any) -> None:
442
+ from monkeybot_cli.chat_session import HitlRequest
443
+
444
+ kind = str(p.get("hitl_kind") or "confirm")
445
+ if kind not in ("confirm", "elicit", "frontend_unsupported"):
446
+ kind = "confirm"
447
+ raw_schema = p.get("schema")
448
+ schema = dict(raw_schema) if isinstance(raw_schema, dict) else None
449
+ raw_args = p.get("arguments")
450
+ arguments = dict(raw_args) if isinstance(raw_args, dict) else {}
451
+ timeout_raw = p.get("timeout_sec")
452
+ try:
453
+ timeout_sec = float(timeout_raw) if timeout_raw is not None else None
454
+ except (TypeError, ValueError):
455
+ timeout_sec = None
456
+ req = HitlRequest(
457
+ kind=kind, # type: ignore[arg-type]
458
+ prompt=str(p.get("prompt") or ""),
459
+ tool_call_id=str(p.get("tool_call_id") or ""),
460
+ elicitation_id=str(p.get("elicitation_id") or ""),
461
+ tool_name=str(p.get("tool_name") or ""),
462
+ schema=schema,
463
+ arguments=arguments,
464
+ timeout_sec=timeout_sec,
465
+ )
466
+ self.hitl_prompt = format_hitl_plain_prompt(req)
467
+ print(f"{_DIM}{self.hitl_prompt}{_RESET}", flush=True)
468
+
469
+ def _on_hitl_failed(self, p: dict, _controller: Any) -> None:
470
+ print(f"{_RED}{p.get('message')}{_RESET}", flush=True)
471
+
472
+ def _on_hitl_frontend_unsupported(self, p: dict, _controller: Any) -> None:
473
+ print(
474
+ f"\x1b[33mFrontend tool '{p.get('name')}' requires a UI — "
475
+ f"not supported in terminal chat.\x1b[0m"
476
+ )
477
+
478
+ def _on_session_busy(self, _p: dict, _controller: Any) -> None:
479
+ print("Session busy — wait for the current turn to finish.", file=sys.stderr)
480
+
481
+ def _on_error(self, p: dict, _controller: Any) -> None:
482
+ print(str(p.get("message") or ""), file=sys.stderr)
483
+
484
+ def _on_stream_failed(self, p: dict, _controller: Any) -> None:
485
+ print(str(p.get("message") or ""), file=sys.stderr)
486
+
487
+ def _on_thinking_trace(self, p: dict, _controller: Any) -> None:
488
+ print(f"{_DIM}[thinking] {p.get('text')}{_RESET}", flush=True)
489
+
490
+ def _on_thinking_block_delta(self, p: dict, _controller: Any) -> None:
491
+ text = str(p.get("text") or "")
492
+ if not text:
493
+ return
494
+ if not self._thinking_open:
495
+ self.spinner.stop()
496
+ print(f"{_DIM}{_BOLD}Thinking...{_RESET}", flush=True)
497
+ self._thinking_open = True
498
+ print(f"{_DIM}{text}{_RESET}", end="", flush=True)
499
+
500
+ def _on_thinking_block_complete(self, _p: dict, _controller: Any) -> None:
501
+ if self._thinking_open:
502
+ print(flush=True)
503
+ print(f"{_DIM}{_BOLD}...done thinking.{_RESET}", flush=True)
504
+ self._thinking_open = False
505
+
506
+ def _on_usage_updated(self, p: dict, _controller: Any) -> None:
507
+ usage = p.get("usage")
508
+ if isinstance(usage, SessionUsageView):
509
+ self._last_usage = usage
510
+ _print_context_ring(usage)
511
+
512
+ # Intentional no-ops for TUI-only / optional plain events (parity with EVENT_KINDS).
513
+ def _on_session_ready(self, _p: dict, _controller: Any) -> None:
514
+ return
515
+
516
+ def _on_connection_state(self, _p: dict, _controller: Any) -> None:
517
+ return
518
+
519
+ def _on_transcript_backfill(self, _p: dict, _controller: Any) -> None:
520
+ return
521
+
522
+ def _on_thinking(self, _p: dict, _controller: Any) -> None:
523
+ return
524
+
525
+ def _on_stream_ended(self, _p: dict, _controller: Any) -> None:
526
+ return
527
+
528
+ def _on_voice_state(self, _p: dict, _controller: Any) -> None:
529
+ return
530
+
531
+ def _on_audio_chunk(self, _p: dict, _controller: Any) -> None:
532
+ return
533
+
534
+ def _on_device_error(self, p: dict, _controller: Any) -> None:
535
+ msg = str(p.get("message") or "Audio device error")
536
+ hint = p.get("hint")
537
+ print(f"{_DIM}{msg}{_RESET}", flush=True)
538
+ if hint:
539
+ print(f"{_DIM}{hint}{_RESET}", flush=True)
540
+
541
+ def _on_user_transcript(self, p: dict, _controller: Any) -> None:
542
+ text = str(p.get("text") or "").strip()
543
+ if text and p.get("is_final", True):
544
+ print(f"{_USER_PROMPT} {text}", flush=True)
545
+
546
+
547
+ async def _plain_chat_session(
548
+ args: argparse.Namespace,
549
+ base: str,
550
+ *,
551
+ spawned_gateway: bool,
552
+ ) -> int:
553
+ interrupt = asyncio.Event()
554
+ loop = asyncio.get_running_loop()
555
+ with contextlib.suppress(NotImplementedError):
556
+ loop.add_signal_handler(signal.SIGINT, interrupt.set)
557
+
558
+ animations_enabled = not (
559
+ bool(getattr(args, "no_animations", False))
560
+ or os.environ.get("MONKEYBOT_CHAT_NO_ANIMATIONS", "").strip().lower()
561
+ in ("1", "true", "yes")
562
+ )
563
+ renderer = _PlainRenderer(animations_enabled=animations_enabled)
564
+ renderer.start_io_worker()
565
+
566
+ async def hitl_reader(req: Any) -> HitlAnswer:
567
+ from monkeybot_cli.chat_session import HitlRequest
568
+
569
+ assert isinstance(req, HitlRequest)
570
+ # Prompt already printed via _on_hitl_required; short line for input.
571
+ label = "y/n" if req.kind == "confirm" else "input"
572
+ ans = await _read_line(f"{label}: ", interrupt)
573
+ if ans is None or interrupt.is_set():
574
+ return HitlAnswer(cancelled=True)
575
+ lower = ans.strip().lower()
576
+ if req.kind == "confirm":
577
+ if lower in ("y", "yes"):
578
+ return HitlAnswer(approved=True, text=ans)
579
+ return HitlAnswer(approved=False, text=ans)
580
+ return HitlAnswer(text=ans)
581
+
582
+ controller = ChatSessionController(
583
+ base=base,
584
+ model_provider=args.model_provider,
585
+ model_name=args.model_name,
586
+ show_thinking=args.show_thinking,
587
+ verbose=args.verbose,
588
+ show_usage=args.usage,
589
+ emit=lambda e: renderer.on_event(e, controller),
590
+ hitl_reader=hitl_reader,
591
+ resume_session_id=getattr(args, "session", None),
592
+ )
593
+ try:
594
+ await controller.connect()
595
+ except RuntimeError as exc:
596
+ print(str(exc), file=sys.stderr)
597
+ await renderer.stop_io_worker()
598
+ return 1
599
+
600
+ hint = "Type /bye to exit"
601
+ if spawned_gateway:
602
+ hint += " (stops the gateway)"
603
+ print(f"{_DIM}{hint}. Ctrl-C also exits.{_RESET}\n")
604
+
605
+ try:
606
+ while not interrupt.is_set() and controller.stream_alive:
607
+ user_line = await _read_line(_USER_PROMPT, interrupt)
608
+ if user_line is None or interrupt.is_set():
609
+ break
610
+ if not user_line.strip():
611
+ continue
612
+ if is_exit_command(user_line):
613
+ if spawned_gateway:
614
+ print(f"\n{_DIM}Goodbye — shutting down gateway…{_RESET}")
615
+ else:
616
+ print(f"\n{_DIM}Goodbye.{_RESET}")
617
+ break
618
+ await controller.submit(user_line)
619
+ finally:
620
+ await controller.close()
621
+ await renderer.stop_io_worker()
622
+ return 1 if controller.stream_error else 0
623
+
624
+
625
+ class _SpawnedGateway(NamedTuple):
626
+ proc: subprocess.Popen[str]
627
+ log_path: Path
628
+ log_file: TextIO
629
+
630
+
631
+ def _format_gateway_log_tail(log_path: Path, *, max_lines: int = 40) -> str:
632
+ if not log_path.is_file():
633
+ return ""
634
+ text = log_path.read_text(encoding="utf-8", errors="replace").strip()
635
+ if not text:
636
+ return ""
637
+ return "\n".join(text.splitlines()[-max_lines:])
638
+
639
+
640
+ def _tail_gateway_log(gateway: _SpawnedGateway, *, max_lines: int = 40) -> str:
641
+ if gateway.proc.poll() is None:
642
+ with contextlib.suppress(subprocess.TimeoutExpired):
643
+ gateway.proc.wait(timeout=1)
644
+ else:
645
+ gateway.proc.wait()
646
+ gateway.log_file.flush()
647
+ with contextlib.suppress(OSError):
648
+ gateway.log_file.close()
649
+ return _format_gateway_log_tail(gateway.log_path, max_lines=max_lines)
650
+
651
+
652
+ def _cleanup_gateway_log(gateway: _SpawnedGateway | None) -> None:
653
+ if gateway is None:
654
+ return
655
+ with contextlib.suppress(OSError):
656
+ if not gateway.log_file.closed:
657
+ gateway.log_file.close()
658
+ with contextlib.suppress(OSError):
659
+ gateway.log_path.unlink(missing_ok=True)
660
+
661
+
662
+ def _spawn_gateway(config_path: Path | None, agent_root: Path, port: int) -> _SpawnedGateway:
663
+ env = os.environ.copy()
664
+ if config_path is not None:
665
+ env["MONKEYBOT_CONFIG"] = str(config_path)
666
+ env["PORT"] = str(port)
667
+ env.setdefault("LOG_LEVEL", "error")
668
+ log_file = tempfile.NamedTemporaryFile(
669
+ mode="w+",
670
+ prefix="monkeybot-gateway-",
671
+ suffix=".log",
672
+ delete=False,
673
+ encoding="utf-8",
674
+ errors="replace",
675
+ )
676
+ proc = subprocess.Popen(
677
+ gateway_argv(resolve_runtime_python(agent_root)),
678
+ env=env,
679
+ cwd=agent_root,
680
+ stdout=subprocess.DEVNULL,
681
+ stderr=log_file,
682
+ )
683
+ return _SpawnedGateway(proc=proc, log_path=Path(log_file.name), log_file=log_file)
684
+
685
+
686
+ def run_chat(args: argparse.Namespace) -> int:
687
+ cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
688
+ config_path = resolve_config(args.config, cwd=cwd)
689
+ load_agent_dotenv(cwd=cwd, config_path=config_path)
690
+ agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
691
+
692
+ base = _resolve_base_url(args, config_path)
693
+ attach = args.attach or bool(args.url)
694
+
695
+ proc: subprocess.Popen[str] | None = None
696
+ spawned: _SpawnedGateway | None = None
697
+ if not attach:
698
+ if config_path is None:
699
+ print(
700
+ "No monkeybot.yaml found in this directory. cd into an agent root "
701
+ "(containing monkeybot_config/monkeybot.yaml) or pass --config / --attach.",
702
+ file=sys.stderr,
703
+ )
704
+ return 1
705
+ _, cfg_doc = load_config_doc(config_path)
706
+ if is_sandbox_enabled(cfg_doc):
707
+ if not ensure_opensandbox_for_agent(
708
+ agent_root,
709
+ server_url=server_url_from_config(cfg_doc),
710
+ ):
711
+ print(
712
+ f"{_DIM}Continuing without a healthy OpenSandbox — run_command may fail.{_RESET}",
713
+ file=sys.stderr,
714
+ )
715
+ port = args.port if args.port else _port_from_config(config_path)
716
+ spawned = _spawn_gateway(config_path, agent_root, port)
717
+ proc = spawned.proc
718
+ if not _wait_for_health(base, proc):
719
+ print("Gateway failed to start.", file=sys.stderr)
720
+ log_tail = _tail_gateway_log(spawned)
721
+ if log_tail:
722
+ print(f"{_DIM}--- gateway log ---{_RESET}", file=sys.stderr)
723
+ print(log_tail, file=sys.stderr)
724
+ print(f"{_DIM}--- end log ---{_RESET}", file=sys.stderr)
725
+ else:
726
+ print("Run `monkeybot run` to see logs.", file=sys.stderr)
727
+ if proc.poll() is None:
728
+ proc.terminate()
729
+ with contextlib.suppress(subprocess.TimeoutExpired):
730
+ proc.wait(timeout=5)
731
+ _cleanup_gateway_log(spawned)
732
+ return 1
733
+
734
+ provider, model = _model_banner_fields(args, config_path)
735
+ animations_enabled = not (
736
+ bool(getattr(args, "no_animations", False))
737
+ or os.environ.get("MONKEYBOT_CHAT_NO_ANIMATIONS", "").strip().lower()
738
+ in ("1", "true", "yes")
739
+ )
740
+ theme_choice = str(getattr(args, "theme", "auto") or "auto")
741
+ try:
742
+ if use_textual_tui():
743
+ return run_chat_tui(
744
+ base=base,
745
+ agent_root=agent_root,
746
+ provider=provider,
747
+ model=model,
748
+ spawned_gateway=not attach,
749
+ model_provider=args.model_provider,
750
+ model_name=args.model_name,
751
+ show_thinking=args.show_thinking,
752
+ verbose=args.verbose,
753
+ show_usage=args.usage,
754
+ resume_session_id=getattr(args, "session", None),
755
+ animations_enabled=animations_enabled,
756
+ theme_choice=theme_choice,
757
+ )
758
+ return asyncio.run(_plain_chat_session(args, base, spawned_gateway=not attach))
759
+ except KeyboardInterrupt:
760
+ sys.stdout.write("\n")
761
+ sys.stdout.flush()
762
+ if not attach:
763
+ print(f"{_DIM}Shutting down gateway…{_RESET}", file=sys.stderr)
764
+ return 130
765
+ finally:
766
+ if proc and proc.poll() is None:
767
+ proc.kill()
768
+ with contextlib.suppress(subprocess.TimeoutExpired):
769
+ proc.wait(timeout=1)
770
+ if spawned is not None:
771
+ _cleanup_gateway_log(spawned)
772
+
773
+
774
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
775
+ p = subparsers.add_parser(
776
+ "chat",
777
+ help="Talk to the agent (starts the gateway automatically from the current dir)",
778
+ )
779
+ p.add_argument("--cwd", help="Agent root (defaults to the current directory)")
780
+ p.add_argument(
781
+ "--config", help="Path to monkeybot.yaml (defaults to ./monkeybot_config/monkeybot.yaml)"
782
+ )
783
+ p.add_argument(
784
+ "--attach",
785
+ action="store_true",
786
+ help="Connect to an already-running gateway instead of spawning one",
787
+ )
788
+ p.add_argument(
789
+ "--url", help="Gateway base URL (implies --attach; overrides config-derived port)"
790
+ )
791
+ p.add_argument("--port", type=int, help="Gateway port (overrides runtime.port from config)")
792
+ p.add_argument("--show-thinking", action="store_true", help="Show thinking events")
793
+ p.add_argument(
794
+ "--verbose", action="store_true", help="Show tool result payloads after each tool"
795
+ )
796
+ p.add_argument("--usage", action="store_true", help="Show token usage after each turn")
797
+ p.add_argument(
798
+ "--session",
799
+ dest="session",
800
+ help="Resume an existing gateway session id (with transcript backfill when available)",
801
+ )
802
+ p.add_argument(
803
+ "--model-provider", dest="model_provider", help="Override model provider for session"
804
+ )
805
+ p.add_argument("--model-name", dest="model_name", help="Override model name for session")
806
+ p.add_argument(
807
+ "--no-animations",
808
+ action="store_true",
809
+ help="Disable decorative spinners / batched markdown flush (also: MONKEYBOT_CHAT_NO_ANIMATIONS=1)",
810
+ )
811
+ p.add_argument(
812
+ "--theme",
813
+ choices=("auto", "dark", "light"),
814
+ default="auto",
815
+ help="Chat TUI theme (auto uses COLORFGBG when set; default dark)",
816
+ )
817
+ p.set_defaults(func=run_chat)