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.
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:8321You 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.
- Agents that talk to each other — agent 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.
-
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
+ Paneto 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 withWEBADE_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 withWEBADE_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, afile://URI, a URL, a selection from another pane — are typed as-is with no upload, spelled the way this machine spells paths: a droppedC:\…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.
-
Themes —
Settingsoffers 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 inworkspaces.json, Cancel/Esc/× put the old one back. The choice is also mirrored tolocalStorageso a reload paints in the right palette before the server answers. Terminals also carry a 3:1 contrast floor (xterm'sminimumContrastRatio) 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 themesplugin (on by default) derives a theme for each from the WebADE palette —~/.claude/themes/webade.jsonand~/.omp/agent/themes/webade.json— and hands it to every launch as a per-process argument (claude --settings <file>namingcustom:webade;omp --config <overlay>settingtheme.dark/theme.light). Both CLIs watch the file, so a theme change recolors running agent panes within a second, andclaude/ompstarted from a shell keep their own theme. Turn it off underSettings → Pluginsto leave the CLIs on their configured themes. -
Terminal —
Settingspicks 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 onewsl.exe -d <name>away; running under WSL it is the other way round — the Unix shells (/etc/shellsand the usual suspects) are local and the Windows programs launch through interop, as ordinary pty children with the Windows environment intact, so the WindowsPATH, PSReadLine and colors all work either way.Autofollows the system shell (%COMSPEC%on Windows, the detected login shell elsewhere) andCustom…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, andScanre-probes the machine, so a terminal installed since WebADE started shows up without a restart. Drives are enumerated per platform and distros come fromwsl.exe -l -q, so nothing is pinned toC:or to/mnt. A pane keeps its directory; the one exception iscmd.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. -
Plugins —
Settings → Pluginsholds 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 — withlist_agentsandsend_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 onPATH, 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 underSettings → 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 --continuemakes. 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.jsonrecords, 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 withWEBADE_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.
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 backend — llama.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.
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.
0counts 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_ctxadded up. For vLLM it iskv_cache_size_tokensoffcache_config_info, which upstream computes asmax_concurrency × max_model_lenand calls group-aware —num_gpu_blocks × block_sizeis 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 (
4×), 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 | /props → total_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.
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.
| omp | Claude Code | |
|---|---|---|
| tokens in / out / cached | yes | yes, after deduplicating by requestId |
| first-token time | ttft, per request |
not recorded |
| generation time | duration − ttft |
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.
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 toall.
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.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 portRequirements: 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.
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.vbs → webade-service.cmd → node 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 logStop 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.
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:
- Put
HOST=0.0.0.0inwebade.envnext toserver.js(copywebade.env.example). The service reads it at start. - 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). - Restart WebADE:
.\webade-stop.cmd, thenStart-ScheduledTask WebADE. - On the desktop open
Settings → Phone & LAN accessand 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.
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.
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 handHOST=0.0.0.0 ./run.shserver.js— single-file Node server. PTY processes vianode-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:GETanswers from the 30s cache,POSTis the Scan button and re-probes), and an upload endpoint for dropped files (/api/upload: the bytes as the raw body, the name in anx-webade-filenameheader, 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) orFLUSH_BYTESof 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 ofsettings.pluginsand its own routes under/api/plugins/<id>/<route>.plugins/index.jsis the registry;server.jsforwards anything under/api/pluginsto it and never learns what a plugin does. Adding one means writing its module and listing it there — a plugin exportsnormalizeandvalidatefor its config plus whatever routes it needs, and may export two hooks:launchArgs(type, config, settings)to add argv to a session launch andonSettings(config, settings)to follow a settings load or save (this is howplugins/agent-themes.jskeeps the agents' theme files current).public/plugins.js+public/plugins.cssare the client half, self-contained so the Settings block and the top-bar chip stay out ofapp.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, theOrigincheck 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.jscalls 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.jsasks it which pane to show and tells it when it has rendered, and learns nothing about phones. Icons underpublic/icons/.run.sh/run.cmd— start the server in the foreground.webade-service.cmd/webade-service.vbs— what the Windows logon task runs, andwebade-stop.cmdto stop it; see Service.webade.env(fromwebade.env.example) holds that machine's overrides,webade-lan.ps1opens 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.cssderives hovers/inputs/borders/selection from them withcolor-mix(),buildTermTheme()derives the xterm palette, andplugins/agent-themes.jsruns 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.
MIT — see LICENSE.