Skip to content

The web view

way --serve

opens a page with the same catalog way‘s terminal UI shows: a collapsible tree by call chain, sticky group headers, fuzzy and exact search, and a copy button on every row.

The full web view in light mode: tabs, the collapsible task tree, and the search box The full web view in dark mode

It is a server rather than a generated file on purpose. A file would be a snapshot — add a task and the page would be quietly wrong. The server probes, and the page refreshes on focus and on a 15-second poll, so what is on screen is what the repository currently has. How the very first request is answered, and how the page says so, is under Startup, and the cache below. The server binds to 127.0.0.1, and every route is behind a token — loopback is not by itself a boundary. See Security below.

A tab per runner, plus a combined “all” tab, each labelled with a task count. Runners that found nothing collapse into a single “N empty runners” chip rather than each claiming a tab of their own; clicking it opens the runner picker, where every runner is listed with its count and, if it applies, a note that it needs --exec or that it failed to probe.

The web view's tab row in light mode, showing a tab per runner plus a folded chip for empty runners The same tab row in dark mode

Drag a tab to reorder it. The arrangement is saved to the browser’s localStorage and applied everywhere the runner order matters — the tab row, the runner picker, and the top level of the combined tree — so the three stay in agreement. A runner not yet in the saved arrangement (a provider added later) slots in alphabetically at the end instead of vanishing.

Tasks are grouped by call chain into a collapsible tree, with sticky group headers so a long list of moon projects does not lose its heading as you scroll. The Collapse control folds every group at once; it changes nothing about which tasks exist, only how much of the tree is visible. Depth limits how far into the repository to look, by where a task is declared — the shallowest setting shows only what the repository itself declares (its own justfile, package.json, moon project), leaving out everything that belongs to one of its packages. Declared drops tasks that no file actually declares — cargo test and ruff check . are not written down anywhere; they are what a crate, or a ruff-configured project, runs by convention.

Every row has a run button beside its copy button. Pressing it does not run the task in the browser — it hands the terminal way is running in over to that task.

The page is done afterwards. That is the thing to know before pressing it. The server does not stay up behind the task: it spawns the command with stdio: "inherit" and then exits, so the page has nothing left to talk to. Polling stops, and every later fetch fails. The page says so — it replaces itself with “Handed off to the terminal”, the command that ran, and a note that you can close the tab.

What actually happens, in order:

  1. The page POSTs /api/run with the task’s id, and — for a task that takes parameters — a list of trailing argument strings. Never argv, never a command string, never a working directory. The server looks the id up in the catalog it built itself; a task it cannot find is a 404. This is deliberate: the port is reachable by any process on the machine and by any site the browser visits, so the only safe thing to accept from a request is a selector into a catalog the server produced. Accepting a command would make this an arbitrary-code-execution endpoint.

    The arguments are appended to that task’s own argv, never substituted into it. The binary, the leading arguments and the working directory come only from the catalog, so the worst a hostile caller can reach is passing arguments to a command this repository already contains. Elements that are not strings are dropped rather than converted, because String(value) would hand a spawn "[object Object]" as a real argument. The rule is spawnArgv and acceptArgs in src/args.ts, and it is what their tests assert.

  2. The server flushes the JSON response first, so the browser is told before the handoff, then stops itself and spawns the task on its own stdin, stdout and stderr.

  3. argv is spawned directly, with no shell — never display, which for a parameterised recipe carries <placeholders> a shell would read as redirections.

There is one terminal and one process, so only the first run wins. A second concurrent run gets a 409 and the row it was pressed on says “another run already took the terminal”. If the winner’s server is already gone when the second request goes out, the request dies with the connection instead, and the page reports the same fact its own way: “the terminal has already been handed off - this page is done.”

A recipe like just deploy <env> [tag] cannot be run as it stands: its argument vector holds just deploy and nothing else, because the placeholders are not values. Pressing run on such a row opens a bar at the foot of the page instead of handing over immediately:

$ just deploy ▮
takes <env> [tag] Enter run · Esc back

The command to the left of the field is static text, not part of the field. Only the tail is yours to type, because only the tail is what the server will accept — an editable just that silently still ran just would be a control that lies.

Quoting works as a shell’s word splitting does, so "two words" is one argument. Nothing else a shell does happens: no globbing, no variables, no operators, because the result goes to the spawn as separate argv entries and never through a shell. Esc closes the bar with nothing run.

A task that takes no parameters is unaffected: run hands over directly.

The run bar open beneath the task list, showing the command just deploy with staging typed after it and the parameters it takes named underneath The same run bar in dark mode

The same matcher the terminal UI uses, transpiled for the browser so the two rank a query identically: terms are whitespace-separated and ANDed, fuzzy by default, with a leading ' on a term forcing an exact substring match. A toggle switches the whole query to exact mode. Services filters to tasks marked persistent — ones that run until you stop them, such as a dev server — each marked with a dot in the leftmost column.

The task list narrowed by the query dep, with the matched characters highlighted in each row The same narrowed list in dark mode

/ focuses the search box from anywhere on the page. Typing filters the list as you go.

While the search box is focused and a query is active, the top hit is marked as the Enter target — visibly highlighted, so pressing Enter is never a guess about which row it will act on. Pressing Enter there copies that row’s command and hands keyboard focus to the results list, so the arrow keys walk the results next — the natural move after seeing that the top hit was not the one you wanted.

A search result row marked as the Enter target in the web view, light mode The same marked row in dark mode

Inside the list, ↑ and ↓ move between rows (clamped at the ends, not wrapped), and Enter on a focused row copies its command. Escape returns focus to the search box without clearing the query — clearing is the search box’s own Escape. Every row’s copy button is reachable by Tab as well, for a mouse-free copy with no query involved.

The first /api/catalog request is answered from the stored probe when there is one, with a real probe already running behind it, so the page appears at once on a repository whose slowest runner takes seconds; the rows update in place when the probe lands. Every later request gets the freshest thing the server has, and the Refresh button is always a real probe — an explicit refresh that could be answered from a cache is not a refresh. Concurrent requests await one probe rather than starting several.

Nothing is served from a cold store: with execution off every exec runner already contributes a withheld report, so a cold store would yield a page of nothing but withheld rows — an empty page dressed up as an answer.

The status area says one of three things about rows that came from the store:

  • Fresh — nothing at all. A span that is always present is a span nobody reads.
  • Stale — cached: just 35s old - re-probing.
  • Stale and slow to probe here — the louder, error-coloured span: cached: moon (2h old, ~2.4s) - slow to probe here, so re-reading takes a moment. This is the case the cache exists for — a runner this repository has MEASURED at seconds is why the page has rows at all instead of a spinner.

Slowness and refresh cost are measured in THIS repository, from a rolling window of the last five probe durations reported as their median. Where nothing has been measured yet the span says never timed here rather than inventing a number.

The startup banner reports the same fact, so it is visible before the browser opens:

cached moon 2h old — re-probing now

If you run a row that no longer exists — a task renamed since the stored probe — the server’s 404 names the cached listing and its age rather than telling you a row on your screen does not exist.

The cache changes nothing about trust. A cached answer is never a trust grant, clearing the cache never changes what is trusted, and a runner withheld for want of trust is never stored at all.

The gear beside ? opens this project’s configuration as the server resolved it — the same values way config list reports, in the schema’s own key order: runners, execution, discovery, excluded paths.

Each section names which layer supplied its value: this project, your user config, or the built-in default. That is the question a settings screen exists to answer — a runner you did not turn off in this repository may be off because your user config says so, and the section says which.

Runners, discovery and excluded paths are editable. Execution is not — see below.

Edits stage. A badge counts them, each changed section is marked unsaved, and nothing reaches the file until you press Save to waypost.config.json. Revert drops them, and so does closing the dialog: keeping edits you walked away from would let a later Save write something you had abandoned.

Staging rather than saving on each click is deliberate. waypost.config.json is committed, so a checkbox that quietly modified it would be a larger act than a checkbox looks like — and the surprise would be found later, by git status.

A key waypost does not recognise survives a save. The write is a patch of the keys the screen owns, applied to the file as it is on disk, so a setting from a newer version — or one you added by hand — is left alone rather than deleted.

Four things are refused, each with the reason in the message: a key that is not a setting (refused rather than dropped, so a typo cannot vanish silently), a value of the wrong type, allowExec: true, and a request without the session token.

It is deliberately not the runner picker. The picker in the toolbar filters the current session and forgets what you chose; the Runners section here describes what this project collects at all. Two different questions.

Where a grant would change something, the Execution section offers it — the same confirmed action the runner picker carries, and refused by the server in the same cases, so a button is never shown where the endpoint would say no.

Untrust is offered where the repository is trusted. It removes the stored grant, and it does not retroactively change the running session: execution may be on because of this store or because of an explicit --exec, and the server will not guess which. The store is the durable answer and it now says no; start waypost again to re-probe without it. The screen says exactly that rather than implying a revoke it did not perform.

Where a dragged tab order is in force in this browser, the Runners section says so and offers Reset to configured order. Before this there was no way back short of clearing site data — a local override that outranks runners.order with no way to clear it is a trap.

Execution is shown rather than offered as a toggle, because it is not a symmetric switch. A project’s own waypost.config.json may only ever turn execution off; a file that tries to turn it on is ignored, and the screen surfaces that warning rather than showing false with no explanation of why the file did nothing. Turning execution on is the trust grant — see Trust & execution.

The screen reads GET /api/config and writes POST /api/config, both behind the session token like every other API route.

A single button in the toolbar cycles the colour scheme: System → Light → Dark → System. “System” means no explicit choice is stored and the page follows the OS-level prefers-color-scheme; Light and Dark override it in either direction. The choice is saved to localStorage and restored on the next visit, per browser.

The server binds to 127.0.0.1, but loopback is not by itself a boundary: other local users on the machine can reach that port and can read a running process’s argv and environment, and any website the browser visits can issue cross-origin requests to it. So every route is behind a token, and there are two of them.

The boot token signs a browser in. It is 128 bits of hex from the CSPRNG, it appears in the URL way opens (/?boot=…) and nowhere else, and it is single-use — the first request that presents it burns it. It expires 60 seconds after the URL is actually handed to the user, not 60 seconds after it was created: the first catalog request runs in between, and on a cold tree with nothing stored that is long enough that a clock started earlier would overstate the window. It is accepted on the page routes only, never on /api/*.

The session token is what the page then uses. Also 128 bits of hex, one per server process. It never appears in a URL and never in an argv, because those are readable by other local users. It is embedded once into the served HTML as an inline script and the page keeps it in module state only — deliberately not localStorage, sessionStorage or a cookie, all of which outlive the tab or ride along on requests the page did not make. A single authFetch wrapper attaches it as the request header x-waypost-token, and every /api/* route requires it.

A header rather than a cookie is the point, not an implementation detail. A cookie would ride along on a cross-origin request from any site the user happens to be visiting — that is CSRF. A request header does not, and x-waypost-token is a non-simple header, so a cross-origin fetch must first pass a CORS preflight this server never answers.

Two smaller properties:

  • Tokens are compared with a constant-time equality that has no early exit, so a near miss is not distinguishable by timing.
  • A page-route auth failure returns one styled 401 document for every cause — no token, wrong token, spent token, expired token. It says nothing about which check failed, because that is what an attacker would use to aim and the authorized caller never needs it. The remedy it prints is to stop way in the terminal and start it again for a fresh link.

POST /api/trust is gated a third time, beyond the method and the token: the grant must be applicable — the same condition that decides whether the page may offer the button at all. The token proves who is asking; it never proves that asking made sense. Without this check a grant could override --no-exec, or write a standing grant to the store under a one-off --exec, with the rule enforced only by hiding a button.

That button is the toolbar’s Trust this repository, offered on a runner that was withheld for want of execution; it asks for confirmation, and on a grant the server returns a freshly probed catalog so the page re-renders in one round trip. What trust means and where it is stored is its own page.