Skip to main content

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.lock and, 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 .nfsXXXX entry behind and the rmdir then fails with ENOTEMPTY. Leave the directory alone. The message names the pids it measured and gives the kill -9 that 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), and compose.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.ts is the only module that imports @vibesys/transport runtime values or uses fetch (tools/lint-rules/web-fetch-only-in-backend.grit), plus the browser lifecycle, tab storage, and the desktop shell's bridge transport. platform/ never imports app/.
  • clients/features/<name> (@vibesys/feature-run-view, @vibesys/feature-gateway-connect, @vibesys/feature-replay) is one feature. Its src/state/ (the ./state entry, neutral) is a pure step(state, action) -> {state, requests} reducer with request tokens that drop stale replies and invariants, plus the interfaces its effects need (run-view's RunConnection and BrowserLifecycle). Its src/renderer/ (the ./renderer entry, dom) holds ui/ (presentational React, props in and events out) and effects/ (performs the requests through the injected interfaces; never imports ui/). The gateway form's core parses with the WHATWG URL, a DOM global, so it sits in renderer/state/ and that feature publishes ./renderer only. A feature never imports another feature or a platform module; the package-graph checker and .dependency-cruiser.mjs enforce 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:

HeaderValueReason
Cache-Controlno-storeThe bootstrap response carries a capability and run output is live.
Referrer-Policyno-referrerA navigation before the frontend scrubs the bootstrap URL must not copy its token.
X-Content-Type-Optionsnosniff/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-PolicyderivedTranscripts 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' and style-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 and setAttribute('style', ...); it does not govern CSSOM writes, so React's style={{...}} prop (which compiles to el.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 enforces img-src on the favicon it probes for on its own, and a denied probe is a reported securitypolicyviolation that fails the browser audit. The grant allows nothing off-origin, and it does not make /favicon.ico reachable: 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 in clients/apps/web/index.html would suppress the probe and let this tighten to 'none'.
  • font-src 'none', stated rather than left to the default-src fallback, 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 polyfill fetches link[rel=modulepreload] hrefs, and a fetch of a script URL is governed by connect-src, not script-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/ipc parsed 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' ./main entries 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/connection is the connection manager, a pure state/ machine and a shell/; only its shell starts processes or opens sockets. @vibesys/settings is hosts.json and recent.json with SettingsStore as their one writer.
  • The welcome view is clients/apps/desktop-renderer (@vibesys/app-desktop-renderer), composition only: main.tsx (the entry) over wiring/ (each feature's store over the welcome bridge, and the main process's events fanned out to them) and ui/ (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 ./state declares (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 Link reaches one host. LocalLink starts each helper as a child of /bin/sh; SshLink uses the system ssh, 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 a BatchMode exec 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 for sh to decode, so it means the same under any login shell. It checks the checkout, finds uv (the non-interactive PATH, a login shell's PATH, or a few common directories), and execs uv 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 (no uv), with a word saying which.
  • HelperChannel is 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 one run.connect makes it a byte relay carrying the run protocol; its end is the helper's exit status and ChannelEnd line.
  • HostConnection is 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 one AsyncDisposableStack and AbortSignal. It is the app's one retry loop per host, and its check is the failing operation: the control channel is checked by ping, a run channel by a fresh run.connect to 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.
  • ConnectionManager keeps one HostConnection per 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 ID does).

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.