rlmness-cli 0.2.0__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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/cli/main.py ADDED
@@ -0,0 +1,1194 @@
1
+ """CLI entry point for rlm-agent: argument parsing, mode dispatch, and lifecycle management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import importlib.metadata
8
+ import importlib.util
9
+ import os
10
+ import signal
11
+ import sys
12
+ from collections.abc import AsyncIterator, Sequence
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, NoReturn, TextIO
15
+
16
+ if TYPE_CHECKING:
17
+ from rlmagent.conversation import CodingSession
18
+
19
+ from rlmagent.safety import (
20
+ ApprovalContext,
21
+ ApprovalDecision,
22
+ ApprovalManager,
23
+ ApprovalPolicy,
24
+ clean_tool_result,
25
+ mark_untrusted,
26
+ )
27
+ from rlmagent.ui.render import OutputMode, make_renderer
28
+ from rlmagent.harness.contracts.stream import AgentEvent
29
+ from rlmagent.harness.contracts.tooling import ToolOutcome, ToolSpec
30
+ from rlmagent.harness.contracts.transcript import CallBlock, surface_text
31
+ from rlmagent.harness.driver import RuntimeHarness
32
+ from rlmagent.harness.provider.base import ModelProvider
33
+ from rlmagent.harness.session.index import SessionCatalog
34
+ from rlmagent.harness.session.records import (
35
+ SessionMetaRecord,
36
+ TagRecord,
37
+ TranscriptRecord,
38
+ )
39
+ from rlmagent.harness.session.store import JsonlVault
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Constants
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _PACKAGE = "rlmness-cli"
46
+ _DEFAULT_ANTHROPIC_MODEL = "claude-opus-5"
47
+ _DEFAULT_OPENAI_MODEL = "gpt-5"
48
+ _DEFAULT_SYSTEM = "You are a helpful coding assistant."
49
+ _SUBCOMMANDS = frozenset({"acp", "config", "doctor", "factory", "package", "provider", "session", "status", "tui",
50
+ "update", "worker"})
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Version
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def get_version() -> str:
59
+ """Resolve the installed package version, falling back to ``dev``."""
60
+ try:
61
+ return importlib.metadata.version(_PACKAGE)
62
+ except importlib.metadata.PackageNotFoundError:
63
+ return "dev"
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # I/O helpers
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _info(msg: str) -> None:
72
+ sys.stderr.write(msg + "\n")
73
+ sys.stderr.flush()
74
+
75
+
76
+ def _warn(msg: str) -> None:
77
+ sys.stderr.write(f"warning: {msg}\n")
78
+ sys.stderr.flush()
79
+
80
+
81
+ def _die(msg: str) -> NoReturn:
82
+ sys.stderr.write(f"error: {msg}\n")
83
+ sys.stderr.flush()
84
+ sys.exit(1)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Paths
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def _sessions_dir(override: str | None = None) -> Path:
93
+ """Resolve the session directory from flag, env, or default."""
94
+ if override:
95
+ return Path(override)
96
+ from rlmagent.discovery import default_paths
97
+
98
+ return default_paths().sessions
99
+
100
+
101
+ def _session_path(base: Path, session_id: str) -> Path:
102
+ return base / f"{session_id}.jsonl"
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Argument parsing
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def _build_run_parser() -> argparse.ArgumentParser:
111
+ """Build the argument parser for the default run mode."""
112
+ p = argparse.ArgumentParser(
113
+ prog="rlm-agent",
114
+ description="rlm-agent - a coding agent with a persistent Python kernel and sub-agents.",
115
+ )
116
+ p.add_argument(
117
+ "--version", "-V", action="version", version=f"rlm-agent {get_version()}",
118
+ )
119
+ p.add_argument(
120
+ "prompt", nargs="?", default=None,
121
+ help="One-shot prompt (omit for interactive mode).",
122
+ )
123
+ p.add_argument(
124
+ "--print", "-p", dest="print_mode", action="store_true",
125
+ help="Non-interactive: stream final text only, then exit.",
126
+ )
127
+ p.add_argument("--model", "-m", default=None, help="Override model identifier.")
128
+ p.add_argument("--provider", default=None, help="Override provider backend.")
129
+ p.add_argument("--offline", action="store_true", help="Skip optional network discovery and update checks.")
130
+ p.add_argument(
131
+ "--resume", "-r", metavar="ID", default=None,
132
+ help="Resume a previous session by its ID.",
133
+ )
134
+ p.add_argument(
135
+ "--allow-private-fetch", action="store_true",
136
+ help="Let fetch_url reach local and private network addresses.",
137
+ )
138
+ p.add_argument(
139
+ "--resume-anywhere", action="store_true",
140
+ help="Allow --resume in a folder other than the one the session started in.",
141
+ )
142
+ p.add_argument("--session-dir", default=None, help="Session storage directory.")
143
+ p.add_argument("--system-prompt", default=None, help="Override system prompt.")
144
+ p.add_argument(
145
+ "--max-turns", type=int, default=None,
146
+ help="Limit agent turns per submission.",
147
+ )
148
+ p.add_argument("--verbose", "-v", action="store_true", help="Verbose diagnostics.")
149
+ p.add_argument("--no-auto-improve", dest="auto_improve", action="store_false",
150
+ help="Do not update the playbook automatically.")
151
+ p.add_argument("--tools-at", default=None, metavar="SPEC",
152
+ help="Run the workspace tools in a tool host instead of here: "
153
+ "tcp://HOST:PORT (token in RLM_AGENT_TOOLS_TOKEN) or a command "
154
+ "that serves over stdio, e.g. 'python -m rlmagent.toolhost --root /w'.")
155
+ p.add_argument("--deny-git", action="store_true",
156
+ help="Refuse git commands that touch other branches, remotes or history "
157
+ "(push, fetch, checkout, branch, tag, reset, ...).")
158
+ p.add_argument("--shell-access", choices=("workspace", "host"), default="workspace",
159
+ help="Shell filesystem access: isolated workspace (default), or grant host access.")
160
+ p.add_argument("--shell-network", action="store_true",
161
+ help="Allow network access from workspace-isolated shell commands.")
162
+ p.add_argument("--max-tool-output", type=int, default=None, metavar="BYTES",
163
+ help="Most output one cell shows the model (default 20000; 0 disables this cap).")
164
+ p.add_argument("--max-compactions", type=int, default=None, metavar="N",
165
+ help="Most context compactions in this session.")
166
+ p.add_argument("--agent-max-tokens", type=int, default=None, metavar="N",
167
+ help="Most completion tokens any one agent may spend.")
168
+ p.add_argument("--search-block", action="append", default=None, metavar="DOMAIN",
169
+ help="Drop web search results from this domain, *.domain or origin "
170
+ "(repeatable).")
171
+ p.add_argument("--apply-auto-improve", dest="review_auto_improve", action="store_false",
172
+ help="Save automatic playbook proposals at once instead of holding them "
173
+ "for /improve accept.")
174
+ p.add_argument("--improve-every", type=int, default=5,
175
+ help="Check whether to update the playbook every N finished tasks (default 5).")
176
+ p.add_argument("--improve-cooldown", type=float, default=900.0,
177
+ help="Seconds between automatic playbook checks (default 900).")
178
+ p.add_argument("--kernel", choices=("wasm", "local"), default="wasm",
179
+ help="Where the python tool runs: a WebAssembly sandbox (default) or a "
180
+ "local IPython kernel.")
181
+ p.add_argument("--engine", choices=("session", "rlmness"), default="rlmness",
182
+ help="Execution backend (default rlmness; session reads the previous engine).")
183
+ p.add_argument("--restore-kernel", action="store_true",
184
+ help="Recover the last sandbox checkpoint after a restart (WASM only).")
185
+ p.add_argument("--no-network", dest="network", action="store_false",
186
+ help="Keep the sandbox offline: no internet, no package installs.")
187
+ p.add_argument("--auto-approve", action="store_true",
188
+ help="Let the sandbox write files and run commands without asking. "
189
+ "Only for scratch folders.")
190
+ p.add_argument(
191
+ "--contract", metavar="FILE", default=None,
192
+ help="Run with exactly the settings in this run contract (JSON); needs -p.",
193
+ )
194
+ budget = p.add_argument_group("limits for the whole tree of agents")
195
+ budget.add_argument("--max-depth", type=int, default=2,
196
+ help="How many levels of sub-agents may be started (default 2).")
197
+ budget.add_argument("--max-calls", type=int, default=None,
198
+ help="Model calls allowed across all agents (default 200; 50 for acp: models).")
199
+ budget.add_argument("--max-cost", type=float, default=2.0,
200
+ help="Spend allowed across all agents, in dollars (default 2.0).")
201
+ budget.add_argument("--max-agents", type=int, default=50,
202
+ help="Sub-agents that may be started in one session (default 50).")
203
+ budget.add_argument("--max-live", type=int, default=8,
204
+ help="Sub-agents running at the same time (default 8).")
205
+ budget.add_argument("--max-seconds", type=float, default=None,
206
+ help="Wall-clock limit for the whole run (default none).")
207
+ budget.add_argument("--cell-timeout", type=float, default=300.0,
208
+ help="Seconds one kernel cell may run before it is interrupted (default 300).")
209
+ budget.add_argument("--request-timeout", type=float, default=300.0,
210
+ help="Deadline in seconds for each complete model response (default 300).")
211
+ p.add_argument(
212
+ "--no-session", action="store_true", help="Disable session persistence.",
213
+ )
214
+ p.add_argument("--idle-passivate", type=float, default=0, metavar="SECONDS",
215
+ help="Let a saved worker exit after this much idle time (default disabled).")
216
+ p.add_argument(
217
+ "--repl", action="store_true",
218
+ help="Use the line-based REPL instead of the Textual UI.",
219
+ )
220
+ ownership = p.add_mutually_exclusive_group()
221
+ ownership.add_argument(
222
+ "--supervised", action="store_true",
223
+ help="Run the session in a background worker that keeps going when this window "
224
+ "closes, and attach to it.",
225
+ )
226
+ ownership.add_argument(
227
+ "--foreground", action="store_true",
228
+ help="Run the interactive session in this process (the default).",
229
+ )
230
+ trust = p.add_mutually_exclusive_group()
231
+ trust.add_argument(
232
+ "--approve", "-a", action="store_true",
233
+ help="Load this folder's project instructions, skills and plugins for this run.",
234
+ )
235
+ trust.add_argument(
236
+ "--no-approve", "-na", dest="no_approve", action="store_true",
237
+ help="Skip this folder's project inputs for this run.",
238
+ )
239
+ return p
240
+
241
+
242
+ def _build_provider_parser() -> argparse.ArgumentParser:
243
+ p = argparse.ArgumentParser(prog="rlm-agent provider")
244
+ sub = p.add_subparsers(dest="action", required=True)
245
+ sub.add_parser("list", help="List configured providers.")
246
+ sub.add_parser("setup", help="Interactive provider setup.")
247
+ for action in ("login", "logout"):
248
+ auth = sub.add_parser(action, help=f"{action.title()} a subscription account.")
249
+ auth.add_argument("name", choices=["codex", "copilot", "claude-subscription", "xai-subscription"])
250
+ if action == "login":
251
+ auth.add_argument("--new-account", action="store_true")
252
+ auth.add_argument("--client-registration", type=Path, default=None)
253
+ for action in ("models", "test"):
254
+ remote = sub.add_parser(action, help="Query the provider model endpoint.")
255
+ remote.add_argument("name")
256
+ sel = sub.add_parser("select", help="Set the active provider.")
257
+ sel.add_argument("name", help="Provider name to activate.")
258
+ return p
259
+
260
+
261
+ def _build_session_parser() -> argparse.ArgumentParser:
262
+ p = argparse.ArgumentParser(prog="rlm-agent session")
263
+ p.add_argument("--session-dir", default=None)
264
+ sub = p.add_subparsers(dest="action", required=True)
265
+ ls = sub.add_parser("list", help="List saved sessions.")
266
+ ls.add_argument("--session-dir", default=None, dest="sub_session_dir")
267
+ exp = sub.add_parser("export", help="Export a session to stdout.")
268
+ exp.add_argument("session_id", help="Session ID to export.")
269
+ exp.add_argument(
270
+ "--format", "-f", choices=["jsonl", "json", "text", "html"], default="jsonl",
271
+ )
272
+ exp.add_argument("--session-dir", default=None, dest="sub_session_dir")
273
+ share = sub.add_parser("share", help="Preview or explicitly publish an unlisted session gist.")
274
+ share.add_argument("session_id")
275
+ share.add_argument("--publish", action="store_true")
276
+ share.add_argument("--sha256", default=None, help="Digest of the exact previously reviewed payload")
277
+ share.add_argument("--session-dir", default=None, dest="sub_session_dir")
278
+ imp = sub.add_parser("import", help="Import a validated JSONL session under a new ID.")
279
+ imp.add_argument("file", type=Path)
280
+ imp.add_argument("--session-dir", default=None, dest="sub_session_dir")
281
+ st = sub.add_parser("stats", help="Print a session's token and cost totals as JSON.")
282
+ st.add_argument(
283
+ "session_id", nargs="?", default=None,
284
+ help="Session ID to summarize (default: the most recent session).",
285
+ )
286
+ st.add_argument("--session-dir", default=None, dest="sub_session_dir")
287
+ vw = sub.add_parser("view", help="Replay a session's agent steps as cards.")
288
+ vw.add_argument("session_id", nargs="?", default=None,
289
+ help="Session ID (default: the most recent session).")
290
+ vw.add_argument("--session-dir", default=None, dest="sub_session_dir")
291
+ sp = sub.add_parser("steps", help="Per-agent steps, tokens, cost and time of a session.")
292
+ sp.add_argument("session_id", nargs="?", default=None,
293
+ help="Session ID (default: the most recent session).")
294
+ sp.add_argument("--json", action="store_true", help="Print one JSON object per agent.")
295
+ sp.add_argument("--session-dir", default=None, dest="sub_session_dir")
296
+ pr = sub.add_parser("prune", help="List old sessions; remove them with --yes.")
297
+ pr.add_argument("--older-than", type=int, required=True, metavar="DAYS")
298
+ pr.add_argument("--yes", action="store_true", help="Actually remove them.")
299
+ pr.add_argument("--session-dir", default=None, dest="sub_session_dir")
300
+ return p
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Provider / model resolution
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ def _resolve_provider(name: str | None) -> ModelProvider:
309
+ """Build a ``ModelProvider`` from the given name, env, or config module."""
310
+ from rlmagent.config.config import resolve_provider
311
+ from rlmagent.model.settings import ConfigError
312
+
313
+ try:
314
+ return resolve_provider(name)
315
+ except ConfigError as exc:
316
+ _die(str(exc))
317
+
318
+
319
+ def _resolve_provider_name(explicit: str | None) -> str:
320
+ """Provider name from the flag, then ``RLM_AGENT_PROVIDER``, then saved config."""
321
+ if explicit:
322
+ return explicit.strip().lower()
323
+ from rlmagent.config.loader import load_provider
324
+ return load_provider()
325
+
326
+
327
+ def _resolve_model(override: str | None, provider_name: str = "anthropic") -> str:
328
+ """Model from the ``--model`` flag, ``RLM_AGENT_MODEL``, or the provider's default."""
329
+ explicit = override or os.environ.get("RLM_AGENT_MODEL")
330
+ if explicit:
331
+ return explicit
332
+ if provider_name == "openai":
333
+ return _DEFAULT_OPENAI_MODEL
334
+ if provider_name == "cli":
335
+ return "cli:claude-code"
336
+ if provider_name == "vertex":
337
+ return "google/gemini-3.5-flash"
338
+ if provider_name in ("google", "mistral", "azure", "codex", "copilot", "claude-subscription", "xai", "xai-subscription"):
339
+ from rlmagent.config.store import provider_info
340
+
341
+ return provider_info(provider_name).models[0]
342
+ return _DEFAULT_ANTHROPIC_MODEL
343
+
344
+
345
+ def _resolve_system(
346
+ override: str | None,
347
+ *,
348
+ tools: Sequence[ToolSpec] = (),
349
+ skills: Sequence[object] = (),
350
+ ) -> str:
351
+ if override:
352
+ return override
353
+ try:
354
+ from rlmagent.instructions import system_prompt
355
+ return system_prompt(tools=tools, skills=skills) # type: ignore[arg-type]
356
+ except (ImportError, AttributeError):
357
+ return _DEFAULT_SYSTEM
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Tool loading
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ def _load_tools(verbose: bool = False, kernel=None) -> list[ToolSpec]:
366
+ try:
367
+ from rlmagent.tools import build_tool_registry # type: ignore[import-not-found]
368
+ tools: list[ToolSpec] = build_tool_registry(kernel)
369
+ if verbose:
370
+ _info(f"Loaded {len(tools)} tool(s).")
371
+ return tools
372
+ except (ImportError, AttributeError):
373
+ if verbose:
374
+ _info("Tool module not available; running without tools.")
375
+ return []
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Skill loading
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ def _load_skills(cwd: str, verbose: bool = False) -> list[object]:
384
+ """Load skills from all resource directories with full precedence."""
385
+ try:
386
+ from rlmagent.discovery import default_paths, skill_search_paths
387
+ from rlmagent.skillset import load_skills
388
+
389
+ paths = default_paths(project=Path(cwd))
390
+ dirs = skill_search_paths(paths)
391
+ skills = load_skills(dirs)
392
+ if verbose:
393
+ searched = ", ".join(str(d) for d in dirs)
394
+ _info(f"Loaded {len(skills)} skill(s) (searched: {searched}).")
395
+ return list(skills)
396
+ except (ImportError, AttributeError):
397
+ if verbose:
398
+ _info("Skill module not available; running without skills.")
399
+ return []
400
+
401
+
402
+ def _load_prompt_templates(cwd: str, verbose: bool = False) -> list[object]:
403
+ """Load markdown prompt templates from all resource directories."""
404
+ try:
405
+ from rlmagent.discovery import default_paths, prompt_search_paths
406
+ from rlmagent.prompts import load_prompt_templates
407
+
408
+ paths = default_paths(project=Path(cwd))
409
+ dirs = prompt_search_paths(paths)
410
+ templates = load_prompt_templates(dirs)
411
+ if verbose:
412
+ searched = ", ".join(str(d) for d in dirs)
413
+ _info(f"Loaded {len(templates)} prompt template(s) (searched: {searched}).")
414
+ return list(templates)
415
+ except (ImportError, AttributeError):
416
+ if verbose:
417
+ _info("Prompt template module not available; running without templates.")
418
+ return []
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Hook wiring
423
+ # ---------------------------------------------------------------------------
424
+
425
+
426
+ def _install_hooks(harness: RuntimeHarness, verbose: bool = False) -> None:
427
+ try:
428
+ from rlmagent.hooks.hooks import install_hooks # type: ignore[import-not-found]
429
+ install_hooks(harness)
430
+ if verbose:
431
+ _info("Hooks installed.")
432
+ except (ImportError, AttributeError):
433
+ pass
434
+
435
+
436
+ _MUTATING_TOOLS = frozenset({"Write", "Edit", "python"})
437
+
438
+
439
+ def _approval_context(call: CallBlock) -> ApprovalContext:
440
+ """Describe a pending tool call for the approval manager."""
441
+ args = call.arguments
442
+ command = args.get("command", args.get("code"))
443
+ path = args.get("file_path")
444
+ return ApprovalContext(
445
+ tool_name=call.name,
446
+ is_mutating=call.name in _MUTATING_TOOLS,
447
+ affected_paths=(str(path),) if isinstance(path, str) else (),
448
+ command=command if isinstance(command, str) else None,
449
+ )
450
+
451
+
452
+ def _install_safety(harness: RuntimeHarness, policy: ApprovalPolicy) -> None:
453
+ """Gate tool calls through the approval manager and sanitize their results."""
454
+ manager = ApprovalManager(policy)
455
+
456
+ async def before_tool_call(call: CallBlock) -> tuple[bool, str | None]:
457
+ decision = await manager.check(_approval_context(call))
458
+ if decision is ApprovalDecision.APPROVED:
459
+ return False, None
460
+ return True, f"Blocked by safety policy ({decision.value}): {call.name}"
461
+
462
+ async def after_tool_call(
463
+ call: CallBlock, result: ToolOutcome, is_error: bool,
464
+ ) -> tuple[ToolOutcome, bool]:
465
+ return mark_untrusted(clean_tool_result(result)), is_error
466
+
467
+ harness.settings.before_tool_call = before_tool_call
468
+ harness.settings.after_tool_call = after_tool_call
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Extension loading
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ def _load_extensions(verbose: bool = False) -> list[object]:
477
+ """Discover and activate extensions from the extension directories."""
478
+ from rlmagent.plugins import load_extensions
479
+
480
+ result = load_extensions()
481
+
482
+ for failure in result.failures:
483
+ _warn(f"Extension {failure.name!r} failed: {failure.error}")
484
+
485
+ if verbose:
486
+ searched = ", ".join(str(path) for path in result.searched)
487
+ _info(f"Extensions: {result.count} loaded (searched: {searched})")
488
+ for extension in result.loaded:
489
+ _info(f" {extension.name} <- {extension.path}")
490
+
491
+ return [extension.value for extension in result.loaded]
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # Session helpers
496
+ # ---------------------------------------------------------------------------
497
+
498
+
499
+ async def _list_sessions(base: Path, out: TextIO) -> None:
500
+ if not base.exists():
501
+ _info("No sessions found.")
502
+ return
503
+
504
+ # Prefer the catalog index when available
505
+ catalog = SessionCatalog(base)
506
+ entries = catalog.list_all()
507
+ if entries:
508
+ for meta in entries:
509
+ label = meta.title or meta.session_id
510
+ out.write(f" {meta.session_id} {label} ({meta.provider}/{meta.model})\n")
511
+ return
512
+
513
+ # Fallback: scan vault files directly (pre-index sessions)
514
+ files = sorted(base.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
515
+ # Exclude the index file itself
516
+ files = [f for f in files if f.name != "index.jsonl"]
517
+ if not files:
518
+ _info("No sessions found.")
519
+ return
520
+ for path in files:
521
+ vault = JsonlVault(path)
522
+ try:
523
+ records = await vault.read_all()
524
+ except Exception: # noqa: BLE001
525
+ out.write(f" {path.stem} (unreadable)\n")
526
+ continue
527
+ title = path.stem
528
+ n_messages = 0
529
+ for r in records:
530
+ if isinstance(r, TagRecord):
531
+ title = r.label
532
+ elif isinstance(r, SessionMetaRecord) and r.title:
533
+ title = r.title
534
+ elif isinstance(r, TranscriptRecord):
535
+ n_messages += 1
536
+ out.write(f" {path.stem} {title} ({n_messages} messages)\n")
537
+
538
+
539
+ async def _export_session(base: Path, sid: str, fmt: str, out: TextIO) -> None:
540
+ path = _session_path(base, sid)
541
+ if not path.exists():
542
+ _die(f"Session not found: {sid}")
543
+ records = await JsonlVault(path).read_all()
544
+ if fmt == "jsonl":
545
+ from rlmagent.harness.session.store import serialize_record
546
+ for r in records:
547
+ out.write(serialize_record(r))
548
+ elif fmt == "json":
549
+ from pydantic import TypeAdapter
550
+
551
+ from rlmagent.harness.session.records import SessionRecord
552
+ out.write(
553
+ TypeAdapter(list[SessionRecord]).dump_json(records, indent=2).decode() + "\n"
554
+ )
555
+ elif fmt == "html":
556
+ from rlmagent.session_html import render
557
+
558
+ out.write(render([], sid, records=records))
559
+ elif fmt == "text":
560
+ for r in records:
561
+ if isinstance(r, TranscriptRecord):
562
+ out.write(f"[{r.message.role}] {surface_text(r.message)}\n\n")
563
+
564
+
565
+ def _latest_session_id(base: Path) -> str | None:
566
+ """Session id of the most recently modified vault file, if any."""
567
+ if not base.exists():
568
+ return None
569
+ # Run event logs sit beside the vaults as <id>.backend.events.jsonl; skip them.
570
+ files = [p for p in base.glob("*.jsonl") if p.name != "index.jsonl" and "." not in p.stem]
571
+ if not files:
572
+ return None
573
+ return max(files, key=lambda p: p.stat().st_mtime).stem
574
+
575
+
576
+ async def _session_stats(base: Path, sid: str | None, out: TextIO) -> None:
577
+ """Write one JSON object of token and cost totals for a session."""
578
+ import json
579
+ from dataclasses import asdict
580
+
581
+ from rlmagent.harness.session.summary import summarize_records
582
+
583
+ resolved = sid or _latest_session_id(base)
584
+ if resolved is None:
585
+ _die("No sessions found.")
586
+ path = _session_path(base, resolved)
587
+ if not path.exists():
588
+ _die(f"Session not found: {resolved}")
589
+ records = await JsonlVault(path).read_all()
590
+ out.write(json.dumps(asdict(summarize_records(resolved, records))) + "\n")
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Provider subcommand
595
+ # ---------------------------------------------------------------------------
596
+
597
+
598
+ def _handle_provider(argv: list[str]) -> int:
599
+ ns = _build_provider_parser().parse_args(argv)
600
+
601
+ if ns.action in ("login", "logout"):
602
+ from rlmagent.model.settings import ConfigError
603
+ from rlmagent.model.subscription import login, logout
604
+
605
+ if ns.name == "xai-subscription":
606
+ from rlmagent.model import registered_oauth
607
+
608
+ try:
609
+ if ns.action == "login":
610
+ asyncio.run(registered_oauth.login(ns.client_registration))
611
+ _info("Registered OAuth token saved. Verify inference access separately.")
612
+ else:
613
+ asyncio.run(registered_oauth.logout())
614
+ _info("Local OAuth tokens cleared; provider-side revocation was not attempted.")
615
+ return 0
616
+ except (ConfigError, OSError, ValueError) as error:
617
+ _info(str(error))
618
+ return 1
619
+ if ns.name != "codex":
620
+ import shutil
621
+ import subprocess
622
+
623
+ executable = shutil.which("copilot" if ns.name == "copilot" else "claude")
624
+ if executable is None:
625
+ _info("Install the provider's official CLI to use its account sign-in.")
626
+ return 1
627
+ if ns.name == "copilot" and ns.action == "logout":
628
+ _info("Run /logout inside the official Copilot CLI to sign out.")
629
+ return 0
630
+ command = [executable, ns.action] if ns.name == "copilot" else [executable, "auth", ns.action]
631
+ return subprocess.run(command, check=False).returncode
632
+
633
+ try:
634
+ if ns.action == "login":
635
+ _info("Continue with ChatGPT in your browser. Waiting for consent...")
636
+ asyncio.run(login(new_account=ns.new_account))
637
+ _info("Subscription sign-in saved in the platform credential store. "
638
+ "Use --provider codex to run an inference check.")
639
+ else:
640
+ confirmed = asyncio.run(logout())
641
+ _info("Signed out locally." + ("" if confirmed else
642
+ " Remote revocation was not confirmed; disconnect in ChatGPT Settings."))
643
+ return 0
644
+ except ConfigError as exc:
645
+ _info(str(exc))
646
+ return 1
647
+
648
+ if ns.action in ("models", "test"):
649
+ from rlmagent.model.discovery import discover_models, check_provider
650
+ try:
651
+ if ns.action == "test":
652
+ _info(asyncio.run(check_provider(ns.name)))
653
+ return 0
654
+ models = asyncio.run(discover_models(ns.name))
655
+ except Exception as error:
656
+ # HTTP errors can contain endpoint details; never include authentication headers.
657
+ _info(f"Provider check failed: {type(error).__name__}")
658
+ return 1
659
+ if ns.action == "models":
660
+ for model in models:
661
+ print(model)
662
+ else:
663
+ _info(f"Provider model endpoint accepted credentials; {len(models)} models returned")
664
+ return 0
665
+ if ns.action == "list":
666
+ from rlmagent.config.config import list_providers
667
+ list_providers()
668
+ return 0
669
+
670
+ if ns.action == "setup":
671
+ from rlmagent.config.config import setup_provider
672
+ setup_provider()
673
+ return 0
674
+
675
+ if ns.action == "select":
676
+ from rlmagent.config.config import select_provider
677
+ from rlmagent.model.settings import ConfigError
678
+ try:
679
+ select_provider(ns.name)
680
+ _info(f"Active provider: {ns.name}")
681
+ except ConfigError as exc:
682
+ _die(str(exc))
683
+ return 0
684
+
685
+ return 1
686
+
687
+
688
+ # ---------------------------------------------------------------------------
689
+ # Session subcommand
690
+ # ---------------------------------------------------------------------------
691
+
692
+
693
+ def _textual_available() -> bool:
694
+ """Whether the optional ``textual`` extra is importable."""
695
+ return importlib.util.find_spec("textual") is not None
696
+
697
+
698
+ def _handle_tui(ns: argparse.Namespace | None = None) -> int:
699
+ """Launch the Textual UI over the shared runtime session."""
700
+ if not _textual_available():
701
+ _die(
702
+ "The TUI requires the 'textual' extra.\n"
703
+ " Install it with: pip install rlmness-cli[tui]"
704
+ )
705
+ from rlmagent.tui.app import run_tui
706
+ return run_tui(ns)
707
+
708
+
709
+ def _should_launch_tui(ns: argparse.Namespace) -> bool:
710
+ """Whether a bare ``rlm-agent`` invocation should open the Textual UI."""
711
+ if ns.repl or ns.print_mode or ns.prompt is not None or ns.resume:
712
+ return False
713
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
714
+ return False
715
+ return _textual_available()
716
+
717
+
718
+ def _handle_session(argv: list[str]) -> int:
719
+ if argv[:1] in (["detach"], ["attach"]):
720
+ from rlmagent.worker import handle_session
721
+
722
+ return handle_session(argv)
723
+ ns = _build_session_parser().parse_args(argv)
724
+ session_dir = ns.session_dir or getattr(ns, "sub_session_dir", None)
725
+ base = _sessions_dir(session_dir)
726
+
727
+ if ns.action == "list":
728
+ asyncio.run(_list_sessions(base, sys.stdout))
729
+ return 0
730
+
731
+ if ns.action == "export":
732
+ asyncio.run(_export_session(base, ns.session_id, ns.format, sys.stdout))
733
+ return 0
734
+ if ns.action == "share":
735
+ import json
736
+ import re
737
+
738
+ from rlmagent.harness.session.store import serialize_record
739
+ from rlmagent.session_html import render
740
+ from rlmagent.session_share import prepare, publish
741
+
742
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", ns.session_id):
743
+ _die("Invalid session identifier")
744
+ async def share_session():
745
+ records = await JsonlVault(_session_path(base, ns.session_id)).read_all()
746
+ if not records:
747
+ raise ValueError("No session records found")
748
+ body, digest = prepare(ns.session_id, render(
749
+ [record.message for record in records if isinstance(record, TranscriptRecord)],
750
+ ns.session_id, records=records), "".join(serialize_record(record) for record in records))
751
+ if not ns.publish:
752
+ print(json.dumps(body, indent=2, ensure_ascii=False))
753
+ print(f"SHA-256: {digest}")
754
+ print("Unlisted gist: anyone with the link can read it. Nothing uploaded.")
755
+ return
756
+ print(await publish(body, expected_digest=ns.sha256,
757
+ token=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")))
758
+ try:
759
+ asyncio.run(share_session())
760
+ except (OSError, ValueError) as error:
761
+ _die(str(error))
762
+ return 0
763
+ if ns.action == "import":
764
+ from rlmagent.session_import import import_session
765
+
766
+ try:
767
+ print(import_session(ns.file, base))
768
+ except (OSError, ValueError) as exc:
769
+ _die(f"Cannot import session: {exc}")
770
+ return 0
771
+
772
+ if ns.action == "stats":
773
+ asyncio.run(_session_stats(base, ns.session_id, sys.stdout))
774
+ return 0
775
+
776
+ if ns.action in ("view", "steps"):
777
+ from rlmagent.tui.replay import (
778
+ ReplayApp, agent_rows, events_path, format_rows, load_events, runs_of,
779
+ )
780
+
781
+ resolved = ns.session_id or _latest_session_id(base)
782
+ if resolved is None:
783
+ _die("No sessions found.")
784
+ path = events_path(base, resolved)
785
+ if not path.exists():
786
+ _die(f"No agent steps saved for {resolved}; steps are kept for --engine rlmness.")
787
+ books = runs_of(load_events(path))
788
+ if ns.action == "view":
789
+ ReplayApp(resolved, books).run()
790
+ return 0
791
+ rows = agent_rows(books)
792
+ if ns.json:
793
+ import json
794
+ for row in rows:
795
+ print(json.dumps(row))
796
+ else:
797
+ print(format_rows(rows))
798
+ return 0
799
+
800
+ if ns.action == "prune":
801
+ from rlmagent.plugins import rlm_agent_home
802
+ from rlmagent.prune import prune
803
+
804
+ if ns.older_than < 1:
805
+ _die("--older-than must be at least 1 day.")
806
+ return prune(base, rlm_agent_home(), ns.older_than, apply=ns.yes)
807
+
808
+ return 1
809
+
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # Event rendering
813
+ # ---------------------------------------------------------------------------
814
+
815
+
816
+ async def _consume_events(
817
+ events: AsyncIterator[AgentEvent],
818
+ *,
819
+ print_mode: bool,
820
+ ) -> bool:
821
+ """Consume the event stream and render output, returning overall success."""
822
+ mode = OutputMode.TEXT if print_mode else OutputMode.TRANSCRIPT
823
+ renderer = make_renderer(mode)
824
+ async for event in events:
825
+ renderer.render(event)
826
+ return renderer.finish()
827
+
828
+
829
+ # ---------------------------------------------------------------------------
830
+ # Run modes
831
+ # ---------------------------------------------------------------------------
832
+
833
+
834
+ #: ``/help`` is rendered from the shared command registry by the session, so
835
+ #: this file keeps no command list of its own to drift out of date. Only the
836
+ #: skill-invocation form, which is not a registered command, is appended.
837
+ _HELP_SUFFIX = " /skill:<name> [args] Invoke a skill"
838
+
839
+
840
+ async def _run_one_shot(
841
+ session: CodingSession,
842
+ prompt: str,
843
+ *,
844
+ print_mode: bool,
845
+ ) -> int:
846
+ """Submit a single prompt and exit."""
847
+ events = session.submit(prompt)
848
+ ok = await _consume_events(events, print_mode=print_mode)
849
+ ok = await _keep_going(session, ok, print_mode=print_mode)
850
+ sys.stdout.flush()
851
+ return 0 if ok else 1
852
+
853
+
854
+ async def _keep_going(session: CodingSession, ok: bool, *, print_mode: bool) -> bool:
855
+ """Run the turns autonomous mode starts on its own, until it stops."""
856
+ pilot = getattr(session, "autopilot", None)
857
+ while ok and pilot is not None and (following := pilot.next_prompt()):
858
+ _info(f"[autonomous turn {pilot.turns}]")
859
+ ok = await _consume_events(session.submit(following), print_mode=print_mode)
860
+ if pilot is not None and pilot.stopped_because:
861
+ _info(f"[autonomous mode stopped: {pilot.stopped_because}]")
862
+ pilot.stopped_because = ""
863
+ return ok
864
+
865
+
866
+ async def _run_interactive(session: CodingSession) -> int:
867
+ """Run the interactive prompt loop until the user exits."""
868
+ loop = asyncio.get_running_loop()
869
+ _info("Type /help for commands, /auto to toggle auto-accept, /quit to exit.\n")
870
+ tree = getattr(session, "agent_tree", None)
871
+ if tree is not None and tree.approver.policy in ("ask", "auto"):
872
+ tree.approver.ask = _ask_on_console
873
+ if tree is not None:
874
+ tree.approver.question = _question_on_console
875
+
876
+ while True:
877
+ try:
878
+ line = await loop.run_in_executor(None, _prompt_user)
879
+ except (EOFError, KeyboardInterrupt):
880
+ break
881
+
882
+ if line is None:
883
+ break
884
+ text = line.strip()
885
+ if not text:
886
+ continue
887
+
888
+ # CLI-level built-ins
889
+ if text in {"/quit", "/exit", "/q"}:
890
+ break
891
+ if text == "/version":
892
+ _info(f"rlm-agent v{get_version()}")
893
+ continue
894
+ if text == "/auto" and tree is not None:
895
+ on = tree.approver.toggle_auto()
896
+ _info("auto-accept on: file changes and commands go ahead unasked" if on
897
+ else "auto-accept off: writes and commands ask first")
898
+ continue
899
+
900
+ # Resuming yields an event stream rather than a string, so it cannot go
901
+ # through handle_command like the other session commands.
902
+ if text in {"/continue", "/resume"}:
903
+ if session.active:
904
+ _warn("Agent is already running.")
905
+ continue
906
+ try:
907
+ events = session.resume_run()
908
+ except RuntimeError as exc:
909
+ _warn(str(exc))
910
+ continue
911
+ await _consume_events(events, print_mode=False)
912
+ continue
913
+
914
+ # Session-level slash commands (/model, /compact, /stats, /export, ...)
915
+ try:
916
+ response = await session.handle_command(text)
917
+ except Exception as exc: # noqa: BLE001 - a bad command must not kill the REPL
918
+ _warn(f"Command failed: {exc}")
919
+ continue
920
+ if response is not None:
921
+ if text == "/help":
922
+ response = f"{response}\n{_HELP_SUFFIX}"
923
+ _info(response)
924
+ if text.split() in (["/autonomous"], ["/autonomous", "on"]):
925
+ await _keep_going(session, True, print_mode=False)
926
+ continue
927
+
928
+ # A registered command the session does not run (terminal-UI pickers and
929
+ # the like) must not reach the model as a prompt.
930
+ from rlmagent.conversation import COMMAND_REGISTRY
931
+ from rlmagent.directives import parse_command
932
+
933
+ parsed = parse_command(text)
934
+ command = COMMAND_REGISTRY.get(parsed[0]) if parsed is not None else None
935
+ if command is not None:
936
+ _warn(f"/{command.name.lstrip('/')} is not available in the line REPL.")
937
+ continue
938
+
939
+ # Ordinary prompt
940
+ try:
941
+ events = session.submit(text)
942
+ except RuntimeError as exc:
943
+ _warn(str(exc))
944
+ continue
945
+ ok = await _consume_events(events, print_mode=False)
946
+ await _keep_going(session, ok, print_mode=False)
947
+
948
+ return 0
949
+
950
+
951
+ APPROVAL_KEYS = {"y": "once", "a": "always", "t": "auto", "n": "deny"}
952
+
953
+
954
+ async def _ask_on_console(request) -> str:
955
+ """Line-mode approval: show what would change and read one key."""
956
+ from rlmagent.agents.approval import DENY
957
+
958
+ always = "this command" if request.kind == "run" else "this file"
959
+ _info(f"\nThe agent wants to:\n{request.describe()}")
960
+ question = f"Allow? [y] once [a] always {always} [t] and auto-accept the rest [n] deny: "
961
+ loop = asyncio.get_running_loop()
962
+ while True:
963
+ try:
964
+ answer = await loop.run_in_executor(None, input, question)
965
+ except (EOFError, KeyboardInterrupt):
966
+ return DENY
967
+ choice = APPROVAL_KEYS.get(answer.strip().lower()[:1])
968
+ if choice is not None:
969
+ return choice
970
+
971
+
972
+ async def _question_on_console(question: str, choices: list[str] | None, multiple: bool = False):
973
+ """Line-mode question: numbers pick choices (commas for several); anything else is the answer."""
974
+ _info(f"\nThe agent asks:\n{question}")
975
+ for number, choice in enumerate(choices or [], start=1):
976
+ _info(f" {number}. {choice}")
977
+ try:
978
+ answer = await asyncio.get_running_loop().run_in_executor(None, input, "Answer: ")
979
+ except (EOFError, KeyboardInterrupt):
980
+ return None
981
+ answer = answer.strip()
982
+ picks = [p.strip() for p in answer.split(",")]
983
+ if multiple and choices and all(p.isdigit() and 1 <= int(p) <= len(choices) for p in picks):
984
+ return [choices[int(p) - 1] for p in picks]
985
+ if choices and answer.isdigit() and 1 <= int(answer) <= len(choices):
986
+ return choices[int(answer) - 1]
987
+ return answer or None
988
+
989
+
990
+ def _prompt_user() -> str | None:
991
+ """Read a line of input from the user, returning ``None`` on EOF."""
992
+ try:
993
+ return input("\nrlm-agent> ")
994
+ except EOFError:
995
+ return None
996
+
997
+
998
+ # ---------------------------------------------------------------------------
999
+ # Signal handling
1000
+ # ---------------------------------------------------------------------------
1001
+
1002
+
1003
+ def _wire_signals(session: CodingSession) -> None:
1004
+ """Install a SIGINT handler: first press aborts the active run, idle press"""
1005
+
1006
+ def _handler(_signum: int, _frame: object) -> None:
1007
+ if session.active:
1008
+ session.abort()
1009
+ return
1010
+ raise KeyboardInterrupt
1011
+
1012
+ signal.signal(signal.SIGINT, _handler)
1013
+
1014
+
1015
+ # ---------------------------------------------------------------------------
1016
+ # Startup banner
1017
+ # ---------------------------------------------------------------------------
1018
+
1019
+
1020
+ def _banner(provider: str, model: str, session_id: str | None, verbose: bool) -> None:
1021
+ from rlmagent.config.store import PROVIDERS
1022
+
1023
+ label = next((p.label for p in PROVIDERS if p.key == provider), provider)
1024
+ parts = [
1025
+ f"rlm-agent v{get_version()}",
1026
+ f"Provider: {label}",
1027
+ f"Model: {model}",
1028
+ ]
1029
+ if session_id:
1030
+ parts.append(f"Session: {session_id}")
1031
+ if verbose:
1032
+ parts.append(f"Python: {sys.version.split()[0]}")
1033
+ parts.append(f"Cwd: {os.getcwd()}")
1034
+ _info("\n".join(parts))
1035
+
1036
+
1037
+ # ---------------------------------------------------------------------------
1038
+ # Async orchestrator
1039
+ # ---------------------------------------------------------------------------
1040
+
1041
+
1042
+ async def _async_main(ns: argparse.Namespace) -> int:
1043
+ """Wire everything together and dispatch to the chosen run mode."""
1044
+ from rlmagent.runtime import build_session
1045
+
1046
+ if ns.contract:
1047
+ from rlmagent.contract_run import run_contract
1048
+
1049
+ return await run_contract(ns)
1050
+
1051
+ # Piped stdin -> one-shot print mode
1052
+ if ns.prompt is None and not sys.stdin.isatty():
1053
+ ns.prompt = sys.stdin.read().strip()
1054
+ if not ns.prompt:
1055
+ _die("Empty input from stdin.")
1056
+ ns.print_mode = True
1057
+
1058
+ if ns.print_mode and ns.prompt is None:
1059
+ _die("Print mode requires a prompt (positional argument or stdin).")
1060
+
1061
+ # Shared construction — identical to the path the TUI uses.
1062
+ session = await build_session(ns)
1063
+ _wire_signals(session)
1064
+
1065
+ if not ns.print_mode:
1066
+ _banner(session.provider_name, session.model, session.session_id, ns.verbose)
1067
+
1068
+ # --- dispatch ------------------------------------------------------
1069
+
1070
+ try:
1071
+ if ns.prompt:
1072
+ return await _run_one_shot(session, ns.prompt, print_mode=ns.print_mode)
1073
+ return await _run_interactive(session)
1074
+ finally:
1075
+ await session.shutdown()
1076
+
1077
+
1078
+ # ---------------------------------------------------------------------------
1079
+ # Entry point
1080
+ # ---------------------------------------------------------------------------
1081
+
1082
+
1083
+ def main(argv: Sequence[str] | None = None) -> int:
1084
+ """Top-level CLI entry point for rlm-agent."""
1085
+ # Model output is Unicode; a redirected Windows stream defaults to a legacy codepage.
1086
+ for stream in (sys.stdout, sys.stderr):
1087
+ if hasattr(stream, "reconfigure"):
1088
+ stream.reconfigure(encoding="utf-8", errors="replace")
1089
+ args = list(argv if argv is not None else sys.argv[1:])
1090
+ if "--offline" in args:
1091
+ os.environ["RLM_AGENT_OFFLINE"] = "1"
1092
+ args.remove("--offline")
1093
+ from rlmagent.packages import activate
1094
+
1095
+ activate()
1096
+
1097
+ if "--acp" in args:
1098
+ from rlmagent.acp.server import run as serve_acp
1099
+
1100
+ args.remove("--acp")
1101
+ return serve_acp(args)
1102
+
1103
+ # Subcommand dispatch (checked before argparse to avoid positional conflicts)
1104
+ if args and args[0] in _SUBCOMMANDS:
1105
+ cmd, rest = args[0], args[1:]
1106
+ if cmd == "package":
1107
+ from rlmagent.packages import run
1108
+
1109
+ return run(rest)
1110
+ if cmd == "acp":
1111
+ from rlmagent.model.external.acp_install import main as acp_main
1112
+ return acp_main(rest)
1113
+ if cmd == "config":
1114
+ from rlmagent.cli.config_cmd import handle_config
1115
+ return handle_config(rest)
1116
+ if cmd == "factory":
1117
+ from rlmagent.factory import run
1118
+
1119
+ return run(rest)
1120
+ if cmd == "tui":
1121
+ ns = _build_run_parser().parse_args(rest)
1122
+ if ns.supervised or _saved_interactive(ns):
1123
+ return _handle_supervised(ns, rest, tui=True)
1124
+ return _handle_tui(ns)
1125
+ if cmd == "provider":
1126
+ return _handle_provider(rest)
1127
+ if cmd == "session":
1128
+ return _handle_session(rest)
1129
+ if cmd == "worker":
1130
+ from rlmagent.worker import handle
1131
+
1132
+ return handle(rest)
1133
+ if cmd == "update":
1134
+ from rlmagent.cli.update import run
1135
+
1136
+ return run(rest)
1137
+ if cmd in ("status", "doctor"):
1138
+ from rlmagent.cli.health import run
1139
+
1140
+ return run(rest, doctor=cmd == "doctor")
1141
+
1142
+ # Default: run mode
1143
+ ns = _build_run_parser().parse_args(args)
1144
+
1145
+ # A background worker owns the session only when asked for, or when the session
1146
+ # being resumed already has one; otherwise the full UI runs in this process.
1147
+ if ns.supervised or (_saved_interactive(ns) and sys.stdin.isatty()
1148
+ and _live_worker_owns(ns.resume)):
1149
+ tui = not ns.repl and sys.stdin.isatty() and sys.stdout.isatty() and _textual_available()
1150
+ return _handle_supervised(ns, args, tui=tui)
1151
+
1152
+ # Explicit foreground and no-session runs can still use the in-process TUI.
1153
+ if not ns.contract and _should_launch_tui(ns):
1154
+ return _handle_tui(ns)
1155
+
1156
+ try:
1157
+ return asyncio.run(_async_main(ns))
1158
+ except KeyboardInterrupt:
1159
+ _info("\nInterrupted.")
1160
+ return 130
1161
+
1162
+
1163
+ def _live_worker_owns(session_id: str | None) -> bool:
1164
+ """Whether a running background worker holds this saved session."""
1165
+ if not session_id:
1166
+ return False
1167
+ from rlmagent import worker
1168
+
1169
+ for name in worker._worker_names():
1170
+ meta = worker.state_of(name)
1171
+ if meta.get("session") == session_id and (meta["alive"] or worker._supervised(meta)):
1172
+ return True
1173
+ return False
1174
+
1175
+
1176
+ def _saved_interactive(ns: argparse.Namespace) -> bool:
1177
+ return not (ns.foreground or ns.no_session or ns.contract or ns.print_mode or ns.prompt is not None)
1178
+
1179
+
1180
+ def _handle_supervised(ns: argparse.Namespace, args: list[str], *, tui: bool) -> int:
1181
+ if ns.no_session or ns.contract or ns.print_mode or ns.prompt is not None:
1182
+ _die("--supervised requires an interactive saved session")
1183
+ from rlmagent.worker import start_interactive
1184
+
1185
+ if tui and not _textual_available():
1186
+ _die("The TUI requires the 'textual' extra. Install it with: pip install rlmness-cli[tui]")
1187
+ try:
1188
+ return start_interactive(args, session_id=ns.resume, tui=tui)
1189
+ except ValueError as error:
1190
+ _die(str(error))
1191
+
1192
+
1193
+ if __name__ == "__main__":
1194
+ raise SystemExit(main())