Mini Vibe: a coding agent in your terminal and your browser

Mini Vibe is a coding agent in about 350 lines of Python, after Mistral Vibe in the terminal and Vibe Code in the browser. Run it in your repository and type a task: the agent reads the code, edits files and runs commands. Edits apply at once, except to git's settings, and git diff shows them; any command outside a short read-only list asks first. The same session is open in a browser tab, which shows the work as it streams and answers approvals too. While the agent works, type a line to steer it, or press Ctrl-C to stop it.

The recording: Mini Vibe fixes more-itertools issue #1336, where replace() yields None for an empty iterable when window_size > 1. The browser tab answers the second command, a line typed mid-run steers the agent to the fix locate() already uses, and a allows every later command.

Demonstrates: a permission policy that runs read-only commands and asks before any other, a host sandbox in your checkout, two views following one session's events, answering an approval from either view, and steering the running turn.

Run, from the repository you want to work on, with MISTRAL_API_KEY set and MINI_VIBE naming the example's folder: ALLOW_DANGEROUS_LOCAL_HOST=1 uv run --project "$MINI_VIBE" python "$MINI_VIBE/mini_vibe.py"

Allow edits, ask before commands

Allow edits, ask before commands

The agent works in your checkout through DangerousLocalSandbox(workspace=os.getcwd()): its shell and file tools run on your machine, with no container. The policy decides which calls wait for you:

# The patterns read the call's arguments as JSON text, where a newline is the two characters \n.
# A command that could run a second command or write a file asks: a separator, a newline, a
# redirection, an expansion ($), a backtick, or git's --output option.
ASK = ("*;*", "*&*", "*|*", "*\\n*", "*>*", "*<(*", "*$*", "*`*", "*--output*")
# Git's settings can name a program that `git diff` or `git log` runs, so an edit to them asks: a
# path with .git in it (.git/, .gitattributes, .gitconfig), in any case, since macOS disks ignore
# case. `path` matches both write_file's `path` and edit's `file_path`.
GIT_SETTINGS = ('*path": "*[.][gG][iI][tT]*',)
READ_ONLY = (
    "ls",
    "ls *",
    "cat *",
    "head *",
    "tail *",
    "wc *",
    "grep *",
    "git status",
    "git status *",
    "git diff",
    "git diff *",  # with the space: `git difftool` runs a program of its choosing
    "git log",
    "git log *",
    "git show",
    "git show *",
)

coder = agents.Agent(
    model="mistral-large-4",
    name="mini-vibe",
    instructions=(
        "You are a coding agent working in the user's repository, your current directory. Read the "
        "code before you change it, make the smallest change that does the task, then run the "
        "project's tests. End with two or three lines: what you changed and what the tests said."
    ),
    environment=agents.DangerousLocalSandbox(workspace=os.getcwd()),  # your checkout, no container
    permission_policy=agents.PermissionPolicy(
        default="ask",
        rules=[
            agents.PermissionRule(tools=["file_system.read_file"], decision="allow"),
            agents.PermissionRule(
                tools=["file_system.write_file", "file_system.edit"],
                decision="allow",  # like Vibe: edits apply, `git diff` shows them
                sensitive=GIT_SETTINGS,
            ),
            agents.PermissionRule(
                tools=["file_system.bash"],
                decision="ask",
                sensitive=ASK,
                allowlist=[f'*"command": "{command}"*' for command in READ_ONLY],
            ),
        ],
    ),
)
  • Reading and editing files runs. You review the result with git diff, as in Vibe.
  • An edit to git's settings asks. .git/config can name a program that a plain git diff runs, and .gitattributes picks the files it runs on. The pattern matches .git in any case: macOS disks ignore case, so .GIT/config is the same file. The pattern also matches .github/, .gitignore, and an edit whose text has .git in it: these ask too.
  • A read-only command runs. grep -n window more.py matches the allowlist.
  • Any other command asks. python -m unittest, find . -delete and git difftool match no allowlist pattern: git diff * has a space after diff.
  • A command that could run a second command or write a file asks, even when it starts like a read-only one. The patterns match the call's arguments as JSON text, where a newline is the two characters \n: cat a.py; rm -rf . and cat a.py followed by a new line and rm -rf . both match a sensitive pattern, and sensitive is checked before the allowlist.

The patterns read the command's text: they catch a careless command, not a hostile one. An edit to git's user settings outside the repository, ~/.config/git/config, runs unasked. Any other edit runs unasked too, so before you allow a command, look at what the turn changed: an edited test file changes what python -m unittest runs. test_mini_vibe.py runs commands and edits like these, one per case, through the policy with a scripted model; from the example's folder, run it with ALLOW_DANGEROUS_LOCAL_HOST=1 uv run pytest -o asyncio_mode=auto test_mini_vibe.py.

The program needs ALLOW_DANGEROUS_LOCAL_HOST=1, and building the sandbox prints a UserWarning: the agent's commands run as you, with your files and your network. Run it in a checkout you can reset.

One session, two views

One session, two views

The terminal and the browser tab each read session.iter_events() on their own. Neither drives the turn: whoever sends a message starts it, and both views see every event. The terminal prints each event as it comes:

async def show(self) -> None:
    async for event in self.session.iter_events():
        match event:
            case agents.TurnStarted():
                self.turn_id = event.turn_id
            case agents.TextDelta(entry_id=entry_id, text=text):
                self.streamed.add(entry_id)
                self.out(text, end="")
            case agents.EntryCompleted(entry=agents.MessageEntry(role="assistant") as message):
                self.out("" if message.id in self.streamed else message.text)
            case agents.EntryCompleted(entry=agents.ToolCallEntry(name=name, arguments=args)):
                self.out(f"{ORANGE}⏺{RESET} {BOLD}{name.split('.')[-1]}{RESET} {web.summary(args)}")
            case agents.EntryCompleted(entry=agents.ToolResultEntry() as result):
                lines = web.result_text(result).strip().splitlines() or [""]
                more = f" (+{len(lines) - 1} lines)" if len(lines) > 1 else ""
                self.out(f"  {DIM}⎿ {lines[0][:100]}{more}{RESET}")
            case agents.CallbackOpened(callback=agents.OpenApproval() as approval):
                await self.ask(approval)
            case agents.CallbackResolved(callback_id=callback_id):
                for approval in [a for a in self.waiting if a.id == callback_id]:
                    self.waiting.remove(approval)
                    self.out(f"  {DIM}answered in the web view: {web.summary(approval.request.input)}{RESET}")
            case agents.TurnEnded(state=state):
                self.turn_id = None
                if not isinstance(state, agents.Completed):
                    self.out(f"{DIM}[{type(state).__name__}]{RESET}")
                self.out(f"{BOLD}>{RESET} ", end="")
        if self.waiting and not self.prompting and not isinstance(event, agents.TextDelta):
            self.prompt()  # ask again, below the new output

The web view sends the same events to the page as server-sent events, each with its id. A tab that reloads sends back the last id it got, and the stream goes on from there, with nothing shown twice:

async def events(request: Request) -> StreamingResponse:
    from_the_page(request)
    # A browser that reconnects sends the id of the last event it got: nothing shows twice.
    after = request.headers.get("last-event-id")

    async def stream():
        async for event in session.iter_events(after=after):
            if (data := as_json(event)) is not None:
                yield f"id: {event.id}\ndata: {json.dumps(data)}\n\n"

    return StreamingResponse(stream(), media_type="text/event-stream")
Answer from either view

Answer from either view

An approval waits until one view answers it, by its id and its turn's id. When both answer, the first answer counts. The second gets AlreadySettled, or Answered with the first view's answer, which differs from its own when the two views disagreed. The other view then gets CallbackResolved and removes its question:

async def answer(self, approval: agents.OpenApproval, decision: agents.AllowApproval | agents.DenyApproval) -> None:
    match await self.session.turn(approval.turn_id).answer(approval.id, decision):
        case agents.Answered(answer=applied) if applied != decision:
            self.out(f"  {DIM}the web view answered first{RESET}")
        case agents.AlreadySettled():
            self.out(f"  {DIM}the web view answered first{RESET}")
async def answer(request: Request) -> JSONResponse:
    from_the_page(request)
    body = await request.json()
    decision = agents.AllowApproval() if body["allow"] else agents.DenyApproval()
    match await session.turn(body["turn"]).answer(body["id"], decision):
        case agents.Answered(answer=applied) if applied != decision:
            return JSONResponse({"result": "the terminal answered first"})
        case agents.AlreadySettled():
            return JSONResponse({"result": "the terminal answered first"})
    return JSONResponse({"result": "done"})

The model can call several tools at once, so several approvals can be open together. The terminal asks about them one at a time. a (always) allows the tool for the rest of the session, here file_system.bash: every later command runs unasked, chained ones included, and so do the other open calls of that tool. a on an edit allows that tool the same way: later edits to git's settings run unasked too.

Each route except the page itself first checks two things. A page from another website, open in the same browser, could otherwise read your code from 127.0.0.1:8765 or post a task that edits your files:

  • The run's token. Each run makes a new one and prints the link with it in the terminal. The page sends it back with each request.
  • The request's Origin. A request sent by another website's page carries that website's origin.
def from_the_page(request: Request) -> None:
    """Refuse a request without this run's token, or sent by another website's page: it could read
    your code or start a turn that edits your files."""
    token = request.headers.get("x-token") or request.query_params.get("token", "")
    if not secrets.compare_digest(token, TOKEN) or request.headers.get("origin", URL) != URL:
        raise HTTPException(403, "only the web view's own page can send this")
Steer or stop the running turn

Steer or stop the running turn

A line typed while the agent works does not wait for the turn's end. send_message() starts a turn when none runs, and otherwise adds the line to the running turn, which reads it at its next step. Ctrl-C interrupts the turn; the session stays open for the next task:

elif line.strip():
    self.prompting = False
    if self.turn_id is not None:
        self.out(f"  {DIM}↳ steering the running turn{RESET}")
    await self.session.send_message(line)  # starts a turn, or steers the running one
    self.prompt()  # an open approval still waits for y/n/a
async def stop(self) -> None:
    if self.turn_id is not None:
        await self.session.turn(self.turn_id).interrupt()

The web view's form and Stop button call the same two methods.

Serve the web view in the session's loop

Serve the web view in the session's loop

A session started in your process is reachable only from the event loop that started it. So the web server runs as a task in that loop, next to the terminal, rather than in a thread:

async def main() -> None:
    session = await coder.start_session()
    terminal = Terminal(session)
    loop = asyncio.get_running_loop()
    loop.add_signal_handler(signal.SIGINT, lambda: asyncio.ensure_future(terminal.stop()))  # Ctrl-C stops the turn
    print(
        f"{BOLD}mini-vibe{RESET} {DIM}· {os.path.basename(os.getcwd())} · {coder.model} · web view: {web.LINK}{RESET}"
    )
    print(f"{BOLD}>{RESET} ", end="", flush=True)
    browser = web.server(session)
    tasks = [asyncio.create_task(terminal.show()), asyncio.create_task(browser.serve())]
    try:
        await read_lines(terminal)
    finally:
        browser.should_exit = browser.force_exit = True  # an open browser tab does not hold the exit
        tasks[0].cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        await session.cancel()

web.server returns a uvicorn.Server whose capture_signals does nothing, so Ctrl-C reaches the terminal's handler and stops the turn, not the web view.

Mini Vibe's code

Mini Vibe's code

The example is a project with its own pyproject.toml:

FileWhat it shows
mini_vibe.pyThe agent and its permission policy, the terminal view, steering and stopping
web.pyThe web view's server: the event stream, messages, answers and Stop
page.htmlThe browser page, which draws the events and posts the answers
test_mini_vibe.pyThe permission policy's test: which commands run and which ask, with a scripted model
Run it

Run it

Install the example once, from its folder:

uv sync

Then, from the repository you want to work on:

export MISTRAL_API_KEY=...
export MINI_VIBE=~/path/to/mini_vibe  # the example's folder
ALLOW_DANGEROUS_LOCAL_HOST=1 uv run --project "$MINI_VIBE" python "$MINI_VIBE/mini_vibe.py"

Open the web view link the terminal prints, http://127.0.0.1:8765/?token=…, to follow the session in a browser. Type y, n or a to answer a question in the terminal, or click Allow or Deny in the tab. Ctrl-D quits.

Output

Output

In the run above, the browser tab showed each call as one line, and the command waiting for an answer:

The web view of the session: the agent's messages, its tool calls, and a card asking to allow a sed command, with Allow and Deny buttons

At the end of the turn, the tab showed the agent's summary:

The web view at the end of the turn: the agent's summary of the root cause, the change, the added test, and 741 tests passing

The change it made in the repository, from git diff:

git diff
diff --git a/more_itertools/more.py b/more_itertools/more.py
index 91d546e..093199f 100755
--- a/more_itertools/more.py
+++ b/more_itertools/more.py
@@ -3494,7 +3494,7 @@ def replace(iterable, pred, substitutes, count=None, window_size=1):
# Add padding such that the number of windows matches the length of the
# iterable
it = chain(iterable, repeat(_marker, window_size - 1))
- windows = windowed(it, window_size)
+ windows = windowed(it, window_size, fillvalue=_marker)
n = 0
for w in windows:
diff --git a/tests/test_more.py b/tests/test_more.py
index a0c4f1f..971ed97 100644
--- a/tests/test_more.py
+++ b/tests/test_more.py
@@ -4294,6 +4294,17 @@ class ReplaceTests(TestCase):
expected = [5, 6, 7]
self.assertEqual(actual, expected)
+ def test_window_size_empty_iterable(self):
+ # An empty iterable should yield nothing, no matter the predicate
+ iterable = []
+ substitutes = [1, 2]
+ for pred in (lambda *args: True, lambda *args: False):
+ with self.subTest(pred=pred):
+ actual = list(
+ mi.replace(iterable, pred, substitutes, window_size=3)
+ )
+ self.assertEqual(actual, [])
+
def test_window_size_zero(self):
iterable = range(10)
pred = lambda *args: True
Adapt it

Adapt it

  • Run the commands in a container instead of on your machine: give the Agent a LocalDockerSandbox.
  • Ask before edits too: change the first rule's decision to "ask".
  • Allow more commands without asking, such as your test runner: add them to READ_ONLY.