From 54baf579b30b072f5fca34f5cbe6a39c5db6440d Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 21:35:10 +0100 Subject: [PATCH 1/9] build: declare prompt-toolkit and markdown-it-py, which the session imports --- pyproject.toml | 2 ++ uv.lock | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a9ebdb1..c92a882 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "click>=8.3.3", "claude-agent-sdk>=0.2.174", "rich>=13.0.0", + "markdown-it-py>=2.2.0", + "prompt-toolkit>=3.0.40", "questionary>=2.0.0", "watchdog>=3.0.0", "setproctitle>=1.3.0", diff --git a/uv.lock b/uv.lock index e39d339..5b58e6e 100644 --- a/uv.lock +++ b/uv.lock @@ -1732,8 +1732,10 @@ dependencies = [ { name = "click" }, { name = "cryptography" }, { name = "httpx2" }, + { name = "markdown-it-py" }, { name = "multidict" }, { name = "opentelemetry-api" }, + { name = "prompt-toolkit" }, { name = "pygments" }, { name = "pyjwt" }, { name = "python-multipart" }, @@ -1780,11 +1782,13 @@ requires-dist = [ { name = "cryptography", specifier = ">=50.0.0" }, { name = "github-copilot-sdk", marker = "extra == 'copilot'", specifier = ">=1.0.26,<2" }, { name = "httpx2", specifier = ">=2.12.0" }, + { name = "markdown-it-py", specifier = ">=2.2.0" }, { name = "markdownify", marker = "extra == 'collector'", specifier = ">=0.14.1" }, { name = "multidict", specifier = ">=6.9.1" }, { name = "opentelemetry-api", specifier = ">=1.45.1" }, { name = "playwright", marker = "extra == 'manual'", specifier = ">=1.40.0" }, { name = "playwright-stealth", marker = "extra == 'manual'", specifier = ">=1.0.0" }, + { name = "prompt-toolkit", specifier = ">=3.0.40" }, { name = "pygments", specifier = ">=2.20.0" }, { name = "pyjwt", specifier = ">=2.15.0" }, { name = "python-multipart", specifier = ">=0.0.31" }, From 1981e8735ed14ce7b78f72e65fc97b0b3e0a10f3 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 21:35:10 +0100 Subject: [PATCH 2/9] fix(cli): accept /quit and /commands in the session --- src/reverse_api/repl/app.py | 2 ++ tests/test_repl.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/reverse_api/repl/app.py b/src/reverse_api/repl/app.py index fcb3bbb..b0426df 100644 --- a/src/reverse_api/repl/app.py +++ b/src/reverse_api/repl/app.py @@ -75,6 +75,7 @@ "/help": "keys and commands", "/exit": "quit rae", } +ALIASES = {"/quit": "/exit", "/commands": "/help"} HISTORY_RUNS = 20 # Commands that open a picker are echoed once a choice shows something, not when the picker opens. PICKER_COMMANDS = {"/model", "/effort", "/mode", "/history", "/messages", "/resume"} @@ -280,6 +281,7 @@ def cycle_effort(self) -> None: def run_command(self, text: str) -> None: cmd, _, arg = text.partition(" ") cmd, arg = cmd.lower(), arg.strip() + cmd = ALIASES.get(cmd, cmd) st, view = self.st, self.view if cmd not in PICKER_COMMANDS | {"/exit", "/clear"}: view.command(cmd, arg) diff --git a/tests/test_repl.py b/tests/test_repl.py index c007685..ff3f87a 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -417,6 +417,15 @@ def test_commands_are_echoed(self, app_session): session.run_command("/help") assert "› /help" in plain(session.transcript) + def test_quit_and_commands_are_aliases(self, app_session, monkeypatch): + session, _history = app_session + quits = [] + monkeypatch.setattr(session, "quit", lambda: quits.append(True)) + session.run_command("/quit") + assert quits == [True] + session.run_command("/commands") + assert "› /help" in plain(session.transcript) and "unknown command" not in plain(session.transcript) + def test_an_exact_command_wins_over_longer_ones(self, app_session): session, _history = app_session session.buf.text = "/mode" From d9d464fb6cb49b02f5cd3b43d2b5bc4bfbf05566 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 21:35:10 +0100 Subject: [PATCH 3/9] docs: describe the full-screen session, the current models and effort --- README.md | 4 +- website/content/docs/cli/commands.mdx | 62 +++++++++++++------ website/content/docs/configuration/models.mdx | 38 +++++++++--- website/content/docs/index.mdx | 10 +-- website/content/docs/modes/agent.mdx | 20 +++++- website/content/docs/modes/collector.mdx | 2 +- website/content/docs/modes/manual.mdx | 6 +- website/content/docs/quick-start.mdx | 5 +- 8 files changed, 105 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 8f9c257..bd9bad2 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,14 @@ Settings live in `~/.reverse-api/config.json` and can be edited via `/settings` ## CLI -`reverse-api-engineer` opens a full-screen session; `agent`, `engineer` and `collector` open it too when run in a terminal (without `--json` or `--no-interactive`). Type `/` for the commands: +`reverse-api-engineer` opens a full-screen session; `agent`, `engineer` and `collector` open it too when run in a terminal (`agent` and `engineer` stay plain with `--json` or `--no-interactive`). Type `/` for the commands: - `/model`, `/effort`, `/mode`: switch the model, how hard it thinks, and the mode (also Shift+Tab, ctrl+e). - `/settings`: SDK, agent provider, output and sync settings. - `/history`: past runs, with their details, messages, and re-engineer or resume. - `/messages [run_id]`: what the agent did in a run. - `/resume [run_id]`: continue a past run's conversation where it stopped, on any SDK. Works for runs that saved their SDK session: agent and engineer runs from this version on. Collector runs cannot be resumed. - `/output` (ctrl+o): everything from the session, unfolded. `/open`: the latest client. -- `/new`, `/clear`, `/help`, `/exit`. +- `/new`, `/clear`, `/help` (or `/commands`), `/exit` (or `/quit`). Scriptable subcommands (pipe to `jq`): diff --git a/website/content/docs/cli/commands.mdx b/website/content/docs/cli/commands.mdx index 377037b..c9eed51 100644 --- a/website/content/docs/cli/commands.mdx +++ b/website/content/docs/cli/commands.mdx @@ -1,32 +1,52 @@ --- title: Slash commands -description: Commands available inside the interactive CLI. +description: Commands and keys of the interactive session. --- -While in the interactive prompt, the following slash commands are available: +Running `reverse-api-engineer` opens a full-screen session: the conversation +scrolls above an input bar fixed to the bottom. Type `/` to open the command +menu; `Tab` completes and `Enter` runs the highlighted command. -| Command | What it does | -|------------------------|-----------------------------------------------------------| -| `/settings` | Configure model, agent provider, SDK, output directory. | -| `/history` | View past runs with token usage and cost. | -| `/messages ` | View the full message log for a previous run. | -| `/help` | List all commands. Alias: `/commands`. | -| `/exit` | Quit the CLI. | +| Command | What it does | +|------------------------|---------------------------------------------------------------------| +| `/new` | End the open run and start a new capture. | +| `/model` | Choose the model. In collector mode, the collector's own model. | +| `/effort` | How hard the agent thinks, for Claude models (`low` to `max`). | +| `/mode` | Switch between agent, manual, engineer and collector. | +| `/settings` | SDK, agent provider, output directory, language and sync settings. | +| `/history` | Past runs with their cost; open one to resume, re-engineer or read its messages. | +| `/messages [run_id]` | What the agent did in a run, step by step. | +| `/resume [run_id]` | Continue a past run's conversation where it stopped, on any SDK. | +| `/output` | Everything from the session, unfolded. | +| `/open` | Open the latest generated client. | +| `/clear` | Clear the conversation and its spend. | +| `/help` | Keys and commands. Alias: `/commands`. | +| `/exit` | Quit. Alias: `/quit`. | -## Mode cycling +`/resume` works for runs that saved their SDK session, which agent and engineer +runs do from this release on. Older runs and collector runs cannot be resumed. -Press **`Shift+Tab`** to cycle between modes: +## Keys -```text -agent → manual → engineer → collector → agent ... -``` +| Key | What it does | +|----------------------|------------------------------------------------------------| +| `Enter` | Send. While the agent works, the message is queued. | +| `Esc` | Stop the run, or close a menu. | +| `Shift+Tab` | Cycle the mode: agent → manual → engineer → collector. | +| `Ctrl+E` | Cycle the effort level. | +| `Ctrl+O` | Show the full output of folded steps. | +| `PageUp` / `PageDown`| Scroll the conversation. `End` jumps back to the latest. | +| `Ctrl+D` | Quit. | -The current mode is shown in the prompt. Each mode has its own behavior. See -[Usage modes](/docs/modes/manual). +Click a file or URL in the conversation to open it. + +When the agent asks a question, its options show under the input: pick one +with the arrow keys, press `Space` to pick several when the question allows +it, or type your own answer. ## Direct subcommands -Outside the interactive CLI, you can use direct subcommands: +Outside the session, you can use direct subcommands: | Command | What it does | |---------------|------------------------------------------------------------| @@ -37,10 +57,16 @@ Outside the interactive CLI, you can use direct subcommands: | `show` | Inspect one run; supports `--json`. | | `run` | Execute a generated client with its language toolchain. | +In a terminal, `agent`, `engineer` and `collector` open the session in their +mode and start the run, keeping options such as `--headless` and +`--output-dir` for the prompts you type next. `agent` and `engineer` print +plain output instead with `--json`, `--json-stream` or `--no-interactive`; +`collector` does when it runs outside a terminal. + Scripted flags are command-specific: `agent`, `engineer`, and `run` support `--json` and `--json-stream`; `list` and `show` support `--json`; and `run` also supports `--no-interactive` for script selection and dependency prompts. See [Scripted usage](/docs/cli/scripted-usage). -Manual mode is available only inside the interactive REPL because it opens a +Manual mode is available only inside the interactive session because it opens a visible browser for human-driven capture. diff --git a/website/content/docs/configuration/models.mdx b/website/content/docs/configuration/models.mdx index f4d2275..2a36b0c 100644 --- a/website/content/docs/configuration/models.mdx +++ b/website/content/docs/configuration/models.mdx @@ -7,32 +7,43 @@ description: Choose a model for Claude, OpenCode, Copilot, or Cursor. | Model | When to pick it | |---------------|--------------------------------------------------------------------------| -| **Sonnet 4.6** *(default)* | Balanced cost and quality. Right answer for most captures. | -| **Opus 4.6** | Complex APIs (many endpoints, nested auth, polymorphic responses). | -| **Haiku 4.5** | Cheapest and fastest. Good for simple endpoints or quick iterations. | +| **Sonnet 5.5** | Balanced cost and quality, faster and cheaper than Sonnet 4.6. | +| **Opus 5.5** | Complex APIs (many endpoints, nested auth, polymorphic responses). | +| **Opus 4.6** | The previous Opus, for comparison or when a run needs it. | +| **Haiku 5.5** | Cheapest and fastest. Good for simple endpoints or quick iterations. | -## Change the model +Sonnet 4.6 stays the default until you choose another model. It and Haiku 4.5 +are no longer offered in the pickers, but `--model` and `config.json` still +accept them. -In the CLI: +## Change the model -```text -/settings then "Claude Code Model" -``` +In the session, type `/model`. In collector mode, `/model` sets the +collector's own model (`collector_model`). Or pass a model to a direct command: ```bash -reverse-api-engineer agent --prompt "capture the jobs api" --model claude-sonnet-4-6 +reverse-api-engineer agent --prompt "capture the jobs api" --model claude-sonnet-5-5 ``` Or edit `~/.reverse-api/config.json` directly: ```json { - "claude_code_model": "claude-opus-4-6" + "claude_code_model": "claude-opus-5-5" } ``` +## Effort + +Claude models take an effort level, from `low` to `max`: how hard the agent +thinks before acting. Higher effort is slower and costs more, and helps on hard +captures. Set it with `/effort` or `Ctrl+E` in the session, or with +`claude_effort` in the config. Leaving it unset keeps each model's default: +`medium` for Opus 5.5 and Haiku 5.5, `high` for the others. Haiku 4.5 has no +effort setting. + ## Config file reference The full default config: @@ -43,6 +54,8 @@ The full default config: "agent_browser_notes": "", "agent_browser_npx_package": "agent-browser@0", "claude_code_model": "claude-sonnet-4-6", + "claude_setting_sources": [], + "claude_effort": null, "collector_model": "claude-sonnet-4-6", "cursor_model": "composer-2.5", "cursor_web_search": true, @@ -50,6 +63,11 @@ The full default config: "copilot_model": "gpt-5", "opencode_model": "big-pickle", "opencode_provider": "opencode", + "opencode_auto_start": true, + "opencode_base_url": "http://127.0.0.1:4096", + "opencode_npx_package": "opencode-ai@latest", + "ollama_auto_start": true, + "ollama_base_url": "http://127.0.0.1:11434", "output_dir": null, "output_language": "python", "real_time_sync": true, diff --git a/website/content/docs/index.mdx b/website/content/docs/index.mdx index 33914f0..abb3a4c 100644 --- a/website/content/docs/index.mdx +++ b/website/content/docs/index.mdx @@ -98,11 +98,13 @@ Use **Anthropic's API directly** (default) or route through **OpenCode** running locally. Copilot and Cursor SDK backends are also available when their optional dependencies and credentials are configured. -### Interactive CLI with mode cycling +### Full-screen interactive CLI -Press **`Shift+Tab`** to cycle between modes. Slash commands (`/settings`, -`/history`, `/messages`) keep configuration and run inspection one keystroke -away. +The agent's steps, messages and reasoning stream into one conversation, with +the input bar always at the bottom. Press **`Shift+Tab`** to cycle between +modes; slash commands (`/model`, `/effort`, `/history`, `/resume`, +`/settings`) keep configuration, past runs and their conversations one +keystroke away. See [Slash commands](/docs/cli/commands). ### Session history & cost tracking diff --git a/website/content/docs/modes/agent.mdx b/website/content/docs/modes/agent.mdx index 193ae64..bd53440 100644 --- a/website/content/docs/modes/agent.mdx +++ b/website/content/docs/modes/agent.mdx @@ -23,12 +23,28 @@ English; providers such as Playwright MCP or Chrome DevTools MCP route browser a ```bash reverse-api-engineer -# the REPL starts in agent mode -> click on the first job listing +# the session starts in agent mode +> all open jobs on jobs.ashbyhq.com/openai ``` +Or start from the subcommand: in a terminal, `agent` opens the same session +and starts the run right away, with its options kept for the prompts you type +next. + +```bash +reverse-api-engineer agent --prompt "all open jobs on jobs.ashbyhq.com/openai" --headless +``` + +The agent's steps, messages and reasoning stream into the conversation. Type a +follow-up when it finishes, answer its questions in the input bar, and press +`Esc` to stop it. `/resume` picks a past run back up where it stopped. + ## Running non-interactively +With `--json`, `--json-stream` or `--no-interactive`, `agent` never opens the +session: it prints plain output and exits, which is what scripts and other +agents want. + ```bash reverse-api-engineer agent \ --prompt "capture the public jobs api" \ diff --git a/website/content/docs/modes/collector.mdx b/website/content/docs/modes/collector.mdx index 8a54878..94c3d2e 100644 --- a/website/content/docs/modes/collector.mdx +++ b/website/content/docs/modes/collector.mdx @@ -3,7 +3,7 @@ title: Collector mode description: Use an AI agent to collect structured web data as JSON and CSV. --- -Collector mode is an interactive REPL mode for data collection tasks. Instead +Collector mode is a mode of the interactive session for data collection tasks. Instead of generating an API client from a HAR, it runs a Claude-powered collector that uses web search and fetch tools, writes collected items, and exports structured artifacts. diff --git a/website/content/docs/modes/manual.mdx b/website/content/docs/modes/manual.mdx index d1c68c6..d1d6db8 100644 --- a/website/content/docs/modes/manual.mdx +++ b/website/content/docs/modes/manual.mdx @@ -25,14 +25,14 @@ playwright install chromium - Start the interactive REPL: + Start the interactive session: ```bash reverse-api-engineer ``` Press **`Shift+Tab`** once from the default agent prompt to switch into - manual mode. Manual mode is REPL-only because it requires a human in front - of a visible browser. + manual mode. Manual mode only runs from the interactive session because it + requires a human in front of a visible browser. Enter a task description (e.g. *"Fetch Apple job listings"*). diff --git a/website/content/docs/quick-start.mdx b/website/content/docs/quick-start.mdx index 01ee80c..cb6ba62 100644 --- a/website/content/docs/quick-start.mdx +++ b/website/content/docs/quick-start.mdx @@ -11,8 +11,9 @@ description: From a fresh install to a working API client in under five minutes. reverse-api-engineer ``` - You land in a minimalist interactive prompt. Cycle between modes with - **`Shift+Tab`**: + You land in a full-screen session: the conversation above, an input bar + fixed at the bottom. Type `/` for the commands, and cycle between modes + with **`Shift+Tab`**: - **agent**: autonomous AI browser agent (default) - **manual**: browser capture + AI generation (you drive) From 457aeca90b094288fad38b32616bc14ed5139b38 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 22:41:40 +0100 Subject: [PATCH 4/9] fix(cli): keep engineer's --output-dir for later prompts, and find commands by alias in the menu --- src/reverse_api/cli.py | 11 +++++++++++ src/reverse_api/repl/app.py | 17 ++++------------- tests/test_repl.py | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 504aacb..00ad0ea 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1874,6 +1874,7 @@ def engineer( lambda: run_engineer( run_id, prompt=main_prompt, additional_instructions=additional, model=model, output_dir=output_dir, is_fresh=fresh ), + lambda p, m: engineer_from_prompt(p, m or model, output_dir), ) return if not machine_output: @@ -1922,6 +1923,16 @@ def engineer( sys.exit(0 if payload["status"] == "ok" else 1) +def engineer_from_prompt(text: str, model: str | None = None, output_dir: str | None = None) -> dict[str, Any] | None: + """A prompt in engineer mode: a known run id re-engineers that run; anything else is an instruction for the latest run.""" + if session_manager.get_run(text): + return run_engineer(text, model=model, output_dir=output_dir) + latest = session_manager.get_history(limit=1) + if not latest: + raise RuntimeError("no runs yet · start one in agent or manual mode") + return run_engineer(latest[0]["run_id"], model=model, output_dir=output_dir, additional_instructions=text) + + def run_engineer( run_id: str, har_path: Path | None = None, diff --git a/src/reverse_api/repl/app.py b/src/reverse_api/repl/app.py index b0426df..ed11399 100644 --- a/src/reverse_api/repl/app.py +++ b/src/reverse_api/repl/app.py @@ -215,7 +215,9 @@ def palette_active(self) -> bool: def palette_items(self) -> list[tuple[str, str]]: typed = self.buf.text.strip().lower() - return sorted(((c, d) for c, d in COMMANDS.items() if c.startswith(typed)), key=lambda item: item[0] != typed) + aliased = {target for alias, target in ALIASES.items() if alias.startswith(typed)} + items = [(c, d) for c, d in COMMANDS.items() if c.startswith(typed) or c in aliased] + return sorted(items, key=lambda item: item[0] != ALIASES.get(typed, typed)) def choose(self) -> None: """Enter on an open picker: its highlighted item, or for an agent's question, whatever was typed meanwhile.""" @@ -498,7 +500,7 @@ def start(self, prompt: str, mode: str | None = None, echo: bool = True) -> None elif mode == "collector": run = lambda: cli.run_collector(prompt=prompt, model=model) # noqa: E731 elif mode == "engineer": - run = lambda: self.engineer(prompt, model) # noqa: E731 + run = lambda: cli.engineer_from_prompt(prompt, model) # noqa: E731 else: run = lambda: cli.run_agent_capture(prompt=prompt, model=model) # noqa: E731 self.launch(prompt, run, mode, echo) @@ -534,17 +536,6 @@ def check() -> None: threading.Thread(target=check, name="rae-update-check", daemon=True).start() - def engineer(self, text: str, model: str | None) -> dict[str, Any] | None: - """A known run id re-engineers that run; anything else is an instruction for the latest run.""" - sm = self.cli.session_manager - if sm.get_run(text): - return self.cli.run_engineer(text, model=model) - latest = sm.get_history(limit=1) - if not latest: - self.view.error("no runs yet · start one in agent or manual mode") - return None - return self.cli.run_engineer(latest[0]["run_id"], model=model, additional_instructions=text) - def work(self, fn: Callable[[], Any]) -> None: """Run an engine in a worker thread; its UI calls stream into the conversation through the bridge.""" diff --git a/tests/test_repl.py b/tests/test_repl.py index ff3f87a..d0aba50 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -5,7 +5,7 @@ import threading from concurrent.futures import Future from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from rich.console import Console @@ -426,6 +426,13 @@ def test_quit_and_commands_are_aliases(self, app_session, monkeypatch): session.run_command("/commands") assert "› /help" in plain(session.transcript) and "unknown command" not in plain(session.transcript) + def test_the_menu_finds_commands_by_alias(self, app_session): + session, _history = app_session + session.buf.text = "/qu" + assert [c for c, _ in session.palette_items()] == ["/exit"] + session.buf.text = "/c" + assert {c for c, _ in session.palette_items()} == {"/clear", "/help"} + def test_an_exact_command_wins_over_longer_ones(self, app_session): session, _history = app_session session.buf.text = "/mode" @@ -879,6 +886,34 @@ def test_agent_in_a_terminal_opens_the_session_with_every_option(self, monkeypat } assert capture.call_args_list[1].kwargs == {"prompt": "later task", "model": "claude-opus-5-5", "output_dir": None, "headless": True} + def test_engineer_keeps_its_output_dir_for_later_prompts(self, monkeypatch): + from click.testing import CliRunner + + from reverse_api import cli + + monkeypatch.setattr(cli, "_in_terminal", lambda: True) + with patch.object(cli, "open_session") as session, patch.object(cli, "engineer_from_prompt") as later: + CliRunner().invoke(cli.main, ["engineer", "abc123", "-o", "/out"]) + runner = session.call_args.args[4] + runner("add pagination", None) + later.assert_called_once_with("add pagination", None, "/out") + + def test_an_engineer_prompt_is_a_run_id_or_an_instruction_for_the_latest_run(self, monkeypatch): + from reverse_api import cli + + history = MagicMock() + history.get_run.side_effect = lambda text: {"run_id": text} if text == "abc123" else None + history.get_history.return_value = [{"run_id": "latest1"}] + monkeypatch.setattr(cli, "session_manager", history) + with patch.object(cli, "run_engineer") as engineer: + cli.engineer_from_prompt("abc123", "m", "/out") + cli.engineer_from_prompt("add pagination", "m", "/out") + assert engineer.call_args_list[0] == call("abc123", model="m", output_dir="/out") + assert engineer.call_args_list[1] == call("latest1", model="m", output_dir="/out", additional_instructions="add pagination") + history.get_history.return_value = [] + with pytest.raises(RuntimeError, match="no runs yet"): + cli.engineer_from_prompt("add pagination") + def test_without_a_terminal_a_prompt_is_required(self, monkeypatch): from click.testing import CliRunner From 37c48a8bdcb9712ffedb8bc94713d0c58627a55b Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 22:41:40 +0100 Subject: [PATCH 5/9] docs: say which runs /resume can pick up --- website/content/docs/modes/agent.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/content/docs/modes/agent.mdx b/website/content/docs/modes/agent.mdx index bd53440..f722e74 100644 --- a/website/content/docs/modes/agent.mdx +++ b/website/content/docs/modes/agent.mdx @@ -37,7 +37,8 @@ reverse-api-engineer agent --prompt "all open jobs on jobs.ashbyhq.com/openai" - The agent's steps, messages and reasoning stream into the conversation. Type a follow-up when it finishes, answer its questions in the input bar, and press -`Esc` to stop it. `/resume` picks a past run back up where it stopped. +`Esc` to stop it. `/resume` picks a past run back up where it stopped, for +runs that saved their SDK session. ## Running non-interactively From 1f1e40db5cb4b43d983b44b8b1d2f6c17b3bee59 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 22:52:12 +0100 Subject: [PATCH 6/9] feat(cli): show the run id under the input bar, and the run's folders on the verified step --- src/reverse_api/base_engineer.py | 7 +++++++ src/reverse_api/engineer.py | 1 + src/reverse_api/repl/app.py | 2 +- src/reverse_api/repl/bridge.py | 8 +++++++- src/reverse_api/repl/view.py | 8 ++++++++ tests/test_engineer.py | 11 ++++++++++ tests/test_repl.py | 35 ++++++++++++++++++++++++++++++++ 7 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/reverse_api/base_engineer.py b/src/reverse_api/base_engineer.py index 70a7228..0db5a42 100644 --- a/src/reverse_api/base_engineer.py +++ b/src/reverse_api/base_engineer.py @@ -97,6 +97,13 @@ def _report_usage(self) -> None: if isinstance(self.ui, LedgerUI): self.ui.usage(self.usage_metadata) + def _report_workspace(self) -> None: + """Tells the full-screen session where the run's files are, for its verified step.""" + from .repl.bridge import LedgerUI + + if isinstance(self.ui, LedgerUI): + self.ui.workspace(self.scripts_dir, self.local_scripts_dir) + async def _opening_message(self, brief: str) -> str | None: """The first message of the run: its brief, or when resuming, the user's next message.""" message = await self._prompt_follow_up() if self.resume_session else brief diff --git a/src/reverse_api/engineer.py b/src/reverse_api/engineer.py index cac2ab2..a630df9 100644 --- a/src/reverse_api/engineer.py +++ b/src/reverse_api/engineer.py @@ -127,6 +127,7 @@ async def report_client_verified(args: dict[str, Any]) -> dict[str, Any]: ] } already_reported = True + self._report_workspace() # Deliberately no manual message_store/UI logging here — the # generic ToolUseBlock/ToolResultBlock handling in # _process_streaming_response already logs every tool call, diff --git a/src/reverse_api/repl/app.py b/src/reverse_api/repl/app.py index ed11399..ef077f3 100644 --- a/src/reverse_api/repl/app.py +++ b/src/reverse_api/repl/app.py @@ -335,7 +335,7 @@ def run_command(self, text: str) -> None: view.flush_live() self.transcript.clear() st.scroll, st.folded, st.cost, st.tokens, st.step = 0, False, 0.0, 0, 0 - st.last_client, st.client_name, st.host = None, "", "" + st.last_client, st.client_name, st.host, st.run_id = None, "", "", None self.bridge.cost_base = 0.0 elif cmd == "/settings": self.classic(lambda: self.cli.handle_settings(), then=self.reload_settings, pause=False) diff --git a/src/reverse_api/repl/bridge.py b/src/reverse_api/repl/bridge.py index ab2c3a7..d680e69 100644 --- a/src/reverse_api/repl/bridge.py +++ b/src/reverse_api/repl/bridge.py @@ -104,6 +104,7 @@ def __init__(self, view: LedgerView, invalidate: Callable[[], None], ask_user: C self.pending_card: Path | None = None self.cost_base = 0.0 self.verified = False # the agent called report_client_verified in this run + self.workspace: tuple[Path, Path | None] | None = None # the run's folder and its synced copy def ui(self) -> LedgerUI: return LedgerUI(self) @@ -203,7 +204,7 @@ def header(self, run_id: str, prompt: str, model: str | None = None, sdk: str | st.run_id, st.step, st.tokens = run_id, 0, 0 st.running, st.waiting, st.verb, st.kind = True, False, "starting", "other" self.b.cost_base = st.cost - self.b.verified = False + self.b.verified, self.b.workspace = False, None # The run's own model and SDK; the selection in st.model stays for the next run. st.run_model, st.run_sdk = model or st.model, sdk or st.sdk claude = st.run_sdk == "claude" and not predates_effort(st.run_model) @@ -250,6 +251,8 @@ def tool_result(self, tool_name: str, is_error: bool = False, output: str | None if name in HIDDEN_TOOLS: return if name == "report_client_verified" and not is_error: + if not self.b.verified and self.b.workspace: + self.view.workspace(*self.b.workspace) self.b.verified = True # the card promises a client that ran, so only a confirmed report counts text = str(output or "").strip() lines = text.splitlines() @@ -305,6 +308,9 @@ def progress(self, message: str) -> None: self.view.notice(message) self._changed() + def workspace(self, folder: Path, copy: Path | None) -> None: + self.b.workspace = (folder.resolve(), copy.resolve() if copy else None) + def success(self, script_path: str, local_path: str | None = None) -> None: _ = local_path path = Path(script_path) diff --git a/src/reverse_api/repl/view.py b/src/reverse_api/repl/view.py index cb69a96..434c18c 100644 --- a/src/reverse_api/repl/view.py +++ b/src/reverse_api/repl/view.py @@ -482,6 +482,12 @@ def block(rows: list[str], more: int, cut: bool) -> Group: # The full copy is only styled if ctrl+o asks for it: tool output can run to thousands of lines. self.out(block(lines[:OUTPUT_PREVIEW], more, cut=True), Deferred(lambda: block(lines, 0, cut=False))) + def workspace(self, folder: Path, copy: Path | None) -> None: + """Where a verified client's files are, absolute so a click opens them.""" + rows = [("in", folder), ("copy", copy)] if copy else [("in", folder)] + for label, path in rows: + self.out(f"{' ' * INDENT}[{HAIRLINE}]└[/] [{DIM}]{label}[/] [{INK_SOFT}]{link(str(path), path)}[/]") + def code(self, source: str, language: str) -> None: """Written code, previewed from its first definition; ctrl+o shows all of it.""" total = source.count("\n") + 1 @@ -683,6 +689,8 @@ def status(self) -> FormattedText: hint = [(f"fg:{DIM}", "type a follow-up · /new starts over" + (" · ⌃o expand " if st.folded else " "))] else: hint = [(f"fg:{DIM}", "/ commands" + (" · ⌃o expand " if st.folded else " "))] + if st.run_id: + hint += [(f"fg:{DIM}", f"· run {st.run_id} ")] used = sum(len(t) for _, t in left) + sum(len(t) for _, t in hint) return FormattedText(left + [("", " " * max(1, width() - used))] + hint) diff --git a/tests/test_engineer.py b/tests/test_engineer.py index 604bcc8..de51e25 100644 --- a/tests/test_engineer.py +++ b/tests/test_engineer.py @@ -909,6 +909,17 @@ async def test_tool_emits_client_executed_event_when_called(self, tmp_path): assert len(client_executed) == 1 assert client_executed[0]["script_path"] == str(eng.scripts_dir / "api_client.py") + @pytest.mark.asyncio + async def test_tool_tells_the_session_where_the_files_are_once(self, tmp_path): + eng = self._make_engineer(tmp_path, output_language="python") + eng._report_workspace = MagicMock() + + tool = eng._build_verification_tool() + await tool.handler({"summary": "first confirmation"}) + await tool.handler({"summary": "second confirmation"}) + + eng._report_workspace.assert_called_once_with() + @pytest.mark.asyncio async def test_tool_second_call_in_same_session_does_not_re_emit(self, tmp_path): """The tool's own description tells the agent to call it exactly diff --git a/tests/test_repl.py b/tests/test_repl.py index d0aba50..c73b66e 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -120,6 +120,35 @@ def test_no_card_until_the_agent_verified_the_client(self, session, tmp_path): ui.usage({"input_tokens": 10, "output_tokens": 5, "estimated_cost_usd": 0.01}) assert "ready" not in plain(t), "a client path that does not exist gets no card" + def test_verified_shows_where_the_files_are(self, session): + _st, t, _view, b = session + ui = LedgerUI(b) + ui.header("abc123", "prompt", model="claude-sonnet-5-5") + folder, copy = Path("/runs/abc123"), Path("/work/scripts/jobs") + ui.workspace(folder, copy) + for _ in range(2): + ui.tool_start("mcp__verification__report_client_verified", {"summary": "ok"}) + ui.tool_result("mcp__verification__report_client_verified", output="Recorded") + text = plain(t) + assert text.count(f"└ in {folder.resolve()}") == 1 and text.count(f"└ copy {copy.resolve()}") == 1 + + def test_no_folders_without_a_confirmed_verification(self, session, tmp_path): + _st, t, _view, b = session + ui = LedgerUI(b) + ui.header("abc123", "prompt", model="claude-sonnet-5-5") + ui.workspace(tmp_path / "runs" / "abc123", None) + ui.tool_result("mcp__verification__report_client_verified", is_error=True, output="failed") + assert "└ in" not in plain(t) + ui.header("def456", "prompt", model="claude-sonnet-5-5") + ui.tool_result("mcp__verification__report_client_verified", output="Recorded") + assert "└ in" not in plain(t), "a new run forgets the last run's folder" + + def test_the_status_row_shows_the_run_id(self, session): + _st, _t, view, b = session + assert "run " not in "".join(text for _, text in view.status()) + LedgerUI(b).header("abc123", "prompt", model="claude-sonnet-5-5") + assert "run abc123" in "".join(text for _, text in view.status()) + def test_folded_output_offers_ctrl_o(self, session): st, _t, view, b = session assert "expand" not in "".join(text for _, text in view.status()) @@ -450,6 +479,12 @@ def test_pickers_echo_once_something_is_picked(self, app_session): pick(session.st, "abc123") assert "› /history abc123" in plain(session.transcript) + def test_clear_forgets_the_run_id(self, app_session): + session, _history = app_session + session.st.run_id = "abc123" + session.run_command("/clear") + assert session.st.run_id is None + def test_clear_starts_over_with_no_usage(self, app_session): session, _history = app_session st = session.st From a39093528f4fd4d503bf71f7e91773562ad9d08c Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 22:52:12 +0100 Subject: [PATCH 7/9] docs: the run id in the status row and the verified step's folders --- website/content/docs/cli/commands.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/content/docs/cli/commands.mdx b/website/content/docs/cli/commands.mdx index c9eed51..393ce6b 100644 --- a/website/content/docs/cli/commands.mdx +++ b/website/content/docs/cli/commands.mdx @@ -40,6 +40,11 @@ runs do from this release on. Older runs and collector runs cannot be resumed. Click a file or URL in the conversation to open it. +The status row under the input shows the run's id, which `/resume`, +`/messages` and `reverse-api-engineer show` take. Once the agent has verified +the client, its step lists the run's folder and the synced copy in +`./scripts`, as full paths. + When the agent asks a question, its options show under the input: pick one with the arrow keys, press `Space` to pick several when the question allows it, or type your own answer. From cb636cc9cd21a5874794da82735ebe0ea591dc68 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 23:01:43 +0100 Subject: [PATCH 8/9] fix(cli): engineer prompts skip runs with no capture, and the run id only shows when it fits --- src/reverse_api/cli.py | 19 +++++++++++-------- src/reverse_api/repl/view.py | 5 +++-- tests/test_repl.py | 24 +++++++++++++++++++----- website/content/docs/cli/commands.mdx | 4 ++-- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 00ad0ea..e9319b6 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1927,10 +1927,16 @@ def engineer_from_prompt(text: str, model: str | None = None, output_dir: str | """A prompt in engineer mode: a known run id re-engineers that run; anything else is an instruction for the latest run.""" if session_manager.get_run(text): return run_engineer(text, model=model, output_dir=output_dir) - latest = session_manager.get_history(limit=1) - if not latest: - raise RuntimeError("no runs yet · start one in agent or manual mode") - return run_engineer(latest[0]["run_id"], model=model, output_dir=output_dir, additional_instructions=text) + # Collector runs capture no traffic, so the latest run with a HAR. + latest = next((r for r in session_manager.get_history(limit=50) if _har_path(r["run_id"], r).exists()), None) + if latest is None: + raise RuntimeError("no captured runs yet · start one in agent or manual mode") + return run_engineer(latest["run_id"], model=model, output_dir=output_dir, additional_instructions=text) + + +def _har_path(run_id: str, run: dict[str, Any]) -> Path: + """Where a run in history keeps its captured traffic.""" + return Path((run.get("paths") or {}).get("har_dir") or get_har_dir(run_id, None)) / "recording.har" def run_engineer( @@ -1962,10 +1968,7 @@ def run_engineer( else: if not prompt: prompt = run_data["prompt"] - # Detect where it was saved - paths = run_data.get("paths", {}) - har_dir = Path(paths.get("har_dir", get_har_dir(run_id, None))) - har_path = har_dir / "recording.har" + har_path = _har_path(run_id, run_data) sdk = _session_sdk(resume) or config_manager.get("sdk", "claude") enable_sync = config_manager.get("real_time_sync", True) diff --git a/src/reverse_api/repl/view.py b/src/reverse_api/repl/view.py index 434c18c..3d8c9e8 100644 --- a/src/reverse_api/repl/view.py +++ b/src/reverse_api/repl/view.py @@ -689,9 +689,10 @@ def status(self) -> FormattedText: hint = [(f"fg:{DIM}", "type a follow-up · /new starts over" + (" · ⌃o expand " if st.folded else " "))] else: hint = [(f"fg:{DIM}", "/ commands" + (" · ⌃o expand " if st.folded else " "))] - if st.run_id: - hint += [(f"fg:{DIM}", f"· run {st.run_id} ")] used = sum(len(t) for _, t in left) + sum(len(t) for _, t in hint) + run = f"· run {st.run_id} " if st.run_id else "" + if run and used + 1 + len(run) <= width(): # left out rather than cut off + hint, used = hint + [(f"fg:{DIM}", run)], used + len(run) return FormattedText(left + [("", " " * max(1, width() - used))] + hint) def menu(self, rows: list[tuple[str, str, bool, bool]], title: str | None = None, limit: int = MENU_ROWS) -> FormattedText: diff --git a/tests/test_repl.py b/tests/test_repl.py index c73b66e..d9d5e46 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -149,6 +149,14 @@ def test_the_status_row_shows_the_run_id(self, session): LedgerUI(b).header("abc123", "prompt", model="claude-sonnet-5-5") assert "run abc123" in "".join(text for _, text in view.status()) + def test_a_run_id_that_does_not_fit_is_left_out(self, session, monkeypatch): + from reverse_api.repl import view as view_module + + _st, _t, view, b = session + LedgerUI(b).header("abc123", "prompt", model="claude-sonnet-5-5") + monkeypatch.setattr(view_module, "width", lambda: 40) + assert "run abc123" not in "".join(text for _, text in view.status()) + def test_folded_output_offers_ctrl_o(self, session): st, _t, view, b = session assert "expand" not in "".join(text for _, text in view.status()) @@ -933,20 +941,26 @@ def test_engineer_keeps_its_output_dir_for_later_prompts(self, monkeypatch): runner("add pagination", None) later.assert_called_once_with("add pagination", None, "/out") - def test_an_engineer_prompt_is_a_run_id_or_an_instruction_for_the_latest_run(self, monkeypatch): + def test_an_engineer_prompt_is_a_run_id_or_an_instruction_for_the_latest_capture(self, monkeypatch, tmp_path): from reverse_api import cli + captured = tmp_path / "har" / "capture1" + captured.mkdir(parents=True) + (captured / "recording.har").write_text("{}", encoding="utf-8") history = MagicMock() history.get_run.side_effect = lambda text: {"run_id": text} if text == "abc123" else None - history.get_history.return_value = [{"run_id": "latest1"}] + history.get_history.return_value = [ + {"run_id": "collect1", "paths": {"har_dir": str(tmp_path / "har" / "collect1")}}, + {"run_id": "capture1", "paths": {"har_dir": str(captured)}}, + ] monkeypatch.setattr(cli, "session_manager", history) with patch.object(cli, "run_engineer") as engineer: cli.engineer_from_prompt("abc123", "m", "/out") cli.engineer_from_prompt("add pagination", "m", "/out") assert engineer.call_args_list[0] == call("abc123", model="m", output_dir="/out") - assert engineer.call_args_list[1] == call("latest1", model="m", output_dir="/out", additional_instructions="add pagination") - history.get_history.return_value = [] - with pytest.raises(RuntimeError, match="no runs yet"): + assert engineer.call_args_list[1] == call("capture1", model="m", output_dir="/out", additional_instructions="add pagination") + history.get_history.return_value = history.get_history.return_value[:1] + with pytest.raises(RuntimeError, match="no captured runs yet"): cli.engineer_from_prompt("add pagination") def test_without_a_terminal_a_prompt_is_required(self, monkeypatch): diff --git a/website/content/docs/cli/commands.mdx b/website/content/docs/cli/commands.mdx index 393ce6b..690e232 100644 --- a/website/content/docs/cli/commands.mdx +++ b/website/content/docs/cli/commands.mdx @@ -42,8 +42,8 @@ Click a file or URL in the conversation to open it. The status row under the input shows the run's id, which `/resume`, `/messages` and `reverse-api-engineer show` take. Once the agent has verified -the client, its step lists the run's folder and the synced copy in -`./scripts`, as full paths. +the client, its step lists the run's folder, and with real-time sync on, the +synced copy in `./scripts`, as full paths. When the agent asks a question, its options show under the input: pick one with the arrow keys, press `Space` to pick several when the question allows From 67820a28b9dbfc7368fedeb184e732ba22dd6bd2 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 28 Oct 2026 23:10:18 +0100 Subject: [PATCH 9/9] docs: chrome-mcp is the default agent provider in the config reference --- website/content/docs/configuration/models.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/docs/configuration/models.mdx b/website/content/docs/configuration/models.mdx index 2a36b0c..6fabc7a 100644 --- a/website/content/docs/configuration/models.mdx +++ b/website/content/docs/configuration/models.mdx @@ -50,7 +50,7 @@ The full default config: ```json { - "agent_provider": "auto", + "agent_provider": "chrome-mcp", "agent_browser_notes": "", "agent_browser_npx_package": "agent-browser@0", "claude_code_model": "claude-sonnet-4-6",