monkeybot-cli 0.5.0__tar.gz → 0.5.1__tar.gz

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 (88) hide show
  1. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/PKG-INFO +1 -1
  2. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/pyproject.toml +1 -1
  3. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/skills/monkeybot/SKILL.md +3 -1
  4. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/skills/monkeybot/references/config-sections.md +1 -1
  5. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_local_shell.py +7 -43
  6. monkeybot_cli-0.5.1/src/monkeybot_cli/commands/run_cmd.py +197 -0
  7. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/extras_catalog.py +2 -1
  8. monkeybot_cli-0.5.1/src/monkeybot_cli/process_tree.py +44 -0
  9. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/providers.py +11 -0
  10. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold.py +1 -0
  11. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/env.example +4 -1
  12. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +13 -1
  13. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_local_shell.py +16 -15
  14. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_extras_catalog.py +5 -0
  15. monkeybot_cli-0.5.1/tests/test_run_cmd.py +277 -0
  16. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_scaffold.py +1 -0
  17. monkeybot_cli-0.5.0/src/monkeybot_cli/commands/run_cmd.py +0 -63
  18. monkeybot_cli-0.5.0/tests/test_run_cmd.py +0 -90
  19. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/.gitignore +0 -0
  20. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/__init__.py +0 -0
  21. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_file_index.py +0 -0
  22. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_renderer.py +0 -0
  23. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_session.py +0 -0
  24. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_status_bar.py +0 -0
  25. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_theme.py +0 -0
  26. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tool_display.py +0 -0
  27. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tui.py +0 -0
  28. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/chat_tui_widgets.py +0 -0
  29. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/__init__.py +0 -0
  30. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/chat.py +0 -0
  31. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/doctor.py +0 -0
  32. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/loop.py +0 -0
  33. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/new.py +0 -0
  34. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/refresh.py +0 -0
  35. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/talk.py +0 -0
  36. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/commands/validate.py +0 -0
  37. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/compat.py +0 -0
  38. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/config_resolve.py +0 -0
  39. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/exit_commands.py +0 -0
  40. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/gateway_health.py +0 -0
  41. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/main.py +0 -0
  42. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/opensandbox_lifecycle.py +0 -0
  43. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/output.py +0 -0
  44. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/__init__.py +0 -0
  45. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/audio_io.py +0 -0
  46. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/client.py +0 -0
  47. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
  48. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/push_to_talk.py +0 -0
  49. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/session.py +0 -0
  50. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/session_controller.py +0 -0
  51. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/talk_ui.py +0 -0
  52. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
  53. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/runtime_python.py +0 -0
  54. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
  55. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
  56. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
  57. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -0
  58. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +0 -0
  59. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
  60. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
  61. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
  62. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
  63. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
  64. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
  65. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
  66. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
  67. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/session_controller.py +0 -0
  68. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/src/monkeybot_cli/terminal_markdown.py +0 -0
  69. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_continue.py +0 -0
  70. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_e2e.py +0 -0
  71. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_errors.py +0 -0
  72. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_file_index.py +0 -0
  73. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_grounding.py +0 -0
  74. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_renderer_parity.py +0 -0
  75. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_session.py +0 -0
  76. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_status_bar.py +0 -0
  77. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_theme.py +0 -0
  78. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_tool_display.py +0 -0
  79. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_chat_tui.py +0 -0
  80. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_cli.py +0 -0
  81. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_config_resolve.py +0 -0
  82. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_doctor.py +0 -0
  83. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_gateway_health.py +0 -0
  84. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_opensandbox_lifecycle.py +0 -0
  85. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_realtime_session_controller.py +0 -0
  86. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_runtime_python.py +0 -0
  87. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/tests/test_terminal_markdown.py +0 -0
  88. {monkeybot_cli-0.5.0 → monkeybot_cli-0.5.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: monkeybot-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: CLI to create, configure, validate, and chat with monkeybot agents.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "monkeybot-cli"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "CLI to create, configure, validate, and chat with monkeybot agents."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -111,7 +111,9 @@ uv sync
111
111
  | `vertex-claude` | `GCP_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` / `ANTHROPIC_VERTEX_PROJECT_ID` (ADC) | `monkeybot[vertex-claude]` |
112
112
  | `aws_bedrock` | `AWS_ACCESS_KEY_ID` / `AWS_PROFILE` + `AWS_REGION` | `monkeybot[bedrock]` |
113
113
  | `huggingface` | `HF_TOKEN` (or `HUGGINGFACE_API_KEY`) | `monkeybot[huggingface]` |
114
- | `ollama` | None required — `OLLAMA_BASE_URL` (default `http://localhost:11434`) for a non-default server | `monkeybot[ollama]` |
114
+ | `ollama-cloud` | `OLLAMA_API_KEY` | `monkeybot[ollama]` |
115
+ | `ollama-local` | None required — `OLLAMA_BASE_URL` (default `http://localhost:11434`) | `monkeybot[ollama]` |
116
+ | `ollama` | Legacy auto-route (key + blank URL = cloud; explicit URL wins) | `monkeybot[ollama]` |
115
117
 
116
118
  **Agent-first dependencies.** The CLI is thin — it does **not** install provider/storage extras globally. `monkeybot new` scaffolds a `pyproject.toml` with the selected provider (and any `--with` extras). Run plain `uv sync` in the agent directory. `monkeybot run` / `chat` spawn the gateway from that project's interpreter (`.venv/bin/python`, else `uv run python`), and `doctor` checks extras in that same interpreter. For a config-only tree (just `monkeybot_config/`, no `pyproject.toml`) the gateway uses the CLI's interpreter when it already has MonkeyBot 3.x (and MemPalace if memory is on). If memory is enabled and that interpreter cannot import MemPalace, `run` / `chat` provision a cached CLI-managed venv under `~/.cache/monkeybot/runtimes/` holding `monkeybot[memory]` pinned to the running core (never rewrites a `pyproject.toml`). `doctor` reuses that cache when it is already present. Otherwise extras must be installed in the CLI env (`uv tool install --with 'monkeybot[<extra>]' monkeybot-cli`).
117
119
 
@@ -45,7 +45,7 @@ Validate check ids: `paths.agent_md.exists`, `paths.skills_path.exists`, `paths.
45
45
  | `max_turns` | `1000` | Hard cap on turns per run |
46
46
  | `summarization_model` | (main model) | Cheaper model for history summarization (env `CONTEXT_SUMMARIZATION_MODEL`) |
47
47
 
48
- Validate check ids: `model.provider.supported`, `model.name.present`. Supported YAML providers: `gemini`/`vertex`, `openai`, `anthropic`, `vertex-claude`, `huggingface`, `ollama`, `aws_bedrock`, `fake`.
48
+ Validate check ids: `model.provider.supported`, `model.name.present`. Supported YAML providers: `gemini`/`vertex`, `openai`, `anthropic`, `vertex-claude`, `huggingface`, `ollama-cloud`, `ollama-local`, `ollama`, `aws_bedrock`, `fake`.
49
49
 
50
50
  ## `gcp` / `anthropic_vertex` (non-secret identifiers)
51
51
 
@@ -5,54 +5,18 @@ Output is local-only — never sent to the agent.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import os
9
- import signal
10
8
  import subprocess
11
9
  import sys
12
10
  import threading
13
11
  from pathlib import Path
14
12
 
15
- _IS_WINDOWS = sys.platform == "win32"
13
+ from monkeybot_cli.process_tree import IS_WINDOWS, kill_process_tree, popen_kwargs_for_platform
16
14
 
17
15
  # Cap capture before TUI truncation so `!yes`-style floods cannot OOM the process.
18
16
  _MAX_CAPTURE_CHARS = 100_000
19
17
  _READ_CHUNK = 4_096
20
18
 
21
19
 
22
- def _popen_kwargs_for_platform() -> dict[str, object]:
23
- if _IS_WINDOWS:
24
- # getattr fallback: this constant only exists on Windows builds of
25
- # `subprocess`, so a plain attribute access breaks importing/testing
26
- # this module on POSIX even though the branch never runs there.
27
- return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
28
- return {"start_new_session": True}
29
-
30
-
31
- def _kill_process_tree(pid: int) -> None:
32
- """Best-effort kill of the shell process group/tree.
33
-
34
- Swallow lookup/permission races — leader exit can make killpg raise
35
- ``PermissionError`` even though descendants still need a signal.
36
- """
37
- if _IS_WINDOWS:
38
- try:
39
- subprocess.run(
40
- ["taskkill", "/F", "/T", "/PID", str(pid)],
41
- capture_output=True,
42
- check=False,
43
- )
44
- except OSError:
45
- pass
46
- return
47
- try:
48
- os.killpg(pid, signal.SIGKILL)
49
- except (ProcessLookupError, PermissionError, OSError):
50
- try:
51
- os.kill(pid, signal.SIGKILL)
52
- except (ProcessLookupError, PermissionError, OSError):
53
- pass
54
-
55
-
56
20
  def _append_capped(chunks: list[str], total: int, data: str) -> tuple[int, bool]:
57
21
  """Append ``data`` up to the capture cap. Returns (new_total, hit_cap)."""
58
22
  remain = _MAX_CAPTURE_CHARS - total
@@ -84,7 +48,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
84
48
  stdout=subprocess.PIPE,
85
49
  stderr=subprocess.STDOUT,
86
50
  text=True,
87
- **_popen_kwargs_for_platform(),
51
+ **popen_kwargs_for_platform(),
88
52
  )
89
53
  assert proc.stdout is not None
90
54
 
@@ -104,7 +68,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
104
68
  state["total"], hit = _append_capped(chunks, state["total"], chunk)
105
69
  if hit:
106
70
  state["capped"] = True
107
- _kill_process_tree(proc.pid)
71
+ kill_process_tree(proc.pid)
108
72
  break
109
73
  except (ValueError, OSError):
110
74
  # stdout closed from the main thread to unblock a stuck read.
@@ -119,12 +83,12 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
119
83
  proc.wait(timeout=timeout)
120
84
  except subprocess.TimeoutExpired:
121
85
  timed_out = True
122
- _kill_process_tree(proc.pid)
86
+ kill_process_tree(proc.pid)
123
87
 
124
88
  # Shell may have exited while a descendant still holds the pipe —
125
89
  # kill the tree and close stdout so the reader cannot block forever.
126
90
  if reader.is_alive():
127
- _kill_process_tree(proc.pid)
91
+ kill_process_tree(proc.pid)
128
92
  stop_reader.set()
129
93
  try:
130
94
  proc.stdout.close()
@@ -139,7 +103,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
139
103
  reader.join(timeout=0.1)
140
104
  except Exception:
141
105
  timed_out = True
142
- _kill_process_tree(proc.pid)
106
+ kill_process_tree(proc.pid)
143
107
  stop_reader.set()
144
108
  try:
145
109
  proc.stdout.close()
@@ -149,7 +113,7 @@ def run_local_shell(command: str, cwd: Path, *, timeout: float = 120.0) -> tuple
149
113
 
150
114
  try:
151
115
  if proc.poll() is None:
152
- _kill_process_tree(proc.pid)
116
+ kill_process_tree(proc.pid)
153
117
  try:
154
118
  proc.wait(timeout=1)
155
119
  except Exception:
@@ -0,0 +1,197 @@
1
+ """monkeybot run — launch the SSE gateway as a subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from types import FrameType
14
+ from typing import Any
15
+
16
+ from monkeybot_cli.config_resolve import (
17
+ load_agent_dotenv,
18
+ load_config_doc,
19
+ resolve_agent_root,
20
+ resolve_config,
21
+ )
22
+ from monkeybot_cli.opensandbox_lifecycle import (
23
+ ensure_opensandbox_for_agent,
24
+ is_sandbox_enabled,
25
+ server_url_from_config,
26
+ )
27
+ from monkeybot_cli.process_tree import IS_WINDOWS, kill_process_tree, popen_kwargs_for_platform
28
+ from monkeybot_cli.runtime_python import gateway_argv, prepare_runtime_python
29
+
30
+ _SHUTDOWN_TIMEOUT_SECS = 5.0
31
+ _KILL_TIMEOUT_SECS = 1.0
32
+ _Handler = Callable[[int, FrameType | None], Any] | int | None
33
+
34
+
35
+ def _cli_exit_status(rc: int | None, received: int | None) -> int:
36
+ """Map a child ``wait()`` status to a CLI exit code.
37
+
38
+ ``wait()`` returns a negative signal number when the child dies from a
39
+ signal; ``SystemExit(-15)`` becomes shell status 241. Unix convention is
40
+ ``128 + signal`` (143 for SIGTERM, 130 for SIGINT).
41
+
42
+ When the shim received SIGINT, return 130 even if the child exited 0 —
43
+ that matches the conventional Ctrl-C exit status.
44
+ """
45
+ if received == signal.SIGINT:
46
+ return 130
47
+ if rc is None:
48
+ return 1
49
+ if rc < 0:
50
+ return 128 + (-rc)
51
+ return rc
52
+
53
+
54
+ def _signal_process_group(proc: subprocess.Popen[bytes], signum: int) -> None:
55
+ if proc.poll() is not None:
56
+ return
57
+ if IS_WINDOWS:
58
+ try:
59
+ proc.send_signal(signum)
60
+ except OSError as exc:
61
+ print(f"failed to signal gateway: {exc}", file=sys.stderr)
62
+ return
63
+ try:
64
+ os.killpg(proc.pid, signum)
65
+ return
66
+ except ProcessLookupError:
67
+ return
68
+ except OSError as exc:
69
+ try:
70
+ proc.send_signal(signum)
71
+ except OSError:
72
+ print(f"failed to signal gateway: {exc}", file=sys.stderr)
73
+
74
+
75
+ def run_gateway_process(
76
+ cmd: list[str],
77
+ *,
78
+ env: dict[str, str],
79
+ cwd: Path,
80
+ shutdown_timeout: float = _SHUTDOWN_TIMEOUT_SECS,
81
+ kill_timeout: float = _KILL_TIMEOUT_SECS,
82
+ ) -> int:
83
+ """Start the gateway and forward stop signals to its process group.
84
+
85
+ Electron sends SIGTERM only to this CLI shim. ``subprocess.run`` dies on
86
+ that signal and leaves uvicorn holding the port. On POSIX the child is
87
+ started in a new session so SIGTERM/SIGINT reach the whole tree (``uv run``
88
+ plus the gateway grandchild), then escalate to SIGKILL if it ignores the
89
+ signal for ``shutdown_timeout`` seconds.
90
+
91
+ ``start_new_session`` also detaches the gateway from the terminal's
92
+ foreground process group (stdin reads would raise SIGTTIN). That is fine
93
+ for uvicorn today and required for ``killpg``.
94
+
95
+ On Windows, ``send_signal(SIGTERM)`` only terminates the immediate child;
96
+ grandchildren may still be orphaned.
97
+
98
+ Interactive Ctrl-C already hits the foreground process group; forwarding
99
+ SIGINT matters here because the child is in a new session and would
100
+ otherwise keep running.
101
+ """
102
+ received: int | None = None
103
+ proc: subprocess.Popen[bytes] | None = None
104
+ signal_forwarded = False
105
+ deadline: float | None = None
106
+
107
+ def _forward(signum: int, _frame: object | None) -> None:
108
+ nonlocal received, signal_forwarded, deadline
109
+ if received is None:
110
+ received = signum
111
+ if proc is not None:
112
+ _signal_process_group(proc, signum)
113
+ signal_forwarded = True
114
+ if deadline is None:
115
+ deadline = time.monotonic() + shutdown_timeout
116
+
117
+ restored: dict[int, _Handler] = {}
118
+ forwarded = [signal.SIGINT, signal.SIGTERM]
119
+ if not IS_WINDOWS:
120
+ forwarded.append(signal.SIGHUP)
121
+ for sig in forwarded:
122
+ try:
123
+ restored[sig] = signal.signal(sig, _forward)
124
+ except (ValueError, OSError) as exc:
125
+ print(f"failed to install {sig.name} handler: {exc}", file=sys.stderr)
126
+ try:
127
+ proc = subprocess.Popen(
128
+ cmd,
129
+ env=env,
130
+ cwd=cwd,
131
+ **popen_kwargs_for_platform(),
132
+ )
133
+ if received is not None and not signal_forwarded:
134
+ _signal_process_group(proc, received)
135
+ if deadline is None:
136
+ deadline = time.monotonic() + shutdown_timeout
137
+ while True:
138
+ try:
139
+ rc = proc.wait(timeout=0.25)
140
+ except subprocess.TimeoutExpired:
141
+ if deadline is None or time.monotonic() < deadline:
142
+ continue
143
+ print(
144
+ "gateway did not exit after signal; killing process tree",
145
+ file=sys.stderr,
146
+ )
147
+ kill_process_tree(proc.pid)
148
+ try:
149
+ proc.wait(timeout=kill_timeout)
150
+ except subprocess.TimeoutExpired:
151
+ print("gateway still running after kill", file=sys.stderr)
152
+ rc = proc.returncode
153
+ return _cli_exit_status(rc, received)
154
+ finally:
155
+ for sig, handler in restored.items():
156
+ try:
157
+ signal.signal(sig, handler)
158
+ except (ValueError, OSError) as exc:
159
+ print(f"failed to restore {sig.name} handler: {exc}", file=sys.stderr)
160
+
161
+
162
+ def run_run(args: argparse.Namespace) -> int:
163
+ cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
164
+ config_path = resolve_config(args.config, cwd=cwd)
165
+ load_agent_dotenv(cwd=cwd, config_path=config_path)
166
+ env = os.environ.copy()
167
+ if config_path is not None:
168
+ env["MONKEYBOT_CONFIG"] = str(config_path)
169
+ if args.port:
170
+ env["PORT"] = str(args.port)
171
+ # --cwd intentionally pins the subprocess and runtime directory. Without
172
+ # it, an explicit config determines the agent root.
173
+ agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
174
+ if config_path is not None:
175
+ _, cfg_doc = load_config_doc(config_path)
176
+ if is_sandbox_enabled(cfg_doc):
177
+ if not ensure_opensandbox_for_agent(
178
+ agent_root,
179
+ server_url=server_url_from_config(cfg_doc),
180
+ # Fail fast: Mac app health-checks the gateway immediately.
181
+ docker_wait_secs=2.0,
182
+ ):
183
+ print(
184
+ "Continuing without a healthy OpenSandbox — run_command may fail.",
185
+ flush=True,
186
+ )
187
+ runtime = prepare_runtime_python(agent_root, config_path)
188
+ cmd = gateway_argv(runtime)
189
+ return run_gateway_process(cmd, env=env, cwd=agent_root)
190
+
191
+
192
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
193
+ p = subparsers.add_parser("run", help="Start the monkeybot SSE gateway")
194
+ p.add_argument("--config", help="Path to monkeybot.yaml (sets MONKEYBOT_CONFIG)")
195
+ p.add_argument("--port", type=int, help="Listen port (sets PORT)")
196
+ p.add_argument("--cwd", help="Working directory for the gateway process")
197
+ p.set_defaults(func=run_run)
@@ -24,7 +24,8 @@ PROVIDER_CHOICES: tuple[ExtraChoice, ...] = (
24
24
  ExtraChoice("vertex-claude", "Claude on Vertex AI"),
25
25
  ExtraChoice("aws_bedrock", "AWS Bedrock"),
26
26
  ExtraChoice("huggingface", "Hugging Face"),
27
- ExtraChoice("ollama", "Ollama (local)"),
27
+ ExtraChoice("ollama-cloud", "Ollama Cloud"),
28
+ ExtraChoice("ollama-local", "Ollama (local)"),
28
29
  ExtraChoice("nvidia", "NVIDIA NIM"),
29
30
  ExtraChoice("openrouter", "OpenRouter"),
30
31
  )
@@ -0,0 +1,44 @@
1
+ """Shared helpers for spawning and tearing down process groups/trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+
10
+ IS_WINDOWS = sys.platform == "win32"
11
+
12
+
13
+ def popen_kwargs_for_platform() -> dict[str, object]:
14
+ if IS_WINDOWS:
15
+ # getattr fallback: this constant only exists on Windows builds of
16
+ # `subprocess`, so a plain attribute access breaks importing/testing
17
+ # this module on POSIX even though the branch never runs there.
18
+ return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
19
+ return {"start_new_session": True}
20
+
21
+
22
+ def kill_process_tree(pid: int) -> None:
23
+ """Best-effort kill of the shell process group/tree.
24
+
25
+ Swallow lookup/permission races — leader exit can make killpg raise
26
+ ``PermissionError`` even though descendants still need a signal.
27
+ """
28
+ if IS_WINDOWS:
29
+ try:
30
+ subprocess.run(
31
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
32
+ capture_output=True,
33
+ check=False,
34
+ )
35
+ except OSError:
36
+ pass
37
+ return
38
+ try:
39
+ os.killpg(pid, signal.SIGKILL)
40
+ except (ProcessLookupError, PermissionError, OSError):
41
+ try:
42
+ os.kill(pid, signal.SIGKILL)
43
+ except (ProcessLookupError, PermissionError, OSError):
44
+ pass
@@ -59,6 +59,17 @@ PROVIDER_SPECS: dict[str, ProviderSpec] = {
59
59
  (),
60
60
  credentials_optional=True,
61
61
  ),
62
+ "ollama-cloud": ProviderSpec(
63
+ ("ollama-cloud", "ollama_cloud"),
64
+ "ollama",
65
+ ("OLLAMA_API_KEY",),
66
+ ),
67
+ "ollama-local": ProviderSpec(
68
+ ("ollama-local", "ollama_local"),
69
+ "ollama",
70
+ (),
71
+ credentials_optional=True,
72
+ ),
62
73
  "fake": ProviderSpec(("fake",), None, (), credentials_optional=True),
63
74
  }
64
75
 
@@ -138,6 +138,7 @@ def ensure_workspace(dest: Path, *, force: bool) -> list[str]:
138
138
  "browser/playbooks",
139
139
  "browser/Screenshots",
140
140
  "generated-media/images",
141
+ "generated-media/video",
141
142
  ):
142
143
  path = workspace / rel
143
144
  path.mkdir(parents=True, exist_ok=True)
@@ -28,7 +28,10 @@ DB_URL=sqlite:///data/monkeybot.db
28
28
  # Hugging Face
29
29
  # HF_TOKEN=
30
30
 
31
- # Ollama (local modelsno API key needed)
31
+ # Ollama Cloud (model.provider: ollama-cloud)key from ollama.com/settings/keys
32
+ # OLLAMA_API_KEY=
33
+
34
+ # Ollama local (model.provider: ollama-local) — no API key needed
32
35
  # OLLAMA_BASE_URL=http://localhost:11434
33
36
 
34
37
  # Optional web search
@@ -87,7 +87,7 @@ paths:
87
87
  workspace_root: ./workspace
88
88
 
89
89
  model:
90
- # gemini | openai | anthropic | vertex-claude | huggingface | ollama | nvidia | openrouter | aws_bedrock | fake
90
+ # gemini | openai | anthropic | vertex-claude | huggingface | ollama-cloud | ollama-local | ollama | nvidia | openrouter | aws_bedrock | fake
91
91
  provider: gemini
92
92
  name: gemini-3.1-flash-live-preview
93
93
  temperature: 0.7
@@ -214,6 +214,18 @@ todo_list:
214
214
  enabled: true
215
215
  mirror_to_disk: true
216
216
 
217
+ # Desktop-only, macOS-only local computer control (open/reveal files & folders,
218
+ # launch apps, read/write the clipboard, browse/move/trash files) via
219
+ # computer_* tools. Off by default — server/Cloud Run deployments should never
220
+ # set this. The Monkeybot desktop app sets MONKEYBOT_COMPUTER_TOOLS when it
221
+ # spawns a gateway with this feature turned on in its settings; every computer_*
222
+ # call still asks for approval by default (a built-in permissions.yaml baseline,
223
+ # not configurable here) and "Always allow" is scoped to the exact action, not
224
+ # the whole tool. See monkeybot_config/permissions.yaml and
225
+ # monkeybot_config/approvals.json (machine-written, do not hand-edit).
226
+ computer:
227
+ enabled: false
228
+
217
229
  sandbox:
218
230
  # Opt-in: requires Docker Desktop (OpenSandbox). Leave false for normal Mac
219
231
  # installs so the gateway starts without Docker. When enabled, ``monkeybot run``
@@ -8,6 +8,7 @@ import time
8
8
  from pathlib import Path
9
9
 
10
10
  import monkeybot_cli.chat_local_shell as chat_local_shell
11
+ import monkeybot_cli.process_tree as process_tree
11
12
  from monkeybot_cli.chat_local_shell import run_local_shell, truncate_output
12
13
 
13
14
 
@@ -30,7 +31,7 @@ def test_run_local_shell_uses_cwd(tmp_path: Path) -> None:
30
31
 
31
32
 
32
33
  def test_kill_process_tree_swallows_permission_error(monkeypatch) -> None:
33
- monkeypatch.setattr(chat_local_shell, "_IS_WINDOWS", False)
34
+ monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
34
35
 
35
36
  def _boom_killpg(pid: int, sig: int) -> None:
36
37
  raise PermissionError("race")
@@ -38,9 +39,9 @@ def test_kill_process_tree_swallows_permission_error(monkeypatch) -> None:
38
39
  def _boom_kill(pid: int, sig: int) -> None:
39
40
  raise ProcessLookupError()
40
41
 
41
- monkeypatch.setattr(chat_local_shell.os, "killpg", _boom_killpg)
42
- monkeypatch.setattr(chat_local_shell.os, "kill", _boom_kill)
43
- chat_local_shell._kill_process_tree(4321) # must not raise
42
+ monkeypatch.setattr(process_tree.os, "killpg", _boom_killpg)
43
+ monkeypatch.setattr(process_tree.os, "kill", _boom_kill)
44
+ process_tree.kill_process_tree(4321) # must not raise
44
45
 
45
46
 
46
47
  def test_run_local_shell_timeout(tmp_path: Path) -> None:
@@ -104,22 +105,22 @@ def test_run_local_shell_timeout_kills_grandchildren(tmp_path: Path) -> None:
104
105
 
105
106
 
106
107
  def test_popen_kwargs_use_process_group_on_posix(monkeypatch) -> None:
107
- monkeypatch.setattr(chat_local_shell, "_IS_WINDOWS", False)
108
- assert chat_local_shell._popen_kwargs_for_platform() == {"start_new_session": True}
108
+ monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
109
+ assert process_tree.popen_kwargs_for_platform() == {"start_new_session": True}
109
110
 
110
111
 
111
112
  def test_popen_kwargs_use_new_process_group_on_windows(monkeypatch) -> None:
112
- monkeypatch.setattr(chat_local_shell, "_IS_WINDOWS", True)
113
- kwargs = chat_local_shell._popen_kwargs_for_platform()
113
+ monkeypatch.setattr(process_tree, "IS_WINDOWS", True)
114
+ kwargs = process_tree.popen_kwargs_for_platform()
114
115
  assert set(kwargs) == {"creationflags"}
115
116
 
116
117
 
117
118
  def test_kill_process_tree_uses_killpg_on_posix(monkeypatch) -> None:
118
- monkeypatch.setattr(chat_local_shell, "_IS_WINDOWS", False)
119
+ monkeypatch.setattr(process_tree, "IS_WINDOWS", False)
119
120
  calls: list[tuple[int, int]] = []
120
- monkeypatch.setattr(chat_local_shell.os, "killpg", lambda pid, sig: calls.append((pid, sig)))
121
- chat_local_shell._kill_process_tree(4321)
122
- assert calls == [(4321, chat_local_shell.signal.SIGKILL)]
121
+ monkeypatch.setattr(process_tree.os, "killpg", lambda pid, sig: calls.append((pid, sig)))
122
+ process_tree.kill_process_tree(4321)
123
+ assert calls == [(4321, process_tree.signal.SIGKILL)]
123
124
 
124
125
 
125
126
  def test_kill_process_tree_uses_taskkill_on_windows(monkeypatch) -> None:
@@ -128,14 +129,14 @@ def test_kill_process_tree_uses_taskkill_on_windows(monkeypatch) -> None:
128
129
  os.killpg doesn't exist on Windows, so the POSIX-only kill path would
129
130
  crash a timed-out `!` command instead of finishing the tool block there.
130
131
  """
131
- monkeypatch.setattr(chat_local_shell, "_IS_WINDOWS", True)
132
+ monkeypatch.setattr(process_tree, "IS_WINDOWS", True)
132
133
  calls: list[list[str]] = []
133
134
  monkeypatch.setattr(
134
- chat_local_shell.subprocess,
135
+ process_tree.subprocess,
135
136
  "run",
136
137
  lambda argv, **kwargs: calls.append(list(argv)),
137
138
  )
138
- chat_local_shell._kill_process_tree(4321)
139
+ process_tree.kill_process_tree(4321)
139
140
  assert calls == [["taskkill", "/F", "/T", "/PID", "4321"]]
140
141
 
141
142
 
@@ -21,6 +21,8 @@ def test_normalize_extra_token_features_and_providers() -> None:
21
21
  assert normalize_extra_token("anthropic") == "claude"
22
22
  assert normalize_extra_token("aws_bedrock") == "bedrock"
23
23
  assert normalize_extra_token("claude") == "claude"
24
+ assert normalize_extra_token("ollama-cloud") == "ollama"
25
+ assert normalize_extra_token("ollama-local") == "ollama"
24
26
  assert normalize_extra_token("fake") is None
25
27
  assert normalize_extra_token("not-a-real-extra") is None
26
28
 
@@ -42,6 +44,9 @@ def test_extra_module_covers_catalog_extras() -> None:
42
44
  def test_provider_extra_name() -> None:
43
45
  assert provider_extra_name("openai") == "openai"
44
46
  assert provider_extra_name("anthropic") == "claude"
47
+ assert provider_extra_name("ollama-cloud") == "ollama"
48
+ assert provider_extra_name("ollama-local") == "ollama"
49
+ assert provider_extra_name("ollama") == "ollama"
45
50
  assert provider_extra_name("fake") is None
46
51
 
47
52