Skip to content

Repository files navigation

WebADE — Web Agent Development Environment

License: MIT Node.js ≥ 18

Run Claude Code and omp agents in terminal panes in your browser: split a workspace across up to six panes, let the agents message each other to divide the work, watch it from your phone, and pick every session back up after a crash. Self-hosted, MIT.

Quickstart

git clone https://github.com/jameszampa/webade.git
cd webade && npm install
run.cmd                          :: Windows — http://localhost:8321
./run.sh                         # macOS/Linux/WSL — http://localhost:8321

You need Node.js ≥ 18 and at least one agent CLI (omp, claude) on PATH — or none at all: a plain Terminal pane needs nothing extra. Missing CLIs show as disabled in the UI.

Highlights

  • Agents that talk to each otheragent mail: one agent is on a long goal, a second starts to help; they split the work and report back without going through you.
  • Sessions that outlive everything — switch tabs, close the browser, crash the server: panes come back in the same directory, on their old scrollback, with their conversations resumed.
  • Your phone is a pane strip — the same UI one pane at a time, with a key bar for Esc/Tab/Ctrl and a full-screen home-screen app.
  • Knows your local LLMs — one chip per llama.cpp/vLLM backend: free slots, queue depth, context left — before you open the next pane.
  • Ten themes, agents included — Claude Code and omp recolor to match the pane within a second.
  • Plain terminals too — PowerShell, Git Bash, every WSL distro, one dropdown.

The details

  • Workspaces — tabs across the top. Each workspace is its own grid of terminal panes, split evenly (1 / 2 / 4 / 6 panes). Create (+ Workspace), rename (double-click a tab, or right-click it for a Rename workspace menu), or delete (×) them at any time.

  • Panes — each pane runs one session in one directory. Pick the session type (OMP, Claude Code, Terminal — a shell with no agent attached — or Notepad — a plain note, which has no directory and no process) and, where the type wants one, a directory, then Launch. Use + Pane to add panes (grid stays evenly split), and the pane header buttons to change directory, restart, or kill a session. A notepad's text is saved per pane, under ~/.webade/notes (override with WEBADE_NOTES_DIR), and comes back across a server restart and a relaunch; killing the pane keeps the note, and only deleting the pane deletes it.

  • Drop a file on a terminal — drag an image (or any file) from your file manager onto a running pane and its path is typed into the session, ready for the agent to read. A browser never reveals where a dragged file came from, so the file is uploaded and the path you get is the saved copy, under ~/.webade/uploads (override with WEBADE_UPLOAD_DIR, 64 MB per file). Dropping the same file twice reuses the stored copy instead of piling up duplicates. Drags that already carry text — a path, a file:// URI, a URL, a selection from another pane — are typed as-is with no upload, spelled the way this machine spells paths: a dropped C:\… is used as-is when the server runs on Windows, and translated to its /mnt/<drive> form when it runs under WSL.

  • Directory picker — Browse… opens a file browser rooted at your home and at every drive on the machine (C:\, D:\ … natively; / and the mounted /mnt/c … under WSL), so you can open sessions in any directory.

  • Phone — the same page on a phone shows the workspace one pane at a time, with a strip of chips to switch between them, a key bar for what a soft keyboard lacks (Esc, Tab, Shift-Tab, a held Ctrl, Ctrl-C), a session that scrolls under a finger and keeps going after a flick, and a hold on a tab or pane header for its menu. It opens on the workspace and pane you were last in on the desktop, and can be added to the home screen as a full-screen app. Reaching it from the phone means opening the server to the LAN behind an access token — see Phone and LAN access.

  • ThemesSettings offers ten palettes, each shown as a miniature pane in that theme (eight dark: Midnight, Carbon, Fjord, Ocean, Forest, Plum, Mocha, Ember; two light: Paper, Frost). Every palette is contrast-checked on its own terminal background — body text ≥ 11:1, dim text and every ANSI color used as text ≥ 4.6:1, the "bright black" that CLIs use for comments ≥ 4.5:1 — so nothing an agent prints lands unreadable. Picking one repaints the UI, every live terminal (palette, cursor, selection) and every notepad immediately; Save stores it in workspaces.json, Cancel/Esc/× put the old one back. The choice is also mirrored to localStorage so a reload paints in the right palette before the server answers. Terminals also carry a 3:1 contrast floor (xterm's minimumContrastRatio) for colors a program picks itself.

  • Agent themes — OMP and Claude Code draw most of their UI in truecolor from a theme file of their own, not from the terminal's 16 colors, so on their own they never quite match the pane. The Agent themes plugin (on by default) derives a theme for each from the WebADE palette — ~/.claude/themes/webade.json and ~/.omp/agent/themes/webade.json — and hands it to every launch as a per-process argument (claude --settings <file> naming custom:webade; omp --config <overlay> setting theme.dark/theme.light). Both CLIs watch the file, so a theme change recolors running agent panes within a second, and claude/omp started from a shell keep their own theme. Turn it off under Settings → Plugins to leave the CLIs on their configured themes.

  • TerminalSettings picks what a Terminal pane runs from the terminals detected on this machine: Windows PowerShell, PowerShell 7, Command Prompt, Git Bash, MSYS2, Cygwin, and every installed WSL distro. Running natively on Windows the first group is local and each distro is one wsl.exe -d <name> away; running under WSL it is the other way round — the Unix shells (/etc/shells and the usual suspects) are local and the Windows programs launch through interop, as ordinary pty children with the Windows environment intact, so the Windows PATH, PSReadLine and colors all work either way. Auto follows the system shell (%COMSPEC% on Windows, the detected login shell elsewhere) and Custom… takes any command (pwsh, "C:\Program Files\Git\bin\bash.exe" -l -i, zsh, tmux). The terminals sit in a dropdown grouped by where they come from, and Scan re-probes the machine, so a terminal installed since WebADE started shows up without a restart. Drives are enumerated per platform and distros come from wsl.exe -l -q, so nothing is pinned to C: or to /mnt. A pane keeps its directory; the one exception is cmd.exe, which refuses a UNC working directory, so it starts in %USERPROFILE% when the pane sits somewhere it would see as UNC — which under WSL is every Linux path (\\wsl.localhost\...). The command is resolved when a session starts, so a change applies to the next launch — restart a pane to move it over.

  • PluginsSettings → Plugins holds optional features that are off until you switch them on. The first is Local LLM tracking: give it the URLs of the llama.cpp or vLLM servers you point your agents at, and the top bar gains a chip per backend answering the only question that matters before opening another pane — is there room, or will this queue? Click one for per-endpoint detail. See Local LLM tracking. The second is Session metrics: a on every agent pane and a Workspace metrics item on the workspace tab menu, both opening the same panel — decode speed, first-token time, context fill and where the wall clock went, for one pane, a whole workspace, or one backend. See Session metrics. The third is Agent mail: the agents in different panes can message each other, so one agent on a long goal and a second brought in to help can divide the work and report back without going through you. Each OMP and Claude Code launch is handed an MCP server — served by WebADE itself — with list_agents and send_message; a message is typed into the receiving pane as its next turn. Every agent pane has a name, shown in its header. See Agent mail.

  • Persistence — sessions keep running server-side while you switch workspaces or tabs; coming back re-attaches to the live terminal. Workspace layout (agent + directory per pane) is saved to workspaces.json.

  • Resume after a restart — a pty cannot outlive the server that opened it, so a restart (or a crash, or the WSL VM going down) takes every session with it. WebADE puts them back: panes that were running start again in the same directory, the terminal opens on the scrollback it had, and the agent CLIs are asked to reopen the conversation they were in — claude --resume, omp --resume — so a pane picks the thread up rather than starting from nothing. Sessions come back a fraction of a second apart, so a full workspace does not launch at once; panes waiting their turn say so. Anything that cannot be put back falls through to the Relaunch button it used to show: a directory that has since been deleted, a CLI no longer on PATH, a conversation the CLI will not reopen. A pane you killed yourself stays closed — only work that was interrupted comes back. Turn the whole thing off under Settings → After a restart or a crash.

    When there is nothing recorded to reopen — a pane from before WebADE kept track, or one whose session id never became a conversation because nothing was typed into it — the pane picks up the most recent conversation in its directory instead of coming back empty, which is the same call claude --continue makes. It never takes one another pane is already in.

  • Reopen a conversation — the ⏱ button on a pane header lists every conversation its agent has in that directory, labelled with what was first asked and when it was last touched. Picking one restarts the pane on it. That is the way back when a restart guessed wrong, when you want a thread from yesterday, or when the work predates any of this being recorded — and it is why the guessing above is safe to do at all.

    How it works: workspaces.json records, per pane, whether a session was running and which conversation it held, rewritten on every change so the file is right even when nothing gets a chance to run on the way down. Scrollback is mirrored to ~/.webade/history/<session>.log (override with WEBADE_HISTORY_DIR), capped at the replay buffer and rewritten every few seconds, so a crash costs at most those few seconds of output. Claude Code is launched under a session id WebADE picks (--session-id), which is what makes its conversation findable again; omp names its own, so WebADE identifies the session file by the working directory recorded inside it and the moment it started, which is the moment the pane launched it. A conversation resumed from inside the CLI rather than through WebADE began long before that, so it is recognised instead by the title the pane is showing — omp keeps the same title on the transcript's first line — and only while that transcript is still being written to, since a title is not an identifier and a conversation nobody is in has a still file. Both keep one JSONL file per conversation, which is also where the picker's labels and timestamps come from.

  • Notifications — the server records two things worth telling you about and pushes each to every open tab over a small event channel: an agent session that finished on its own, and an agent that has stopped and is waiting on you. The top-bar bell shows how many are unseen, a panel lists them (type, workspace, what happened, when), and a click jumps to that workspace and flashes the pane. A notice counts as seen once its workspace is on screen with this page actually in front — a pane that ends while you are watching announces itself with its exit overlay, which is closer and says the same thing. A session you kill yourself is not announced at all, and the list lives in server memory: a restart starts it empty. If the page is not in front when one arrives and you have allowed browser notifications (asked once, up front), the OS shows a pop-up that does the same jump.

    The waiting half is what catches a permission prompt or a question asked in a workspace nobody is looking at. It does not read any CLI's screen to decide that: a working agent is never quiet — measured across eight live sessions, 8396 gaps between output while working, the longest 185ms, spinner and status line repainting straight through long tool calls — while sessions sitting at their prompt went quiet for tens of seconds. So silence past a threshold means the agent has stopped and only you can restart it. The screen is read afterwards, and only to quote what is being asked: when a question can be recognised the notice shows it ("Do you want to proceed?"), and when it cannot the notice says only that the agent is waiting, rather than quoting a status line back at you as though it were the question.

    A session is announced only after it has been seen running, and one notice stands per session until you come back to it. Running means continuous output following your own typing into that session — since its last notice, or at all for its first — and nothing else ever counts: not a launch banner; not the repaint of the conversation a restart brought back, however long a slow resume takes to produce it, so panes a restart restores stay silent until you next use them, and one that comes back and exits raises no finished notice either; not something waking an idle agent you have not returned to; not the redraw your own scrolling or a pane resize provokes; not a status line ticking over. Each of these used to buy another notice: a restart re-announced every idle pane it brought back, showing a workspace re-announced every agent waiting in it, and the same session rang the bell over and over as it sat. A wait that ends while its notice is still unread takes the notice with it: the agent going back to work on its own — a queued task, a timer — withdraws it, a session that ends replaces it with the finished one, and a pane or workspace being closed takes it along. Terminal panes are left alone — a shell at its prompt is quiet for ever and is nobody's agent. Turn it off or retune it under Settings → Plugins → Notifications (idle threshold, a grace period after your own typing, and the patterns used to recognise a question); changes take effect without a restart.

Local LLM tracking

Enable it under Settings → Plugins, then add one row per server — the same URL you give a client, with or without the trailing /v1. Test probes it, names the model, and fills in the slot count when the server publishes one. max is the concurrent-request limit (read from llama.cpp, yours to supply for vLLM) and hold is how long a paused conversation keeps its slot — see Between turns. Both server types are detected from what they answer with, so there is no type to pick.

The top bar gets one chip per backendllama.cpp 1 free · 96k ctx, vLLM 2 queued · 36k ctx — each coloured by its own state, because a full llama.cpp tells you nothing about a spare vLLM. Each reads N free · N ctx, slots and context left being the two ways a new session can fail to fit, and swaps the first half for N queued once a queue stops emptying. When nothing is in flight but slots are still spoken for it says so — 0 free · parked 2m — because otherwise "no room" reads identically whether the server is flat out or just waiting on someone who stepped away. Clicking either opens the same panel: per endpoint, what is running, what is parked, what is queued, how much context is left, and — for llama.cpp — a cell per slot.

Between turns

Every figure on that panel is derived from history, not from one instant, and it has to be. An agent between turns has no request open: llama.cpp's requests_processing drops to zero, vLLM's num_requests_running drops to zero, and both servers look idle while a conversation is plainly still in progress. Read instant by instant the indicator flickers several times a minute.

A llama.cpp slot counts as parked when it is not generating but is still holding a conversation WebADE has watched work. /slots shows the difference: a released slot keeps its prompt cache and still reports its last task, so it is drawn dashed with the time since its last turn, and counted as in use.

The catch is that those fields are never cleared. A slot whose owner left hours ago looks identical to one pausing mid-sentence, so "still holds a conversation" cannot mean "still occupied" on its own — only elapsed time separates them. Each endpoint therefore has a hold: how long a pause still counts as someone using the slot, 90 seconds by default, set per endpoint in Settings. Past it the slot is released and reads free · cached — available, and a prefix-cache head start for whoever takes it next. Two consequences worth knowing:

  • A slot already parked when WebADE starts is treated as free. WebADE never watched it work, so it cannot date what is in its cache, and guessing "busy" would pin the endpoint at zero free indefinitely.
  • Raise the hold if your turns are separated by long tool calls; lower it if sessions are short. 0 counts only requests actually in flight.

vLLM has no per-conversation view at all, so the same question is answered from its counters: generation_tokens_total moving between polls proves work happened even when nothing is in flight. The panel shows the settled figure with the live one beside it — 2 active, 1 in flight now — rather than replacing one with the other.

Queues get the same treatment. vLLM's num_requests_waiting blinks 0/1 on roughly half of all samples with a single request running: that is the scheduler admitting a request a step late, not saturation. Only a queue that has not emptied once in eight seconds reads as queueing; a briefer one is labelled transient.

Context is reported in tokens, not percentages, for both server types: a server with a 512K pool holding one 32K prompt reads 480k free of 512k. Two different limits get their own figures, because they fail differently:

  • context pool — everything the server can hold across all requests at once. For llama.cpp that is every slot's n_ctx added up. For vLLM it is kv_cache_size_tokens off cache_config_info, which upstream computes as max_concurrency × max_model_len and calls group-aware — num_gpu_blocks × block_size is wrong for hybrid models, so WebADE only falls back to that for servers too old to publish the field.
  • longest one request — how far a single conversation can grow before it is truncated, whatever the pool has spare. vLLM also reports how many full-length conversations fit at once (), which is the number that decides whether a long session displaces someone else.

Running out of context counts as being full, the same as running out of slots: past about 90% either way an endpoint reads nearly full, because that is where vLLM starts preempting.

What each server can tell you differs, and the UI does not paper over it:

llama.cpp vLLM
concurrent capacity /propstotal_slots not published — set max yourself
running / queued /metrics (needs --metrics) /metrics, on by default
parked between turns /slots (needs --slots), per slot inferred from token counters
context in use /slots (needs --slots), exact per slot KV pool × usage fraction
context pool sum of every slot's n_ctx kv_cache_size_tokens
longest one request n_ctx per slot max_model_len

So llama-server is worth starting with --metrics --slots; without them WebADE falls back to counting busy slots and tells you which flag is missing. vLLM needs no flags, but it publishes no equivalent of max_num_seqs, so headroom as a slot count only appears once you fill in max — context headroom needs nothing. Where a figure cannot be had, WebADE says so rather than inventing one.

Probing happens on the server, not in the page: neither llama.cpp nor vLLM sends CORS headers, so a browser fetch would be blocked, and one poll server-side feeds every open tab. Readings are cached for about a second and only taken while a tab is visible.

Session metrics

Enable it under Settings → Plugins. Every agent pane then gets a to the left of its folder button, and a workspace tab's right-click menu gains Workspace metrics. Both open the same panel, because they are the same question asked at two sizes.

The panel leads with four numbers — output rate, first-token time, context fill, tokens out — then the turn timeline, where the time actually went, and a table one level down: backends under a pane, panes under a workspace, sessions under a backend. Any row that leads somewhere is clickable, so a slow workspace opens the pane that is slow, which opens the backend it was waiting on, and a back arrow walks it in reverse.

A workspace is several conversations at once, so it answers a different question and drops the three blocks that only make sense for one. Aggregate replaces the output rate: every pane's generating time is merged and the tokens divided by the clock they actually occupied, so panes working together add up (1.3× panes at once) while panes that merely ran on the same day do not. Total context is the sum across panes — four panes at 300k is 1.2M of KV held somewhere — with the pane closest to its own ceiling named beside it. Then the panes table, and nothing else: one gauge, one timeline and one time split cannot be drawn for five conversations without picking one and calling it the workspace. Open a pane for those.

Rate samples are taken per transcript, never off the merged pile. A workspace of five panes reported no first-token time at all while the last twenty turns happened to be Claude's, which records none; each conversation now contributes its own most-recent turns, and a tile says so — 20 of 100 turns — when only some of them carry the measurement. Totals are keyed by transcript too, so two panes reopened on one conversation count it once.

Nothing is instrumented and nothing is sent anywhere: it reads the transcripts the agents already write, forward from a byte offset, so a 14 MB conversation is parsed once and never again.

Context windows are never typed in. Local models bring their own — omp records a contextWindow per model in models.yml, so that is read off the machine — and the current hosted Claude models are built in (claude-opus-5, opus-4-8, opus-4-7, opus-4-6, fable-5, mythos-5, sonnet-5, sonnet-4-6 at 1M; haiku-4-5 at 200K), including the [1m] spelling and dated snapshots like claude-haiku-4-5-20251001. Retired and legacy ids are deliberately absent: their windows are not published anywhere WebADE can check, and a guessed number rendered as a confident percentage is worse than none — so they, and anything that is not a Claude model, show tokens and say the window is unknown.

The built-in figures are also checked against reality rather than trusted. A prompt that has already been sent is proof the window is at least that big, so if a model's transcripts exceed the number on record, the percentage is withheld and the panel says the built-in window has gone stale. That is a bug report, not a setting for you to fix.

What the two CLIs can and cannot say

omp Claude Code
tokens in / out / cached yes yes, after deduplicating by requestId
first-token time ttft, per request not recorded
generation time durationttft whole request only
prompt size and its floor contextSnapshot prompt size only
cost usage.cost not recorded

So an omp pane reports a true Decode rate and an amber wait segment per turn, while a claude pane reports Effective — output over the whole request, prefill included — and for first token. The label always says which, because the two are not comparable and printing them under one heading would invite exactly that comparison.

Two more things the panel will not pretend to know. First-token time is queue wait and prefill together and nothing in a transcript separates them, so it is labelled queue + prefill rather than sold as a prefill measurement. And a reply that arrives unstreamed has duration == ttft, which would divide by nothing and print a five-digit rate, so it reports no rate at all and says how many turns that happened to.

Agent mail

Turned on under Settings → Plugins, this lets the agents in different panes talk to each other. The case it is built for: one agent is on a long goal — say, every open issue in a repo — and you start a second to help. They need to split the work, say what they are about to touch, and report back, and doing that through you is a bottleneck. With agent mail they do it directly.

Every OMP and Claude Code pane is handed an MCP server — served by WebADE itself, not a separate process — with two tools:

  • list_agents — who else is reachable, and for each one its name, which CLI it is, its workspace and directory, and whether it is working, idle, at a dialog, or waiting for you. The caller's own entry is marked (you).
  • send_message(to, message) — text to one agent by name, or to all.

Delivery is the terminal. A message is typed into the receiving pane as its next turn, prefixed [agent-mail from <sender>], exactly as if you had pasted it and pressed Enter — so an idle agent starts on it at once, a busy one picks it up when its turn ends, and the whole exchange is there in the pane's scrollback. There is no inbox to poll and nothing to reconnect after a restart. Each agent is told, in its system prompt, that a turn beginning that way is mail from another agent and to answer it with send_message rather than in the terminal.

Two things hold a delivery back, briefly, and the sender's tool result says which: someone is typing in that pane right now — splicing a message into a half-written line would mangle both — or the pane is sitting quiet on a permission or choice dialog, where a pasted line and an Enter would answer for you. A held message goes the moment the pane clears; several for one pane queue and are delivered in order, each as its own turn.

Names. Every agent pane has a name — the address the tools use, shown in its header. Click it (or use the pane's context menu) to set one; a pane launched without a name is given omp-1, claude-2 and so on. The name lives in workspaces.json, so it survives a restart, and a renamed pane keeps its new name when its agent is resumed.

How the CLIs are wired. The MCP server is reached over streamable HTTP at /api/plugins/agent-mail/mcp, and each launch is pointed at it with a per-process argument, so nothing in your own OMP or Claude configuration is touched and a claude/omp started from a shell has none of this. Claude Code gets --mcp-config <file> and an --append-system-prompt; OMP gets a --plugin-dir package (an agent-plugins.org plugin.json + mcp.json) and an --append-system-prompt file. A per-launch token in the URL identifies the calling pane, so the server knows who is asking without the agent having to say. The tools reach an agent from the next launch on — restart a pane to give a running agent the tools, or to hand it a name you just set.

When WebADE is opened to the network, the endpoint sits behind the same access token as everything else, and the local URL handed to the CLIs stays loopback (overridable with WEBADE_AGENT_MAIL_BASE). Turned off, no launch is given the tools and the endpoint answers 404.

Run

run.cmd                          :: Windows — http://localhost:8321
set PORT=9000 && run.cmd         :: custom port
./run.sh            # macOS/Linux/WSL — http://localhost:8321
PORT=9000 ./run.sh  # custom port

Requirements: Node.js ≥ 18 (node-pty prebuilds are used) and the agent CLIs on PATH:

  • omp (oh-my-pi)
  • claude (Claude Code)

Missing binaries are detected and shown as disabled in the UI. On Windows a bare name is resolved through PATHEXT, so claude finds claude.exe.

The Terminal type needs nothing extra — with no shell configured it runs %COMSPEC% on Windows, and elsewhere $SHELL (falling back to the shell in /etc/passwd, then /bin/bash, then /bin/sh) as an ordinary interactive shell, so your ~/.bashrc, prompt, and aliases all apply. Pick another terminal under Settings → What a Terminal pane runs, or type one: the command is split on whitespace (quotes group; off Windows \ also escapes, on Windows it stays a path separator so C:\Program Files\… survives being typed literally) and exec'd directly — there is no sh -c, so pipes and variable expansion are not available there. A bare name is resolved through the server's PATH, which is the service's, not your interactive one. Detected terminals are stored as absolute paths, so they do not depend on that PATH.

Service (autostart at logon)

On Windows, WebADE can run as a per-user scheduled task that starts at logon, so the server is up when you are. The chain is: Task Scheduler → webade-service.vbswebade-service.cmdnode server.js on :8321.

Create the task once, pointing at the VBS in this directory:

schtasks /Create /TN WebADE /SC ONLOGON /TR "wscript.exe \"C:\path\to\webade\webade-service.vbs\""

Each link earns its place. webade-service.cmd pins the service defaults and redirects output to a log, since nothing is watching the console. webade-service.vbs starts that with window style 0 — Task Scheduler runs its action in the interactive session, so pointing it straight at node.exe would leave a console window in the taskbar for as long as the server runs. The .vbs waits for the server instead of firing and forgetting, which keeps the task in the Running state and lets Task Scheduler restart WebADE if it ever exits.

(iwr http://localhost:8321/api/state -TimeoutSec 5).StatusCode   # health check
Get-ScheduledTask WebADE | Select TaskName,State                 # Running = WebADE is up
.\webade-stop.cmd                                                # stop it
Start-ScheduledTask WebADE                                       # start it
Get-Content "$env:LOCALAPPDATA\WebADE\webade.log" -Tail 20       # this run's log

Stop with webade-stop.cmd, not Stop-ScheduledTask alone. Task Scheduler only terminates the process it launched — wscript.exe — and nothing cascades from there, so the server underneath keeps running, holding the port and the open log. The next start then fails with nowhere to report why. webade-stop.cmd ends the task, stops the batch (cmd.exe) and its wscript.exe, and only then stops whatever still listens on the port. The order matters: cmd reads a batch file by byte offset as it runs, so a batch left alive when its node dies resumes reading webade-service.cmd at the offset it remembers — and if the file was edited while the server ran (a git pull, an agent), that offset now points somewhere else and it re-runs whatever is there. One stop did exactly that: the old batch relaunched the old server on the old settings and took the port before the fresh start could. The batch guards against it too — everything after node rides on node's own line and ends in exit /b, so nothing is read from the file once the server is up — but the stop script does not rely on the running batch being a version that knows that.

Logs live in %LOCALAPPDATA%\WebADE\webade.log for the current run, and webade.prev.log, kept so a crash loop is still diagnosable. Launching a session appends an AttachConsole failed stack trace from node-pty/lib/conpty_console_list_agent.js: that helper enumerates a ConPTY's console processes and cannot attach to one from a windowless service. It is a separate short-lived process, and it is noise — sessions start, and killing a pane still reaps its shell. The service binds 127.0.0.1 unless a webade.env next to server.js says otherwise (webade.env.example shows the file; HOST=0.0.0.0 is the line that opens it to a phone — see Phone and LAN access and Security). To remove the autostart entirely: Unregister-ScheduledTask -TaskName WebADE.

Phone and LAN access

WebADE works on a phone, and picks up where the desktop left off: which workspace is in front and which pane you were in are kept on the server (an activate call carries both), so the phone opens on that pane and the desktop puts the cursor back in it. Any number of browsers may watch a session at once; the pty runs at the smallest of them (tmux-style), so while the phone is attached the desktop sees the session at the phone's width, and gets its own back when the phone lets go. A phone lets go by itself when it has been off screen for a few seconds — a pocketed phone must not hold every desktop pane down to 45 columns, or stream output nobody sees — and takes the sessions back when it is looked at again.

On a narrow screen (under 760px) the workspace shows one pane at a time — the one last used — with a strip of chips above it to switch panes and add one; the pane header's buttons fold into a menu, which opens as a sheet from the bottom; modals fill the screen. Holding a tab or a pane header opens its menu, the way right-clicking does on the desktop, and a workspace is deleted from that menu (the × is folded away). On a touch screen a key bar sits above the keyboard with the keys a soft keyboard has not got: Esc, Tab, ⇧Tab, Ctrl (held for the next key you type — Ctrl then c sends Ctrl-C), ^C, and a key to put the keyboard away. There are no arrow keys: the session scrolls under a finger, 1:1, and a flick carries on after the lift the way a native list does. The page can be added to the home screen (Chrome: menu → Add to Home screen; Safari: share → Add to Home Screen) and then opens full-screen, signed in.

Reaching it from the phone means the server has to listen on the LAN, which hands a shell to whoever connects, so opening it up comes with a gate: an access token, created in ~/.webade/access-token on first start (or set with WEBADE_TOKEN). Connections from this machine need nothing — loopback is exempt, so the desktop, the health check and everything else keep working as before, and with the service bound to 127.0.0.1 the gate never fires at all. Every other connection, HTTP or WebSocket, needs the token: once, as ?token=… in the address, which sets a year-long cookie and redirects to the app; a browser without it gets a sign-in page to type it into. A WebSocket opened by a page this server did not serve (a wrong Origin) is refused outright. To open it up, on Windows:

  1. Put HOST=0.0.0.0 in webade.env next to server.js (copy webade.env.example). The service reads it at start.
  2. Let the port through Windows Firewall, once, as Administrator: powershell -ExecutionPolicy Bypass -File .\webade-lan.ps1. The rule is limited to the local subnet, on the Private and Public profiles alike (Windows tends to file a home Ethernet connection under Public).
  3. Restart WebADE: .\webade-stop.cmd, then Start-ScheduledTask WebADE.
  4. On the desktop open Settings → Phone & LAN access and scan the QR code with the phone, on the same Wi-Fi. The code carries the token; the token itself is shown beside it for typing into the sign-in page instead.

The startup banner in webade.log lists the same addresses. A VPN address (Tailscale) is listed too, since 0.0.0.0 answers on it, which is the way in from outside the LAN. To rotate the token, delete the file and restart; every phone then has to scan again. Elsewhere it is HOST=0.0.0.0 ./run.sh and whatever the firewall there wants.

One more thing a second viewer used to do: type into the session. A pty's output carries questions for the terminal — ConPTY asks for Device Attributes (ESC [ c) on every start — and every terminal that saw the question answered it, including every browser that was later handed the scrollback, each answer after the first landing at the shell as typed input (^[[?1;2c on the prompt) or at an agent as a stray Escape. The server now answers those itself (as xterm.js would, so the asker cannot tell), and the viewers see neither question nor answer; only the cursor position report, which no one but a renderer can answer, still goes to the viewers, and stays out of the replay.

Windows and WSL

The server runs on either side of the WSL boundary and adapts: it enumerates drive letters or reads /proc/mounts, resolves executables through PATHEXT or the execute bit, and spells paths with the local separator (the front end asks /api/info for it rather than assuming). Sessions are ConPTY children on Windows and ordinary ptys elsewhere.

Security

Whoever reaches the port gets full shell access to the agent CLIs, so what reaches it is the whole question. server.js defaults to 0.0.0.0, but webade-service.cmd pins HOST=127.0.0.1 unless webade.env says otherwise, which keeps the port off the LAN. Connections that are not from this machine are gated by the access token (~/.webade/access-token, or WEBADE_TOKEN): a cookie set once from ?token=…, checked on every request and WebSocket upgrade, with a wrong Origin refused as well — see Phone and LAN access. Loopback is exempt, so nothing on this machine needs the token, which also means a page on some other site loaded in a browser on this machine can reach the port; the Origin check is what keeps such a page from attaching a socket to a session. The token is not a substitute for a trusted network: it goes over plain HTTP, so use it on your own LAN or over a VPN, not across the open internet. Open it up deliberately:

rem webade.env next to server.js: HOST=0.0.0.0   (the service)
set HOST=0.0.0.0 && run.cmd                        :: by hand
HOST=0.0.0.0 ./run.sh

How it works

  • server.js — single-file Node server. PTY processes via node-pty, a WebSocket bridge per session (/ws/term/:sid), REST API for workspaces/panes (/api/...), a directory listing API (/api/fs?path=...), terminal detection (/api/terminals: GET answers from the 30s cache, POST is the Scan button and re-probes), and an upload endpoint for dropped files (/api/upload: the bytes as the raw body, the name in an x-webade-filename header, the stored path in the reply).
  • Output is coalesced before it is sent: a pty emits very small pieces, and the cost is per-message rather than per-byte, so a session gathers up to FLUSH_MS (8ms) or FLUSH_BYTES of output into one WebSocket message. On a busy session that is roughly an order of magnitude fewer messages, on both ends. Anything that needs viewers caught up — a new viewer about to be handed the history, an exit notice — flushes first, so nothing arrives twice or late.
  • Panes of inactive workspaces stay attached and live rather than being torn down and replayed, so cost scales with the total number of panes, not the visible ones. Reckon on roughly 10 MB of server memory per live session (plus a shell and its console host as OS processes); 48 sessions measured at ~500 MB and under 10% of one core with agent-like output.
  • plugins/ — optional features, each owning one slice of settings.plugins and its own routes under /api/plugins/<id>/<route>. plugins/index.js is the registry; server.js forwards anything under /api/plugins to it and never learns what a plugin does. Adding one means writing its module and listing it there — a plugin exports normalize and validate for its config plus whatever routes it needs, and may export two hooks: launchArgs(type, config, settings) to add argv to a session launch and onSettings(config, settings) to follow a settings load or save (this is how plugins/agent-themes.js keeps the agents' theme files current). public/plugins.js + public/plugins.css are the client half, self-contained so the Settings block and the top-bar chip stay out of app.js.
  • public/ — xterm.js (vendored) + a small vanilla-JS front end.
  • access.js — who may reach the server: the access token, the cookie, the sign-in page, the Origin check on sockets, /api/access (what the Settings section shows) and the web-app manifest, whose start URL carries the token so the home-screen app opens signed in. server.js calls it at the top of the request and upgrade handlers and never looks inside.
  • public/mobile.js / public/mobile.css — the phone: one pane at a time and the pane strip, the key bar, hold-for-menu, the keyboard-aware viewport, letting go of sessions while off screen, and the QR code in Settings. app.js asks it which pane to show and tells it when it has rendered, and learns nothing about phones. Icons under public/icons/.
  • run.sh / run.cmd — start the server in the foreground.
  • webade-service.cmd / webade-service.vbs — what the Windows logon task runs, and webade-stop.cmd to stop it; see Service. webade.env (from webade.env.example) holds that machine's overrides, webade-lan.ps1 opens the firewall for the LAN.
  • public/themes.js — the theme catalogue and the only place colors are defined: each theme lists 12 base UI colors plus the 16 ANSI slots, style.css derives hovers/inputs/borders/selection from them with color-mix(), buildTermTheme() derives the xterm palette, and plugins/agent-themes.js runs the same file as a CommonJS module to derive the Claude Code and OMP themes. Adding a theme means adding one entry there — the server only stores the chosen id as an opaque slug.

Any number of viewers may watch a session at once — two browser tabs, the desktop and a phone. Each reports the size it can show and the pty runs at the smallest of them, so every viewer sees the same unclipped screen; a viewer that detaches gives the size back. Terminal queries in the output (Device Attributes, status reports) are answered by the server rather than by each viewer, so a second viewer, or a replay, never types an answer into the session — see Phone and LAN access.

License

MIT — see LICENSE.

About

Browser workspaces for Claude Code and omp agents — split panes, agent-to-agent messaging, phone access, sessions that survive a crash. Self-hosted, MIT.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages