Web UI development
The web UI has two supported local workflows. Use replay mode when working on React presentation and use live mode when checking the browser transport, server gateway, and shared state projection.
One-command local browser demo
From the repository root, run:
./scripts/run-web-ui.sh
This is the browser-only source-checkout launcher. It installs the locked
JavaScript workspace, builds clients/apps/web, starts a detached live gateway on
127.0.0.1:8765, replays a recorded run through the real WebSocket protocol,
and asks the host to open the capability URL in its default browser. If the
browser cannot be opened automatically, open the VibeSys web UI ready URL
printed in the terminal. The capability is a browser bootstrap: after the
first response, the page removes it from the address bar and all WebSocket
URLs. Same-browser reload and history navigation use an HttpOnly session
cookie.
The script directly invokes entrypoints.web; it does not invoke the
vibesys launcher or start OpenTUI. The vibesys executable remains the
interactive TUI launcher by default.
Replay mode
From a VibeSys source checkout:
uv run python -m entrypoints.web dev
Open http://127.0.0.1:5173. This mode serves the deterministic event fixture
through Vite and shows a Live gateway URL field. It does not start a Python
server or a WebSocket connection until a capability URL is submitted.
Live demo mode
The demo serves the repository's deterministic recorded run through the real HTTP and WebSocket gateway:
uv run python -m entrypoints.web live --demo --open
The command builds clients/apps/web, starts a detached loopback gateway on port
8765, and prints a capability-bearing URL. Open that URL in a browser. The
gateway remains available until explicitly stopped, so the browser can inspect
the completed state without an agent CLI or credentials. A second invocation
from the same checkout reuses that gateway instead of competing for port 8765.
The production build writes clients/apps/web/dist/.vibesys-web-build.json. That
manifest identifies the build and records every tracked input that can change
the production browser bundle, including the three client source trees, HTML
entrypoint, Vite configuration, package metadata, and locked dependency graph.
The gateway refuses the checkout's default bundle when any recorded input has
changed or the manifest is missing, and reports the changed path plus
pnpm --dir clients/apps/web build. --no-build still reuses the last build, but
only while that build matches the current workspace. The build ID is stored in
the instance record and printed by live and status, so an operator can tell
which browser artifact a gateway is serving.
The lower-level server option --web-assets PATH is an explicit opt-out for an
operator-managed static bundle. If PATH contains a VibeSys build manifest it
receives the same freshness check; otherwise the gateway identifies its current
contents with a hash but cannot compare them with workspace sources.
Use status, rotate, and stop with the instance path printed by the command
when the gateway needs to be inspected, its launch URL must be invalidated, or
it must be stopped. Rotation leaves attached pages and open sockets running and
prints the replacement launch URL. For --demo, the ignored runtime
directory is stable within the source checkout:
uv run python -m entrypoints.web status --instance clients/apps/web/.vibesys-demo/web-gateway.json
uv run python -m entrypoints.web rotate --instance clients/apps/web/.vibesys-demo/web-gateway.json
uv run python -m entrypoints.web stop --instance clients/apps/web/.vibesys-demo/web-gateway.json
If the record is missing or belongs to another checkout, use the fixed port instead. A bind failure prints these commands on its first diagnostic:
uv run python -m entrypoints.web status --port 8765
uv run python -m entrypoints.web stop --port 8765
Port-keyed discovery is Linux-only and fail-closed. It reads listening sockets
from /proc/net/tcp and /proc/net/tcp6, maps them through same-user descriptors,
and accepts it as a VibeSys gateway only when the process command names
entrypoints.server, the requested web port and an explicit instance path,
and that process also holds the matching instance claim. stop --port then
uses a pidfd and rechecks that complete identity before sending SIGTERM. It
works with the default derived instance path because the detached launcher
normalizes that path into the child process command. The port-keyed command
refuses unrelated listeners, multiple owners, other-user or unreadable
listeners, identity changes, and hosts without /proc or pidfd signalling.
It never sends a signal based only on a port or pid.
stop's exit status is the only signal that says whether the instance
directory's files are free, so anything that removes or reuses that directory
must be conditional on it:
- Exit 0: no process this host can observe is using the directory. Its
remaining files (
web-gateway.json.lockand, for a detached gateway,web-gateway.json.log) are retained by design, but nothing holds them open, so removing them is safe. - Exit 1: something is still using it, or that could not be established.
Removing the directory now is the bug this contract exists to prevent: on
NFS, unlinking a file another process holds open leaves a
.nfsXXXXentry behind and thermdirthen fails withENOTEMPTY. Leave the directory alone. The message names the pids it measured and gives thekill -9that ends them.
Two observations decide that: the pids with a file open under the directory
(read from /proc), and whether anything holds the startup log's flock. The
instance record decides nothing, because the gateway unlinks it as the first
step of teardown and a gateway started without --detach never writes a
startup log at all. Neither does status, which reads only the record and so
reports "no gateway is running" for exactly the directories stop refuses to
declare free. Where a host exposes neither witness (no /proc, or flock
unavailable or failing), stop reports exit 1 rather than treating missing
evidence as an idle directory.
stop waits up to 12 seconds, fixed. That is the transport's canonical
10-second WebSocket close bound plus 2 seconds for process scheduling and for
the released files to become observable. The wait must strictly outlast the
close bound because the gateway closes its transport before its session on
SIGTERM. This is a teardown budget, not a run budget: a run's own work is
already finished or abandoned by the time anything signals it, so nothing
about the run mode makes teardown longer. It sends only SIGTERM, because that
is what runs the ordered teardown; SIGKILL leaves the record behind for the
next launch to trip over, which is why escalation stays with the operator.
Do not remove <project>/.vibesys itself. Only the demo's ignored
clients/apps/web/.vibesys-demo is disposable; a real project's .vibesys holds
run state that Project owns.
Pass a real project and task for an operator-owned run. Additional VibeSys run
arguments follow --:
uv run python -m entrypoints.web live --project /path/to/project --task TASK --open -- --outer-loop agent --local
Run page layout
The run page is one app package over three feature packages (clients-architecture.md owns the package graph):
clients/apps/web(@vibesys/app-web) is composition only.src/app/holds the browser and desktop entries (main.tsx,desktop-main.tsx), the pure boot decision (boot.ts), andcompose.tsx, which builds the feature stores, injects platform services into them, and wires cross-feature flows (replayed events fold into the run view).src/platform/holds the browser's services:backend.tsis the only module that imports@vibesys/transportruntime values or usesfetch(tools/lint-rules/web-fetch-only-in-backend.grit), plus the browser lifecycle, tab storage, and the desktop shell's bridge transport.platform/never importsapp/.clients/features/<name>(@vibesys/feature-run-view,@vibesys/feature-gateway-connect,@vibesys/feature-replay) is one feature. Itssrc/state/(the./stateentry,neutral) is a purestep(state, action) -> {state, requests}reducer with request tokens that drop stale replies andinvariants, plus the interfaces its effects need (run-view'sRunConnectionandBrowserLifecycle). Itssrc/renderer/(the./rendererentry,dom) holdsui/(presentational React, props in and events out) andeffects/(performs the requests through the injected interfaces; never importsui/). The gateway form's core parses with the WHATWGURL, a DOM global, so it sits inrenderer/state/and that feature publishes./rendereronly. A feature never imports another feature or a platform module; the package-graph checker and.dependency-cruiser.mjsenforce both.
The store kernel (Step, Reducer, createStore) comes from
@vibesys/store, shared with the desktop welcome view. The reducers are
tested model-based with checkModel from @vibesys/store/testing: seeded
random action and reply sequences, checking after every step that each awaited
token was issued and no two slots await one, that no stale reply is applied,
and each reducer's own invariants and properties.
Gateway HTTP hygiene
Every gateway HTTP response, including the 403 and 404 bodies, is built by
WebSocketGateway._response in src/server/transport/websocket.py, which
merges the headers into one dict literal, so no route can set or duplicate one:
| Header | Value | Reason |
|---|---|---|
Cache-Control | no-store | The bootstrap response carries a capability and run output is live. |
Referrer-Policy | no-referrer | A navigation before the frontend scrubs the bootstrap URL must not copy its token. |
X-Content-Type-Options | nosniff | /assets/* is token-free and the content type falls back to application/octet-stream, so no response may be re-typed by sniffing. |
Content-Security-Policy | derived | Transcripts render model- and tool-produced text. |
_POLICY_DIRECTIVES in that module is the authoritative directive map, and
_content_security_policy is its only serializer. Do not add another copy of
the header value. Two exist on purpose, as independent cross-checks that fail
when they disagree with the module: _expected_policy in
tests/server/test_websocket_transport.py and the expectedPolicy function in
clients/apps/web/e2e/gateway-hygiene.spec.ts. Neither imports the gateway, so
neither agrees by construction. The directive values named below are
explanation and not a third copy, since nothing reads them. The shape is
deny-everything (default-src 'none') plus these grants:
script-src 'self'andstyle-src 'self', no'unsafe-inline'. The Vite production build emits one external script and one external stylesheet. What the policy actually forbids is a<style>element andsetAttribute('style', ...); it does not govern CSSOM writes, so React'sstyle={{...}}prop (which compiles toel.style.setProperty) is unaffected. The real constraints on a contributor are therefore: no CSS-in-JS runtime (emotion and styled-components inject<style>) and no React 19<style href precedence>hoisting.img-src 'self'. The app renders no images, but Chromium enforcesimg-srcon the favicon it probes for on its own, and a denied probe is a reportedsecuritypolicyviolationthat fails the browser audit. The grant allows nothing off-origin, and it does not make/favicon.icoreachable: the gateway has no route for that path and answers 404 with or without a capability token. The grant is still load-bearing, because what needs permitting is the browser issuing the same-origin probe and not what the probe is answered with. Only declaring an icon inclients/apps/web/index.htmlwould suppress the probe and let this tighten to'none'.font-src 'none', stated rather than left to thedefault-srcfallback, because "no external font" is the air-gap property being asserted.connect-src 'self'plus this gateway's own WebSocket authority.'self'is separately load-bearing and must not be removed: Vite's modulepreload polyfillfetcheslink[rel=modulepreload]hrefs, and afetchof a script URL is governed byconnect-src, notscript-src.
connect-src names exactly one socket source, ws://127.0.0.1:<this gateway's bound port>. That is the authority of every page this gateway hands out
(WebSocketGateway.url is always http://127.0.0.1:<bound port>/?token=...),
so the grant is exact and textual.
Client code must not use innerHTML, outerHTML, insertAdjacentHTML, or
document.write. These raw HTML sinks bypass React's normal escaping and could
turn model- or tool-produced content into markup that violates this CSP. The
client-wide tools/lint-rules/no-raw-html-sinks.grit rule enforces that restriction
through pnpm --dir clients check:ts; the policy remains authoritative in
_POLICY_DIRECTIVES, rather than being duplicated in the lint rule. The static
gate covers dot and literal-computed properties, direct and optional calls, and
plain, concatenating, or logical assignments in every TypeScript source
extension used by the clients, including desktop preload .cts files. Dynamic
property names and aliased method references are outside this syntactic rule.
Declared browser origins do not appear in connect-src, and must not be
added to it. They answer a different question. --web-origin is the inbound
Origin allowlist: it says who may connect to the gateway. connect-src
says where a page the gateway served may connect to. Neither documented flow
makes a declared origin an answer to the second one:
- In the development flow the page is served by Vite, so it carries Vite's policy and the gateway's header never governs it.
- In a proxied deployment the page is at the proxy's authority and its socket uses the proxy's own port, never the gateway's.
Three shapes this replaced were each wrong in a different direction, which is
why the current rule is narrow. A wildcard port (ws://127.0.0.1:*) authorized
a socket to any other loopback listener, including another user's on a shared
host. Taking each declared origin's full authority kept that grant for every
declared port, including the --browser-origin http://127.0.0.1:5173 this
document uses as the normal case. Pairing each declared hostname with the
gateway's bound port was wrong in both directions at once: it named authorities
no page can ever be served from (ws://proxy.example:<gateway port> when the
page needs the proxy's port), and because a CSP host-part admits a bare *
(host-char = ALPHA / DIGIT / "-", so * is valid and [::1] is not),
--web-origin http://*:5173 put ws://*:<port> in the header and authorized
the capability token to any host at that port, while --web-origin 'http://!;evil.example:5173' split the directive with its ; and dropped the
gateway's own socket source.
The value is now built from module constants and the bound socket only, so no
configured string reaches a security header at all. Declared origins are
instead parsed and canonicalized when they enter, by browser_origin in
src/server/transport/websocket.py, which is the one definition of "an origin
a browser can send" and is what both --web-origin and the gateway
constructor use. It rejects anything a browser could not send, naming the
offending value, and lowercases the scheme and host, compresses an IPv6
literal, and drops a default port, because the handshake compares Origin by
exact string and http://LOCALHOST:5173, http://proxy.example:80, and
http://[0:0:0:0:0:0:0:1]:5173 would otherwise be allowlist entries that never
match. Note this predicate is deliberately not the CSP host-part grammar:
http://[::1]:5173 is a legitimate Origin and is accepted, even though
[::1] is not a valid CSP host-part.
The residual, stated rather than claimed away: a page reached by some other
spelling of the same socket (http://localhost:<port>), or served through a
proxy that forwards these headers, is not covered by the explicit source and
falls back on 'self'. CSP3 states 'self' covers the wss: variant of the
page's origin but is less clear about plain ws:. Deriving the source from the
document request's own authority would close that exactly, and is the intended
follow-up; no declared origin can close it, which is the point above. The bound
port being unknown before start() is not an obstacle: _authority falls back
to the requested port, and the policy is computed per response.
The page constructs a capability-free socket URL. A direct gateway response
exchanges a valid launch token for a port-specific HttpOnly, SameSite=Strict
cookie. The Vite harness performs the same exchange with a credentialed CORS
request, then retains only the gateway authority in ?gateway=. It keeps the
minted browser-session credential in tab-scoped sessionStorage and sends that
credential, never the launch token, on its gateway socket URL. This fallback is
required when the Vite page and gateway use different loopback host spellings,
where a strict cookie is correctly withheld. The backend client receives an
ordinary endpoint and remains unaware of credential policy.
An explicit ?token= always takes precedence over a session cookie. Therefore
a copied old launch URL is rejected after vibesys web rotate even in a
browser that already has a valid session. Rotation atomically replaces the
token and instance record while preserving the session credential and existing
connections. A fresh browser cannot open the clean URL because it has neither.
clients/apps/web/e2e/gateway-hygiene.spec.ts asserts the whole policy against a
live gateway, fails on any reported securitypolicyviolation (with a negative
control that proves the listener fires), and blocks plus fails on any request or
WebSocket the page attempts off loopback.
/assets/* is served without a capability token
_process_request requires a launch capability on lifecycle routes, and a
launch capability or browser session on / and /ws. /assets/* remains
credential-free deliberately. The assets are the public frontend bundle and
hold no run data. Run data moves only over
/ws, where browser connections use their session credential and every
connection requires an exact Origin match. Nothing else is exempt, /health
included: the only thing that probes /health is record discovery in
src/server/transport/discovery.py, which has already read the instance record
and therefore already holds the token, so the reason for the exemption does
not apply to it.
The token gate applies only to paths the gateway has a route for, because
"could this ever be served?" is a question about the route table and "is the
token valid?" is a question about the caller. _ROUTED_PATHS names the routed
set outside /assets/* (/ws, /health, /, /index.html, and the private
capability-rotation route), and a target outside it is answered 404 before the
token is looked at. Deciding in the other
order spent 403, the one signal an operator debugging the token reads first, on
the /favicon.ico probe every browser makes unprompted, on every page load,
where no token could have changed the answer. The cost is that the route table
is observable without a token, since a routed path answers 403 where an
unrouted one answers 404. That discloses only the constants above, alongside
/assets/*, which is already served token-free, and in exchange an unrouted
target no longer reports token validity for a resource that does not exist.
The exemption is a statement about the URL space, not about the filesystem, so
it only holds if the path that satisfies the /assets/ test is the same path
that is looked up. _routing_path percent-decodes and normalizes the request
target once, and _process_request derives both the token decision and the
asset lookup from that single value. A path such as /assets/../index.html
therefore normalizes to /index.html before either decision is made, so it
requires the token like any other page request. Deciding the exemption on the
raw target and then looking the file up from a separately decoded copy would
widen the exemption from "URLs under /assets/" to "anything reachable under
the assets directory", which is not what this decision grants.
The filesystem lookup is rooted at the same subtree for the same reason.
/assets/* is resolved under <dist>/assets and the containment guard is
checked against that directory rather than against the dist root above it, so a
symlink planted inside assets/ that points elsewhere under the dist root is a
404 and not a token-free file. Rooting the guard at the dist root would again
make the exemption a filesystem statement.
Normalizing once also makes trailing slashes and . segments equivalent to
their normalized target, which is a deliberate widening rather than a side
effect: /health/, /index.html/, /./, /.., and /ws/ route as
/health, /index.html, /, /, and /ws, where each of those spellings
was a 404 before. It applies inside /assets/ too: /assets/index.js/ and
/assets/index.js/. are the same token-free target as /assets/index.js,
while /assets/index.js/.. normalizes to /assets, which leaves the exemption
and names no route, so it is a 404. Accepted because one normalization is what
makes the /assets/ test mean what it says, and because every route keeps its
own check under every spelling: /ws/ still requires both the capability token
and an exact Origin match, and a target above the root resolves to the
token-required index rather than outside the tree. The whole table is pinned in
tests/server/test_websocket_transport.py.
The target is split on its first ? rather than parsed as a URL, because an
origin-form request target is absolute-path [ "?" query ] (RFC 9110 7.1) and
not a URL. Two consequences, both pinned: # is an ordinary query byte and so
is part of the token rather than a fragment delimiter, and a target no URL
grammar accepts is still answered. urlsplit raised ValueError on a
malformed IPv6 authority (//[::/, or any absolute-form target with one),
which left the handshake through the websockets library's own error path as
an unauthenticated 500 carrying none of these headers. The capability token is
compared as bytes for the same reason, encoded with surrogateescape: the
library decodes the target with ascii/surrogateescape, so a raw byte above
0x7F arrives as a lone surrogate that strict UTF-8 refuses, just as
compare_digest refuses a non-ASCII str.
Desktop app
The connection design (one host helper, one connection manager, explicit run identity and lifecycle phases, per-screen stores) is in desktop-connection.md; this section says how the code is laid out and how to run it.
The desktop app (clients/apps/desktop-main) ships its own copy of the web UI and
reaches servers through its main process, without the web gateway. A server is
a detached run (vibesys --detach, see tui-architecture.md) on this machine
or on an SSH host. The main process reaches every host the same way: it runs the
host helper there (python -m entrypoints.host_helper) and speaks its typed
protocol; This Mac and an SSH host differ only in how the helper is started, and
the page cannot tell them apart.
# The welcome view: recent runs, This Mac, and Connect to host
pnpm --dir clients --filter @vibesys/app-desktop-main start
# The welcome view on one SSH host, or straight into one of its detached runs
pnpm --dir clients --filter @vibesys/app-desktop-main start -- --host HOST [--instance ID]
# Start a detached run for a local project and open the window on it
pnpm --dir clients --filter @vibesys/app-desktop-main start -- --project ~/proj [-- RUN_ARGS...]
start builds the app, including the two page bundles the window loads from the
app://vibesys origin: the run view (@vibesys/app-web's build:desktop, from
clients/apps/web/desktop.html) and the welcome view (@vibesys/app-desktop-renderer's
build, from clients/apps/desktop-renderer/welcome.html). The page has no network access: the main process
cancels every http(s) and ws(s) request and the page's Content Security Policy
forbids connections. Its only way to the server is the preload bridge,
window.vibesysDesktop.connect(), which takes no host or path: the main process
binds the run view to one run.
Layout
The Electron main process is composed from packages, as the renderer is; the package-graph
checker holds the boundaries between them, and clients/.dependency-cruiser.mjs the modules
inside them:
clients/apps/desktop-main(@vibesys/app-desktop-main) is composition only:src/main/is the composition root, the window, the lifecycle, page confinement, launch arguments, and the welcome IPC boundary, which routes each request@vibesys/ipcparsed to the feature that owns it.src/preload/holds the two sandboxed preloads, each a single file, which the main process loads by path and never imports.- The features'
./mainentries are their main-process controllers:@vibesys/feature-hosts(Hosts: checkouts, tasks, path completion, pushed listings) and@vibesys/feature-run-control(RunControl: attach, start, resume, stop, retry, and the run page's relay). @vibesys/connectionis the connection manager, a purestate/machine and ashell/; only its shell starts processes or opens sockets.@vibesys/settingsishosts.jsonandrecent.jsonwithSettingsStoreas their one writer.- The welcome view is
clients/apps/desktop-renderer(@vibesys/app-desktop-renderer), composition only:main.tsx(the entry) overwiring/(each feature's store over the welcome bridge, and the main process's events fanned out to them) andui/(the view, composed from the features' views, with the flows that cross features). The features are@vibesys/feature-hosts(the host panel, checkout form, sign-in, "Connect to host", and path completion),@vibesys/feature-start-run(the "Start a run" sheet),@vibesys/feature-run-control(the title strip, stop and force stop, resume, the banner), and@vibesys/feature-recent-runs(Recent). Each feature's effects call only the slice of the welcome bridge its./statedeclares (HostsBridge,StartRunBridge,RunControlBridge). - The processes share only workspace packages:
@vibesys/hosts(host naming, a host's listed runs),@vibesys/runs(ids, recent runs, the stop and resume decisions), and@vibesys/ipc(the run page's bridge and the typed welcome IPC, each feature's requests with the parser the main process runs on them). The welcome view draws its dialogs, path fields, status chip, and buttons with@vibesys/ui.
apps/desktop-main declares one entry per process: . (node, src/main/index.ts, the Electron
main) and ./preload-run and ./preload-welcome (preload, the two preload files), so the
package-graph checker holds each side to its runtime and its third-party imports, and a preload to
loading only Electron. src/preload/welcome.test.ts drives the welcome preload's bridge into
@vibesys/ipc's parser, so the renderer and the main process agree on every channel's arguments.
Connections
@vibesys/connection is the only code that creates links and channels
(docs/contributing/desktop-connection.md, "Connection manager"):
- A
Linkreaches one host.LocalLinkstarts each helper as a child of/bin/sh;SshLinkuses the systemssh, so~/.ssh/config, ProxyJump, agents, and certificates work unchanged. The app owns one master connection per host (ControlMaster,ControlPath=/tmp/vsd-<uid>/%C,ControlPersist=30m,ServerAliveInterval), shared by every connection to that host. Only the master authenticates, through an askpass helper that shows ssh's prompt in a native dialog, and only when the user asks (the welcome view's sign-in, a Retry); a host key is never accepted silently. Every helper is aBatchModeexec channel over the master. - Both links start the helper with the same bootstrap (
shell/bootstrap.ts), the only shell text the app sends to a host:sh -c '<one-line script>' vibesys-helper xHEX, a fixed script with no quote or line break, its one datum (the checkout) hex-encoded forshto decode, so it means the same under any login shell. It checks the checkout, findsuv(the non-interactive PATH, a login shell's PATH, or a few common directories), andexecsuv run --no-sync --project CHECKOUT python -I -m entrypoints.host_helper; otherwise it exits 64 (the checkout predates the helper), 65 (not a VibeSys checkout), or 66 (nouv), with a word saying which. HelperChannelis one channel:hello, then requests answered concurrently by id, each under a deadline, every line parsed strictly against the generated contract (@vibesys/contracts/host-helper). A run channel's onerun.connectmakes it a byte relay carrying the run protocol; its end is the helper's exit status andChannelEndline.HostConnectionis one host's connection: a pure machine (state/host-connection.ts: idle, linking, handshaking, ready, reconnecting, offline, auth-needed, incompatible, closed) under a shell that owns the control channel, every run channel, and every timer through oneAsyncDisposableStackandAbortSignal. It is the app's one retry loop per host, and its check is the failing operation: the control channel is checked byping, a run channel by a freshrun.connectto the same run. Callers never retry: a request or a run channel waits until the host is ready, or fails with the machine's terminal reason.ConnectionManagerkeeps oneHostConnectionper host, and owns every connection and link: quitting closes every channel and master once. Runs are detached: closing the app never ends a run (vibesys instances stop IDdoes).
Every implementation passes one contract suite (testing/host-connection-contract.ts): the
in-memory FakeHelper, which speaks the helper protocol on the wire and replays the helper-*
conformance scenarios, and the real helper of this checkout (VIBESYS_REAL_CONTRACTS=1).
Welcome view and run view
The window opens on the welcome view (clients/apps/desktop-renderer/welcome.html, a React
bundle built apart from the run view's, with its own narrow preload): recent runs with
their status, one host's panel (its checkout, its live runs, "Start a run"), and "Connect to host",
a searchable list of the concrete Host aliases in ~/.ssh/config that also accepts a typed
user@host. The main process answers its requests only for that view's web contents and
validates every value before it reaches a host. Host data is pushed, never polled: the main
process watches each shown host's registry (instances.watch) and sends every change
(HostListing); recent runs' statuses derive from those listings. The checkout and project fields
complete paths as you type (paths.complete: one listing per directory, filtered in the view).
On first use of an SSH host the welcome view asks "Where is your VibeSys checkout on HOST?" and
checks the answer by starting the helper there before saving it (hosts.json, version 2: one
checkout per host key); This Mac defaults to the checkout the app was built from. "Start a run"
lists a project's tasks (tasks.list) and starts it (run.start), extra arguments split like a
shell's words; it can be cancelled while it starts, and the run is then stopped as soon as its
record appears. Recent rows are stored by (host, run id) from the run's record when the app
starts or attaches to it (recent.json, version 2).
Once attached, the run view (the web UI) fills the window and the welcome view shrinks to the
title strip above it, showing the host and the connection status. Each page connection is a
MessagePort the main process relays to a new run channel (features/run-control/relay.ts),
reframing one port message as one newline-delimited line in each direction. The page drives those
connections with the browser's WebSocketTransport and WebSession, which keep their resume and
reconciliation logic but no retry timer of their own: the shell wakes the page when the host
connection recovers.
The welcome view's stores (each feature's state/) are pure reducers on @vibesys/store's
kernel; each feature's renderer/ui/ is presentational and renderer/effects/ its shell, and the
app's main.tsx is the composition root. They are tested model-based with checkModel from
@vibesys/store/testing, with random window states from @vibesys/ipc/testing.
AppLifecycle (main/app/app-lifecycle.ts) is the one owner of process-wide state: a
single-instance lock (a second launch focuses the first window and opens what it was asked to)
and a pure quit machine (release every connection once, bounded, then exit through the one
app.exit call site).
Remote host and local laptop
The gateway intentionally binds only to loopback. For the one-command demo, create the tunnel when connecting from the laptop:
ssh -L 8765:127.0.0.1:8765 USER@chelan3
In that remote shell, run:
cd /path/to/VibeSys
./scripts/run-web-ui.sh --port 8765
The script detects the SSH session and does not try to open a browser on the
remote host. Open its printed VibeSys web UI ready URL in the laptop browser;
the existing SSH connection forwards both HTTP and WebSocket traffic.
For the separate Vite browser harness workflow, start the gateway on the remote host with a fixed port and the SSH target that you will use from your laptop:
uv run python -m entrypoints.web live --demo --port 8765 --ssh-target USER@chelan3
On the laptop, start the browser harness and run the printed tunnel command in separate terminals:
uv run python -m entrypoints.web dev --port 5173
ssh -N -L 8765:127.0.0.1:8765 USER@chelan3
The remote command must allow the browser harness origin:
uv run python -m entrypoints.web live --demo --port 8765 --browser-origin http://127.0.0.1:5173 --ssh-target USER@chelan3
Open the Browser harness URL printed by the remote command after starting
the tunnel. The browser page is served locally, and its WebSocket connection
is forwarded to the remote gateway.
The tunnel helper is equivalent when the capability URL is already available:
uv run python -m entrypoints.web tunnel --host USER@chelan3 --url 'http://127.0.0.1:8765/?token=TOKEN'
Open the Open locally URL printed by the helper. The local and remote ports
must match because the gateway checks the exact browser Origin during the
WebSocket handshake. The browser harness origin must be explicitly supplied
with --browser-origin. The capability token is never passed as an SSH
argument and is removed from the browser URL after the cookie exchange.
The remote project record is project-local and can be reused by a second launch
without creating another gateway. Stop the remote gateway from the remote host
with the stop command, or send SIGTERM to the recorded PID.