br0agent
v1.0.5
Published
Local coding agent with a browser UI: zero-dependency Node.js server, OpenAI-compatible backends, built-in file/shell tools, per-turn snapshots and rollback
Maintainers
Readme
br0agent
A minimal local coding agent with a browser UI. Node.js 18+ (ESM, zero third-party dependencies), a single-page client, and an OpenAI-compatible inference backend.
The server binds 127.0.0.1 only. It is a local desktop tool: nothing on the LAN or the internet
can reach it, and there is no TLS, no enrollment and no API token. See requirement/webagent.md
for the full design.
Security: this tool runs shell commands and reads/writes the folders you add to a workspace. Treat a session exactly like a terminal in that project — only open folders you trust, and only point it at an inference backend you are willing to send that content to.
Install
npm install -g br0agent # global CLI: `br0agent`
npx br0agent # or run it once without installing
npm install br0agent # or as a dependency; then `npx br0agent`br0agent serves the UI at http://127.0.0.1:18117/ and opens it in your default browser once the
port is bound (config.json → openBrowser, default true). Useful flags:
--port NNNN, --config <file>, --models <file>, --no-browser (skip the browser this run).
Ctrl+C stops a foreground instance.
br0agent stop # stop the instance on the port from config.json (default 18117)
br0agent stop --port 18118 # stop the instance on that port
br0agent stop --config D:\cfg.jsonbr0agent stop is the cross-platform equivalent of stop.bat: it finds the process listening on the
port, refuses to touch anything whose command line is not server.js, kills the tree (so bg_start
children go too), waits up to 5 s for the port, and exits 0 when the port ends up free, 1 when
another program still holds it (listing that pid instead of killing it). It never starts a server.
User data never lives in the package directory (an npm update would wipe it):
| What | Default location | Override |
| --- | --- | --- |
| runtime config | ~/.br0agent/config.json (created from the shipped template on first run) | --config, BR0AGENT_CONFIG (legacy WEBAGENT_CONFIG) |
| model profiles / API keys | ~/.br0agent/models.json (created from the built-in template on first run) | --models, BR0AGENT_MODELS (legacy WEBAGENT_MODELS) |
| sessions, snapshots, workspaces | ~/.br0agent/ | config.json |
So after installing, edit ~/.br0agent/models.json (not the copy inside node_modules).
Upgrading from a build that used ~/.webagent (or <project>/.webagent/): rename the directory to
.br0agent once; the app no longer moves it automatically.
Windows, macOS and Linux are supported; start.bat / stop.bat are Windows-only wrappers around
the same stop.js that backs br0agent stop.
Quick start (repo checkout)
node server.js # plain start: http://127.0.0.1:18117/ (uses ~/.br0agent/config.json)
.\start.bat # restart: stops the previous instance, starts a new one, opens the browser
.\start.bat --no-open # same restart without opening a browser tab (forwards --no-browser)
.\stop.bat # stop only: same stop logic, no restart (also: .\stop.bat 18117)Both batch files are thin Windows wrappers around stop.js, which owns the port resolution and the
stop logic — the same code behind br0agent stop, so there is exactly one implementation:
start.batasksstop.js --print-portfor the port (checkoutconfig.json→~/.br0agent/config.json→ 18117), stops the previous instance and then runsnode server.jsin the foreground — the server itself opens the browser, sostart.batnever opens a tab and one start never opens two.start.bat --no-openforwards--no-browser, which skips the browser for that run regardless of the config. In a checkout it passes--config config.jsonwhen that file exists; model profiles always stay in~/.br0agent/models.json, so neither the Settings dialog nor a restart writes into the repo.stop.batforwards its arguments (stop.bat 18117,stop.bat --port 18117 --config D:\cfg.json) and its exit code tostop.js.
stop.js stops only a process on that port whose command line runs server.js (killing its tree,
so background bg_start children go with it), waits up to 5 s for the port to be released, and exits
0 when the port ends up free (also when nothing was running) and 1 when another program still holds
it, listing that pid instead of killing it. start.bat refuses to continue on a non-zero exit.
Restarting loses an in-flight run but not the session history, which is already on disk.
The page also works from file:// (double-click web/index.html); it talks to
http://127.0.0.1:18117 (add ?port=NNNN to the URL if you changed the port).
First run seeds ~/.br0agent/config.json from the shipped config.json and ~/.br0agent/models.json
from the repo-root models.json template when it exists, else from the SHIPPED_MODELS constant in
models.js (local Qwenox profile only); it then creates ~/projects and ~/.br0agent/sessions.
Inference backends (models.json)
models.json always lives in ~/.br0agent/; that is the file Settings → Model Settings reads and
writes, and the only one the server uses by default. A checkout may keep its own copy at the repo
root as a template (gitignored): it is copied once when ~/.br0agent/models.json does not exist
yet and is never read again, so editing it only affects the next first run. Pass --models <file>
(or BR0AGENT_MODELS) to point the server somewhere else entirely. A broken file stops the server
at startup (a silently empty dropdown is worse than a loud error); a broken edit while running
keeps the last good copy and shows a warning banner.
Shipped content: one local profile. No template JSON ships inside the package — the first-run file is
written from SHIPPED_MODELS in models.js when the repo template is absent, so this JSON is the
shape of that constant:
{
"defaultModelId": "qwen3.8-flash-next",
"titleModelId": null,
"models": [
{
"model-id": "qwen3.8-flash-next",
"label": "Qwenox Flash (local)",
"provider": "qwenox",
"baseUrl": "http://127.0.0.1:1234/v1",
"apiKey": "",
"contextLength": 262144,
"vision": true,
"thinking": { "enabled": true, "style": "chat_template" }
}
]
}Add a third-party backend by appending one entry to models (keys are plain text, so never commit
this file):
{ "model-id": "deepseek-chat", "label": "DeepSeek Chat", "provider": "deepseek",
"baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-...",
"contextLength": 128000,
"vision": false, "thinking": { "enabled": false, "style": "none" },
"cache": { "promptCacheKey": true, "cacheSalt": "none" } },
{ "model-id": "glm-4.6", "label": "GLM-4.6", "provider": "zhipu",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4", "apiKey": "...",
"contextLength": 200000,
"vision": false, "thinking": { "enabled": true, "style": "glm" } }Fields: model-id, label, baseUrl are required. model-id is the model name sent to the
backend; a profile is identified by the pair (model-id, baseUrl), so the same model served by two
endpoints (a vendor's CN and US site, say) can appear twice — give each entry its own label. The
derived modelKey (<model-id>#<endpoint hash>, never the raw endpoint) is what the dropdown and
sessions carry; defaultModelId/titleModelId name the vendor id, and with duplicate ids the first
entry in file order wins. Optional: provider, apiKey (""),
contextLength (32768), vision (false), thinking ({enabled, style}), cache
({promptCacheKey, cacheSalt}), headers, extraBody, timeoutMs (600000 — connect + headers,
and any silence inside a streamed body: a longer gap is treated as a dead connection and the
request is replayed). Unknown keys are
rejected — including the retired tools flag: every profile must support native function
calling.
thinking.style maps to the vendor body key: none (nothing), glm (thinking: {type}),
chat_template (chat_template_kwargs.enable_thinking), reasoning_effort
(reasoning_effort: "medium", thinking.level overrides). extraBody is merged last, so a vendor
quirk never needs a code change.
Keys never appear in logs, errors, messages.jsonl, the SSE stream or GET /api/models
(apiKey and baseUrl are never returned).
Configuration (config.json)
host must be exactly "127.0.0.1"; anything else (::1, localhost, 0.0.0.0, a LAN IP)
aborts startup. openBrowser (true) opens the UI in the OS default browser as soon as the port
is bound; set it to false to keep startup silent, or pass --no-browser for a single run.
Other knobs: port (18117), defaultProjectRoot (legacy fallback only),
workspace.file (~/.br0agent/workspaces.json) and workspace.defaultFolder
(~/.br0agent/project, created on demand and seeded into every new workspace),
sessions.dir, userRulesPath, shell, upload, snapshot, agent (maxToolRounds —
0 means unlimited, shipped default; titleTimeoutMs, systemPromptPath),
context (mode = extreme default / normal / cyclic, plus per-mode limits),
local.allowedOrigins (loopback origins are appended at startup).
String values are expanded at startup: ~ means the user profile, and %VAR% / ${VAR} / $VAR
read the environment — so the shipped config is platform-neutral. The default config file is
~/.br0agent/config.json; --config <file> or BR0AGENT_CONFIG overrides it (an explicitly named
file must exist, a missing default is seeded from the bundled template).
snapshot controls the per-turn file snapshots and is always on (no enabled switch): dir
(default ~/.br0agent/snapshots), maxFileBytes (5 MB per file), maxBytes (2 GB store cap),
keepTurns (50) and maxAgeDays (30) — a prompt whose manifest was dropped by GC can no longer be
rewritten. agent.systemPromptPath points at the app-level guidance
file (default systemPrompt.md next to server.js); it is injected into the system prompt of every
mode that can write files (Work) after the built-in text and before the user rules, so the user's
CLAUDE.md stays untouched. Plan and Chat do not get it: it documents write and shell tools those
modes are not offered.
context.mode is read at startup (restart to apply) and reported by GET /api/health together with
staticPrefixTokens { system, tools, tasklist, total, sessionId, estimated }.
API (loopback only)
GET /api/health?sessionId=— compaction mode, model count, user rules mtime, static prefix size.GET /api/models— selectable profiles (noapiKey, nobaseUrl), mtime-driven re-read.GET /api/models/file— the runtimemodels.jsonverbatim, includingapiKey/baseUrl(loopback only; the key-free view staysGET /api/models);POST /api/models/file {text}validates withvalidateModelsbefore writing, then adopts the file immediately, so the next prompt already sees the new profiles. Returns the same shape asGET /api/models.GET /api/config— the runtimeconfig.jsonand its absolute path, verbatim as stored on disk (expanded values would freeze~/%VAR%);POST /api/config {text}validates the JSON and writes it back unchanged.context,agent,shellanduploadare swapped into the live config immediately;host,port,openBrowser,defaultProjectRoot,userRulesPath,maxDetachedMs,sessions,snapshot,workspaceandlocalare reported back and need a restart.POST /api/chat {sessionId?, message, modelKey?, modelId?, mode?}— enqueues a run, returns{sessionId, seq, modelId, modelKey}.modelKeyselects a profile;modelIdis still accepted for old clients (with duplicate ids it resolves to the first profile in file order).GET /api/chat/subscribe?sessionId=&fromSeq=— SSE of the run (token,reason,stream_reset,tool_call,tool_result,usage,session_usage,notice,error,done), replayable fromfromSeq.stream_resetmarks a replayed request: the partial round is dropped and the same round streams again from the first token. A transport failure (dropped socket, silent connection) is replayed up to 3 attempts before it becomeserror.POST /api/cancel {sessionId}.POST /api/compress {sessionId}— manual Compress: one forced compaction pass outside a run (bypasses the usage threshold; the drop persists in the session's compact state). Returns{ok, compacted, kept, groups, tokens};409while a run or another compression is in flight. Open tabs seetranscript_divider+usageon the session stream. The UI button sits right of the notification bell and is enabled exactly while Send is (disabled while busy or while compressing).GET /api/sessions— in-memory sessions (disk persistence arrives in P2).GET /api/sessions/<id>/snapshots— per-turn snapshot summaries plus the prompts whose snapshot failed;GET .../snapshots/<turn>the full manifest;POST .../snapshots/<turn>/restorereverts that turn (one-way).GET /api/sessions/<id>/prompts/<seq>/edit-plan— read-only: which turns a prompt rewrite would roll back, the files involved, and accepted gaps. It refuses (409) when the snapshot chain cannot cover the range, before anything is written.POST /api/chat {sessionId, message, editSeq}runs the rewrite first (files back, transcript cut at that prompt) and then the new prompt.DELETE /api/sessions— clear-all: stops every run, deletes every session dir + snapshot manifest, then removes<folder>/.br0agent/uploadsand<folder>/.br0agent/screenshotsinside every folder those sessions used (workspace definitions and other files are untouched).GET/POST /api/workspaces,PATCH/DELETE /api/workspaces/<id>— named folder groups (%USERPROFILE%\.br0agent\workspaces.json).POST /api/workspaces/<id>/folders {path}/DELETE .../folders?path=/POST /api/workspaces/<id>/primary {path}— folder list and the primary (cwd) folder. A new workspace is seeded withworkspace.defaultFolder; the last folder cannot be removed.POST /api/sessionsacceptsworkspaceId(folder list is copied at creation) or the legacyprojectRoot, or an explicit{folders, primary}.
Every route enforces Host = loopback, an Origin allowlist (null for file://,
127.0.0.1:PORT, localhost:PORT), a CORS OPTIONS preflight, and a per-route body contract
(application/json for JSON routes; application/octet-stream plus X-Filename/X-Session-Id
for /api/attachment, which arrives in P3).
Status
P0-P6 are implemented:
server with loopback binding, Host/Origin guards, CORS preflight, per-route body contract
models.jsonloading/validation with runtime re-read, LLM client with streaming, usage/cache normalization and per-call timing, model dropdown, usage bar and per-turn footertools:
read_file,write_file,directory_operate(op=create/mkdir/delete/move/rename; everything is snapshotted,createnever overwrites, a non-empty directory needsdelete {recursive:true}and is stored whole first),str_replace(exact, count-checked, atomic, BOM/CRLF preserving),list_dir,find_files,grep(both cover every write root; hits inside the project stay relative, hits outside are absolute),run_command(timeout + kill tree + capped tail,cwdin any write root),tasklist(durable, dependency-gated, persisted to<sessionsDir>/<id>/tasks.json),webfetch,screenshot(headless Edge/Chrome into<project>/.br0agent/screenshots, then the PNG is attached as an image so a vision profile can actually look at the page)agent loop with tool dispatch,
extreme/normal/cycliccontext compaction, cancel, disconnect/replaysession modes (composer dropdown, persisted in
meta.json):work(every tool),chat(no tools, no tasklist injection) andplan(read-only tools plus the tasklist).common.jsholdsMODE/MODE_TOOLS, the single allow-list the schema and the executor both read: the request carries only that mode's schemas, the built-in prompt only describes those tools (Chat has no Tools/Tasklist section at all), anddispatch_toolstill refuses anything outside the list. A switch rebuilds the whole prompt — the user-rules block is carried over from the snapshot, and the provider's prefix cache for that session is lost by design.one place for tool names:
common.jsexportsTOOL(every tool name),TOOL_NAMES(schema order) andDIR_OP/DIR_OPS(directory_operateoperations, also used as snapshotoplabels). The server imports it, the browser gets the same file at/common.js, and the diagnose scripts import it too — renaming a tool is a one-line change, andp0-budgetfails if the schema list drifts from it.sessions on disk under
%USERPROFILE%\.br0agent\sessions\<id>\(meta.jsonwith the system-prompt snapshot,messages.jsonl,tasks.json,events.log), list/rename/delete, paged message load, one-click JSON export, and a session sidebar that survives a server restart. The upload button next to the export icon imports such an archive: an id that already exists is merged (local records keep theirseq, only the records the archive adds are appended, so re-importing the same file changes nothing), an unknown id becomes a new session whose prompt snapshot is rebuilt from the current rules file, and a record that is not a session is reported as skipped instead of being writtenworkspaces: the new-session screen is two columns — workspaces on the left (add / rename / delete), the selected workspace's folders on the right (
Add folderthrough the server-side browser, radio = primary, per-row remove). A session copies the folder list when it is created: the primary folder becomes its project root, the rest become write roots, so the sandbox, the snapshot domain andrun_command's defaultcwdall follow the radio button. Editing a workspace later never reaches an existing session. The browser lists dot-directories so%USERPROFILE%\.br0agent\project(the seeded default folder) stays reachable.auto title on the first prompt of a session: a separate tool-free, 5 s-capped call that runs before the agent run; timeout falls back to the prompt prefix
settings dialog: the gear next to Export opens a modal with two tabs — Model Settings (
models.json, the default tab) and Server Settings (config.json) — each with JSON syntax highlighting (a transparent textarea over a coloured<pre>, no third-party library), inline syntax errors,Ctrl+S,Reload, and an unsaved-changes guard. Saves keep the file byte-for-byte (no reformatting). A savedmodels.jsonis adopted immediately and the model dropdown is refilled (current choice kept when it still exists); the server-config response lists which sections were hot-applied and which need a restart. System notices show at the right of the usage row, with a bell that keeps the full historyattachments: paste (screenshot) or drag files into the chat; each upload is a raw-body POST with
X-Filename/X-Session-Id, written to<primary folder>/.br0agent/uploads/(temp file → rename, size cap 20 MB, executable extensions refused), given a per-session id (img1,file1)..br0agent/writes its own*.gitignoreon first use (uploads and screenshots do), so the folder never shows up in the project'sgit status— for this repo it is also listed in.gitignore. Images go to a vision profile asimage_urldata blocks; a non-vision profile gets a path note and a notice. Later turns never re-send image bytes — history keeps the path only. Reference an attachment from a prompt with@img1.Stop is immediate: cancel aborts the in-flight stream, kills a running
run_commandprocess tree (measured ~150 ms on a 60 s command), aborts a pending title call, and skips the rest of the round instead of waiting for the tool to finishbackground processes:
bg_start/bg_status/bg_killkeep long-running commands (dev servers, watchers) per session with an output tail; they are killed when the session is deleted or the server exitsask_user: the run pauses on an SSEaskevent, the UI renders the question with option buttons plus a free-text field, andPOST /api/chat/answerresumes the run. A question never expires: it is persisted inmeta.json, survives an agent restart, blocks new prompts (the Send button stays disabled) until answered, and an answer submitted after the run died resumes the interrupted prompt; only cancel/session deletion rejects itrun termination is explicit:
donecarriesreason(normal/max_rounds/cancelled/error); with a positiveagent.maxToolRoundsthe cap shows a red line in the transcript instead of just stopping, while0(shipped default) runs until the model is done or the run is cancelled. Everyusage/tool_call/tool_resultevent carries itsround, andnode diagnose/inspect-session.mjsprints the whole timeline of a stored sessionfile snapshots + write roots (decided 2026-10-17). The write domain is the session's primary folder plus the other folders of its workspace (
+ rootsand the per-session roots API were removed when workspaces arrived: the folder list is edited in one place and copied at creation). Before everywrite_file/str_replace/directory_operateoperation the previous content goes into a content-addressed store, and in a git root once per turngit statussupplies the candidate list while content hashes decide what really changed — git is the index, never the store, and nothing is written into the user's.git. Each turn writes a manifest; every user prompt then offers a pencil that rewrites it: the files of that prompt and of every later turn go back to their state before it (one-way — whatever changed after the snapshot, the user's own edits included, is overwritten or removed), the transcript is cut at the prompt, and the edited prompt runs on the rolled-back state. The rewrite refuses to start when a manifest of the range is missing (GC, old session, a failed snapshot) instead of rolling back half of it. Limits, all deliberate: in a non-git root only the file tools are covered (run_command/bg_startedits are not), paths outside the declared roots are never covered, and a path reaches its.gitignore-ignored state only through the file tools. Paths are canonicalised throughrealpath, so a junction cannot write outside a root.
Keyboard shortcuts: Ctrl+N new session, Ctrl+L focus the composer, Ctrl+Enter send,
Esc closes the folder picker. A manual regression list lives in diagnose/CHECKLIST.md.
Diagnose tests
node diagnose/p0-models.mjs # validation, re-read, thinking mapping, /api/models contract
node diagnose/p0-cache.mjs # byte-stable prefix, cache knobs, vendor usage shapes
node diagnose/p0-budget.mjs # static-prefix ceilings and duplicate-guidance sniff
node diagnose/p0-chat.mjs # real Qwenox round-trip + guards (skips if :1234 is down)
node diagnose/p1-loop.mjs # tools, tasklist rules, sandbox, timeouts, output caps, cancel
node diagnose/p1-compact.mjs # extreme/normal/cyclic on a 200-group history
node diagnose/p1-detach.mjs # run continues while detached, replay is ordered
node diagnose/p1-real.mjs # real engine writes a file through write_file
node diagnose/p2-sessions.mjs # disk store, isolation, browse, restart, export, merge-import, runStartSeq replay
node diagnose/p2-title.mjs # tool-free title call, once per session, timeout fallback
node diagnose/p2-rules.mjs # rules snapshot, one notice, new-session handover, read-only guard
node diagnose/p3-attach.mjs # upload caps/aborts, exe guard, @imgN, vision blocks, T0 stripping
node diagnose/p4-bg.mjs # bg_start/status/kill through the tool layer and a scripted run
node diagnose/p4-ask.mjs # ask -> answer round-trip, timeout, cancel, unknown id
node diagnose/p7-snapshot.mjs # hooks vs git snapshots, create/delete/move revert, write roots, API
node diagnose/p8-workspace.mjs # workspace CRUD, folder guards, primary folder, clear-all purge
node diagnose/p9-mode.mjs # mode schemas, mode-specific prompt text, tasklist suppression, switching
node diagnose/p6-smoke.mjs # end-to-end over HTTP against the real engine (skips if offline)
node diagnose/p0-remote.mjs # opt-in: third-party profiles from models.jsonnpm test (or pnpm test) runs every offline suite including p7-snapshot and p8-workspace;
the real-engine scripts can be run individually.
Every offline suite drives the shared mock server at ../agserver, not a copy in this repo:
diagnose/harness.mjs starts it with a generated config whose scripts hold the scripted replies
(one step per tool round, plus fault scripts that reset or stall a stream), and reads the recorded
request bodies back from GET /__requests. The profile URL selects both: /<usage-shape>/<script>/v1.
Every script self-terminates (per-case timeout + 180 s watchdog), prints durations and exits non-zero on failure. Test servers use ports 18100+ and are killed by the scripts.
License
GPL-3.0-or-later — see LICENSE for the full text. Copyright (C) 2026 mark
[email protected]. There are no third-party dependencies, so the GPL covers this code only.
The npm tarball is the complete source, so a plain npm install needs no separate source offer;
whoever conveys a modified build must carry the GPLv3 terms and the sources for their changes.
