Skip to main content

Clients architecture: TypeScript package graph

This document owns the structure of the TypeScript clients under clients/: which packages exist, which way they may depend on each other, what each entry point may use from its runtime, and how the workspace builds, checks, and tests them. It is the design of record. Packages that do not exist yet are marked in the migration status table, with the item that creates each one.

It builds on, and does not restate:

  • architecture.md: the Python module graph and the placement rule. The same principles (deep modules, one-way dependencies, a pure core with a thin shell) apply here.
  • desktop-connection.md: what the desktop main process, the host helper, and the screen stores do. This document says which package each of those lives in.
  • wire-protocol.md: the run protocol that @vibesys/contracts types.
  • web-development.md: how to run the web UI and the desktop app.

clients/tui is deprecated. It keeps building and its checks stay green, but it is not migrated into this graph; it consumes the packages below through their public entry points like any other app, and is deleted when the desktop app replaces it.

Why​

clients/web and clients/desktop grew as two large packages with layering rules enforced inside each (state/ pure, ui/ free of I/O, effects/ never importing ui/). Those rules hold inside a package but say nothing about which feature may reach which, or which runtime a module may use. A feature that imports another feature's internals, or renderer code that imports a Node module, type-checks today and is caught, if at all, by review.

The fix is the same one the Python side uses with tach: make every boundary a package boundary, declare the edges, and let tooling refuse the rest. A package is used only through its exports map; each entry point is compiled with exactly the runtime library it may use; and one checker validates the graph from the manifests. Per-feature packages make each feature small enough to read whole, and the build model below keeps a graph of thirty packages as fast as the current five.

The package graph​

clients/
libs/contracts/ @vibesys/contracts generated message types + the one runtime validator
libs/ui/ @vibesys/ui the design system: domain-free components + design tokens
libs/store/ @vibesys/store domain-free reducer kernel + fold model-test kit
libs/machine/ @vibesys/machine domain-free machine glue + machine model-test kit
-> store
libs/run-state/ @vibesys/run-state pure fold of run events; ./rounds, ./performance,
./transcript projections -> contracts
libs/transport/ @vibesys/transport framing, redial, persistent stream -> contracts
libs/hosts/ @vibesys/hosts host identity, listing, connection status,
checkout path rules -> contracts
libs/runs/ @vibesys/runs run identity, recent status, resume and stop
-> contracts, hosts
libs/run-session/ @vibesys/run-session one live run: runStep, the session machine, its
ports, connectRun, WebSession -> contracts, run-state,
transport, store, machine
libs/transport-node/ @vibesys/transport-node stdio / unix socket / ssh channel -> transport
libs/ipc/ @vibesys/ipc typed main <-> renderer messages, the welcome
requests' parsers per feature -> hosts, runs
libs/connection/ @vibesys/connection host connection manager -> contracts, transport, hosts,
machine
libs/settings/ @vibesys/settings settings store, atomic files -> hosts, runs
features/<x>/ @vibesys/feature-<x> run-view, gateway-connect, replay, hosts,
start-run, run-control, recent-runs; strict:
app-shell, settings, machines
apps/desktop-main @vibesys/app-desktop-main composition only (Electron main), and the
sandboxed preloads
apps/desktop-renderer @vibesys/app-desktop-renderer composition only (Electron renderer)
apps/web @vibesys/app-web composition only (browser, and the
desktop shell's run page)
apps/desktop-shell @vibesys/app-desktop-shell composition only, strict: the redesigned
window (`shell.html`)
apps/ui-gallery @vibesys/app-ui-gallery development only, never shipped: every
component in every theme, screenshot-tested
in Ink
tui/ @vibesys/tui deprecated terminal UI; stays at the root until it is deleted
tools/ scripts/ (checkers, scaffold), lint-rules/ (Biome plugins), and the shared
tsconfig.base.json, tsconfig.architecture.json, knip.config.ts

The folder a package sits in mirrors its layer: libs/ holds the foundation, core, domain, and service packages, features/ the feature packages, and apps/ the app packages. The manifest's vibesys.layer stays the one source of truth; the layer-matches-folder rule fails a package filed under the wrong folder. Imports are by package name, so moving a package between folders changes no source. tui is the one package outside the three folders (see Layers). Files a tool requires at the workspace root stay there: package.json, pnpm-workspace.yaml, pnpm-lock.yaml, turbo.json, biome.json and biome.warnings.json (Biome resolves a config's plugin paths against the config that extends it, so the warnings config sits beside biome.json), and .dependency-cruiser.mjs.

flowchart BT
contracts["contracts (L0)"]
ui["ui (L0, DOM)"]
store["store (L0)"]
run-state["run-state (L1)"] --> contracts
transport["transport (L1)"] --> contracts
hosts["hosts (L1)"] --> contracts
runs["runs (L2)"] --> contracts & hosts
machine["machine (L1)"] --> store
run-session["run-session (L2)"] --> contracts & run-state & transport & store & machine
transport-node["transport-node (L2, Node)"] --> transport
ipc["ipc (L3)"] --> hosts & runs
connection["connection (L3, Node)"] --> contracts & transport & hosts & machine
settings["settings (L3, Node)"] --> hosts & runs
features["feature-* (L4)"] --> run-session & runs & hosts & ipc & ui & store
features -. "./main only" .-> connection & settings
apps["apps/* (L5)"] --> features

Layers​

Every package declares its layer in package.json ("vibesys": {"layer": ...}). A package may depend only on packages in a strictly lower layer. That one rule gives the direction, rules out cycles, and forbids feature-to-feature and app-to-app edges, because peers share a layer.

LayerRankPackagesMay depend on
foundation0contracts, ui, storenothing in the workspace
core1run-state, transport, hosts, machinefoundation
domain2runs, run-session, transport-nodefoundation, core
service3ipc, connection, settingsfoundation, core, domain
feature4feature-*anything below
app5apps/* (and the deprecated tui)anything below

This refines the sketch in the brief in three places. ui and store sit in foundation: rank 0 is the mechanical form of "no domain dependencies", so the layer rule enforces rule 6 for them with no special case. transport-node, connection, and settings get ranks of their own (domain and service) because each depends on another package the sketch places at the same level (transport-node on transport, connection on transport, hosts, and machine, settings and ipc on hosts and runs). And hosts sits in core, one rank below runs, because a run names the host it runs on (a recent run's status reads its host's listing; the stop flow is keyed by host) while a host never names a run's model.

Packages are named for one concept​

A package is named for the concept it owns, never for a place to put things. @vibesys/core-state and @vibesys/entities were split for this reason: the first held both the generic store kernel and the run fold, the second every domain model the views shared, and each name invited the next unrelated addition. The split gave store (generic, domain-free like ui), run-state (the run fold, its round, performance, and transcript projections as separate entries), hosts, runs, and run-session (the live session below the UI, out of feature-run-view, which keeps only its interaction state, banner copy, and UI). A helper used by one feature lives in that feature (shellWords is @vibesys/feature-start-run/state's). The bucket-names rule keeps foundation, core, and domain packages from being named entities, core, common, utils, shared, helpers, or misc.

Entry points and runtimes​

Every entry in a package's exports map declares the runtime it may use, in the same "vibesys" block:

RuntimeCompiler libCompiler typesWho may import it
neutralES2022noneany entry
domES2022, DOM, DOM.Iterablenone (vite/client for apps)dom entries
nodeES2022node (and electron where needed)node entries
preloadES2022, DOMnode (Electron's renderer API types need Node's)preload entries

A preload is an Electron sandboxed preload: it runs in a page's renderer process before the page, with the DOM and Electron's renderer APIs. The sandbox gives it a require that reaches electron and nothing else, so it may import a workspace package only as a type (import type, typeof import(...), which the compiler erases), no Node builtin, and no node entry. Each preload is one file and one entry, which the main process loads by path into a page.

An entry may import an entry of another package only when the target is neutral or has the same runtime. A feature package has up to three entries:

  • ./state (neutral): the pure reducer, selectors, and the interfaces its effects need (for example RunControl). No React, no I/O.
  • ./renderer (dom): state plus ui/ components plus effects/, which call the injected interfaces. It depends on @vibesys/ui, never on a platform package.
  • ./main (node): the main-process controller that answers the feature's requests with @vibesys/connection and @vibesys/settings (Hosts, RunControl). apps/desktop-main routes each request @vibesys/ipc parsed to the controller of the feature that owns it.

A feature may also publish ./styles.css, the stylesheet of the views it renders (a dom asset, like @vibesys/ui's ./tokens.css); an app imports the stylesheets of the features it composes and keeps only its own layout in its stylesheet, so a feature's look moves with the feature.

A feature publishes only the entries it has. Its pure core is ./state when it needs nothing beyond ES2022; a core that needs a DOM global (@vibesys/feature-gateway-connect parses with the WHATWG URL) stays pure but lives in renderer/state/, and the feature publishes ./renderer alone. Effects reach the platform only through the interfaces ./state declares, which the app implements. A domain package that owns effects declares its ports the same way: @vibesys/run-session declares RunConnection's control transport and BrowserLifecycle, and apps/web implements them with its backend adapter and lifecycle.

Node-only packages (transport-node, connection, settings) therefore reach the renderer through no path: only node entries may import them, and only desktop-main and features' ./main entries are node. A preload is not node either, so it reaches none of them.

Rules and what enforces them​

#RuleEnforced by (rule id in check_package_graph.mjs)
1Layer direction; Node-only packages reachable only from node entrieslayer-direction, runtime-reachability
2No feature depends on another feature; cross-feature flows are composed in appslayer-direction (peers share a layer)
3A package is used only through its exports map; every import of a workspace package is a declared dependencyimports-use-exports, imports-are-declared, imports-are-workspace-packages, workspace-protocol; .dependency-cruiser.mjs for relative paths into another package
4Each entry is compiled with the lib and types of its runtime, so importing a Node module from a renderer entry is a type errorentry-projects, entry-capability; tsc -b then enforces it
5Inside a package: state/ pure, ui/ free of I/O, effects/ never import ui/, effects depend on injected interfacestools/lint-rules/*.grit (Biome) and .dependency-cruiser.mjs
6@vibesys/ui and @vibesys/store have no domain dependencies and name no domain concept; helpers live in the lowest package that needs them, with no shared junk drawerlayer-direction (both are rank 0), domain-free (no word of DOMAIN_WORDS, or an inflection of one, in their production source or stylesheet); review
7A foundation, core, or domain package is named for the concept it owns, not for a bucketbucket-names (no segment of its name in BUCKET_NAMES)
8A sandboxed preload loads only Electron at run time; every workspace import in it is erasedpreload-sandbox (what the entry's emitted CommonJS still requires); .dependency-cruiser.mjs keeps a preload's imports to @vibesys/ipc and Electron
9Control state is an XState machine; data folds are plain reducers (see State)external-dependencies (xstate only in features' ./state and ./renderer and MACHINE_PACKAGES); tools/lint-rules/features-no-hand-rolled-control.grit, tools/lint-rules/control-state-no-store-kernel.grit
10A package's folder matches the layer its manifest declares: libs/* is foundation, core, domain, or service; features/* is feature; apps/* is app; nothing else, except the deprecated tui (DEPRECATED_OUTSIDE_FOLDERS)layer-matches-folder
11A package marked "design": "strict" builds UI only from @vibesys/ui; old UI packages are frozen to fixes only (see Design system enforcement)strict-no-stylesheets, strict-imports, strict-lint-coverage; tools/lint-rules/strict-*.grit; check_frozen_ui.mjs

All package-graph rules, and the tables they read, live in one module, clients/tools/scripts/check_package_graph.mjs, run by pnpm check:ts-architecture. Its tests build a valid fixture workspace and break it once per rule, and a meta-test fails when a rule has no failing fixture. .dependency-cruiser.mjs keeps the rules about modules inside a package and about how imports resolve.

How dependencies are managed​

One declaration. A package's package.json is the single source of truth for its place in the graph:

"vibesys": {"layer": "feature", "entries": {"./state": "neutral", "./renderer": "dom", "./main": "node"}}

The checker, the scaffold (which writes one tsconfig.<entry>.json per entry from it), Knip's policy, and this document all read that block. An export whose source is a stylesheet (@vibesys/ui's ./tokens.css) is a dom asset: it has no tsconfig project, and Vite resolves it through the same source condition. Unknown keys, layers, and runtimes are rejected by name (declarations), and the entries must be exactly the exports keys (entries-match-exports). The block may also carry "design": "strict", the only value the closed design key takes, which opts the package into the design system rules.

Workspace dependencies are declared as workspace:* in the section the code needs (dependencies for production code; test code may also use devDependencies), point to a lower layer, and are mirrored by tsconfig project references. The references must equal the declared workspace dependencies, no more and no fewer (references-match-dependencies): a missing reference is not a compiler error, because the import still resolves to the dependency's source.

Third-party dependencies are confined by runtime and layer through one allowlist table in the checker (EXTERNAL_DEPENDENCIES, rule external-dependencies):

ImportAllowed in
react, react-dom (and subpaths)dom entries: ui, features' ./renderer, browser and renderer apps
electronnode and preload entries of features (./main) and apps (desktop-main)
@opentui/corenode entries of apps (the deprecated tui)
xstatefeatures' ./state and ./renderer; MACHINE_PACKAGES: machine, connection (its host connection machine), and run-session (its session machine). Never store, run-state, hosts, runs, ui, contracts, transport, or ipc
Node builtins (node:* and bare names)node entries

A package that declares one of them in dependencies must have an entry allowed to use it. Knip (pnpm check:knip) composes with this: it reports dependencies declared but never imported and imports of packages not declared, so between the two a dependency is declared exactly when it is used, and used only where its runtime allows.

Legacy packages. tui predates the per-entry layout, so LEGACY_EXEMPTIONS in the checker lets it skip the per-entry project rules; every other rule applies to it. It is deprecated and keeps its exemption until it is deleted. The table only shrinks: desktop left it when its main process was composed from packages (apps/desktop-main, phase 8 of #1878).

Design tokens and themes​

@vibesys/ui publishes its design tokens as typed data and as CSS. One table is the source of truth for each fact; everything else is derived from it.

EntryRuntimeContents
.domThe components (see Components), applyTheme, and the token types (ThemeName, ColorRole, SpaceToken, TextStyle, ...).
./legacydomToday's Button, Combobox, Dialog and StatusChip, which take className and raw HTML attributes. Frozen: only the frozen views import it, and it is deleted with them.
./tokensneutralTHEMES, DEFAULT_THEMES, SPACE, TYPE, SIZE, RADIUS, ELEVATION, MOTION, CONTRAST_PAIRS, contrastRatio, and renderTokensCss. No DOM, so tests and tools import it.
./tokens.cssdom assetGenerated from ./tokens; never edited by hand.
./components.cssdom assetThe components' styles, hand-written from token variables only.
./fonts.cssdom asset@font-face for Schibsted Grotesk and JetBrains Mono (weights 400, 500, 600, and JetBrains Mono 700 for the wordmark) from the woff2 files in src/fonts/, with their SIL OFL texts. Nothing is fetched at runtime.

A theme is {mode, colors} where colors is a Record<ColorRole, Hex>, so a missing role is a compile error. The six themes are paper, mist, sand (light) and ink, midnight, terminal (dark); DEFAULT_THEMES names paper and ink. ThemeName is the data-theme value.

src/tokens.css is written by pnpm --dir clients/libs/ui generate:tokens from the tables and src/primitives.css (the hand-written styles of the primitives, appended verbatim). It has the scales and derived values (color-mix fills for selection, chips and banners) on :root, one :root[data-theme="<name>"] block per theme, the zeroing of every motion duration under prefers-reduced-motion: reduce, and aliases for the variable names the stylesheet had before the roles (LEGACY_ALIASES), which stay until their consumers move. The defaults sit on a bare :root (paper, and ink under prefers-color-scheme: dark) and the named blocks override them, so a data-theme outside ThemeName (another app sets its own names) or none at all follows the OS between the two default themes and every role stays defined.

applyTheme(name, target = document.documentElement) sets data-theme; it takes the element so a test can pass a fake. The selectors name :root, so target only works as the root element.

Design system enforcement​

Desktop screens are built only from @vibesys/ui's public components and tokens. The rule is rolled out without touching existing code: new screens go in new, strict packages, and the old UI packages are frozen until they are deleted. There is no baseline file; strict or frozen is a property of the package.

Strict packages​

A package opts in with a marker in its manifest, which stays the one source of truth for its place in the graph:

"vibesys": {"layer": "feature", "design": "strict", "entries": {"./state": "neutral", "./renderer": "dom"}}

design is a closed set (strict or absent); declarations rejects any other value by name.

#RuleEnforced by
S2A strict package ships no stylesheet: no *.css export and no .css, .scss, .sass, .less, .styl or .pcss file in the packagecheck_package_graph.mjs rule strict-no-stylesheets
S3Strict code renders no raw interactive or styled elements: JSX button, a, input, select, textarea, label, dialog, details, summary, form, table, svg and pre, and the style, className and dangerouslySetInnerHTML props on any element, are errors. Plain div and span stay for grouping text inside slotsGrit rule tools/lint-rules/strict-no-raw-elements.grit
S4Strict code contains no raw colors or lengths: a string or template literal with a hex color, rgb(, rgba(, hsl(, hsla(, oklch(, color-mix(, or a px, rem, em, vh or vw length is an errorGrit rule tools/lint-rules/strict-no-raw-values.grit
S5The strict lint rules cover exactly the strict packages: the includes of biome.json's strict override (the one loading both rules above) are each strict package's src/**/*.ts and src/**/*.tsx plus the two test exclusions, so a new strict package cannot escape S3 and S4, and a package cannot be listed without the marker. The override has no includes until the first strict package existscheck_package_graph.mjs rule strict-lint-coverage (the expected globs come from strictOverrideIncludes)
S6A strict package imports UI only from @vibesys/ui's . and ./tokens, and no old feature package. Its workspace imports are the non-UI libraries in STRICT_WORKSPACE_IMPORTS and other strict packages (a strict app composes strict features' screens), and its third-party ones are react, react-dom and xstate (STRICT_THIRD_PARTY; Electron and Node builtins stay confined by external-dependencies), in production source and in the manifest's dependencies. Test code is exempt. A strict app may also import @vibesys/ui/tokens.css, @vibesys/ui/fonts.css and @vibesys/ui/components.css once, in its entry wiring. Deep imports are already refused by imports-use-exports and .dependency-cruiser.mjs, feature-to-feature imports by layer-directioncheck_package_graph.mjs rule strict-imports
S8@vibesys/ui itself uses tokens: strict-no-raw-values.grit also runs on libs/ui/src except the token table (src/tokens/) and tests, and tools/lint-rules/ui-no-raw-css-values.grit bans raw colors and lengths in the declarations of libs/ui/src/*.css except the generated tokens.css. Lengths in @media conditions are not declarations and staythe two Grit rules, wired by biome.json

biome.json excludes libs/ui/src/primitives.css from the stylesheet rule: the old primitives' pixel values (10px, 5px, 14px, ...) have no token to read, and a token would change what they draw. The components that replace them delete the file and the exclusion.

Every failure names the file and line (or the manifest key), the rule id and the fix, for example "a raw <button> ... Use <Button> from @vibesys/ui". The rules cannot see through a spread (<Card {...props} /> can carry a className); the components take no className or style prop (rule S1, with the component API), which closes that path. Unenforced: whether a screen matches its design needs a reader; the checks hold the parts it is built from.

Frozen UI packages​

An old UI package gets fixes only. FROZEN_UI_PACKAGES in clients/tools/scripts/check_frozen_ui.mjs is the one list of them, each with the paths that hold its UI (src/renderer/ for a feature; src/app/ and src/styles.css for apps/web; src/ui/ and src/welcome.css, the welcome view, for apps/desktop-renderer). In tui_quality, pnpm check:frozen-ui --base-ref origin/main compares the change with its merge base with origin/main, and in a listed package it refuses:

  • a new file under a UI path (new test files are allowed);
  • a name added to the exports of the package's ./renderer entry, followed through export *;
  • a rise, in any UI file, of the count of JSX intrinsic interactive elements (button, a, input, select, textarea, label, dialog, details, summary, form) or of raw color literals, so a fix may move or delete such code and not add to it.

The check is a function of two file trees (frozenUiErrors(base, head)); the command line only builds them, the base from the merge base. A package leaves the list when it is deleted: remove its entry in the same change, and the check fails if a listed package no longer exists. The list only shrinks.

Components​

The redesigned screens are built only from the components of @vibesys/ui's . entry, with no CSS of their own. An app imports ./tokens.css, ./fonts.css and ./components.css once.

  • Props are data and callbacks. Variants are token names or small closed unions (gap: SpaceToken, variant: TextStyle, tone: StatusTone); no component accepts className or style, so a raw value cannot reach one (rule S1 of #2827, held by the types).
  • Styles use tokens only. src/components.css reads token variables (--space-3, --control, --radius-lg, --text-body-size, ...) and writes no color or length (rule S8); its classes are vs- prefixed so they never meet the legacy primitives' global selectors.
  • Accessible by type. StatusDot requires label; Button requires visible text or an icon with label (its aria-label). Status is never color alone: a live dot differs from ok by a ring. A disabled Button, Checkbox or Switch takes disabledReason, shown as help text it is described by. The focus ring (2 px accent, 1 px offset) is drawn here only.
  • Fields wire their control. TextInput, PathInput and Select render only inside a Field, which gives them their label, help and error; outside one they throw.
ComponentProps
Pagewidth?: 'content' | 'wide' | 'narrow'; the scrolling <main> with the page gutter
Stack, Rowgap?: SpaceToken, align?, justify?: 'start' | 'center' | 'end' | 'between', wrap?, grow?
Splitmain, aside, gap?; the aside wraps under the main column when narrow
Gridmin: 'field' | 'tile' | 'swatch', gap?; auto-fit columns
Textvariant?: TextStyle (not style, which strict code may not pass), tone?: 'default' | 'muted' | 'ok' | 'warn' | 'err', as? (element), id?
Wordmarkart (the caller's ASCII art), label, align?: 'start' | 'center', tone?: 'accent' | 'gradient'; bold and solid, sized to its container's width
Iconname: IconName (closed set, ICON_NAMES), label? (decorative without one)
Buttontext or icon + label; one of press, href (renders <a>) or submit; variant?: 'primary' | 'secondary' | 'quiet' | 'danger', size?: 'sm' | 'md', full?, disabledReason?, expanded?
SegmentedControllabel, options: {value, label}[], value, change, size?; native radios, so one tab stop and arrow keys
Selectvalue, options: {value, label, disabledReason?}[], change, mono?
Fieldlabel, help?, error?, one control as children
TextInputvalue, change?, placeholder?, mono?, readOnly?
PathInputvalue, change, completions: {value, detail?}[], placeholder?; the ARIA combobox pattern
Checkbox, Switchlabel, checked, change, disabledReason?
Chiplabel, tone?: StatusTone, dot?
StatusDottone: 'neutral' | 'live' | 'ok' | 'warn' | 'err', label
Cardsection? (a titled region), flush?
Bannertitle, tone?: 'neutral' | 'ok' | 'warn' | 'err', body?, actions?
Menuthe trigger's text or icon + label, items: {id, label, press | href, danger?}[], variant?, size?; raised, keyboard navigable, closes on Esc, a choice or the focus leaving
Dialogopen, title, dismiss, children, actions?; the native modal <dialog> at the overlay elevation
Tablelabel, columns: {key, header, cell(row), mono?, align?}[], rows, rowKey, selected?, select?; sticky header, scrolls sideways in its own focusable box
Tabslabel, tabs: {value, label}[], value, change, the panel as children; arrows move and choose
Tree, TreeRowTree: label, selected?. TreeRow: id, depth, title, subtitle?, state?: {tone, label}, chips?, expanded? + toggle? for a group, select; up, down, left, right, Home, End
KeyValueitems: {key, value, mono?}[]
CheckListitems: {id, state: 'ok' | 'warn' | 'fail' | 'pending', label, detail?, fix?: {text, press}}[]; each state has its own icon and word
Timelinestages: {id, state: 'done' | 'active' | 'waiting' | 'failed', label, elapsed?, quietFor?, body?}[]; a body discloses, open at first for an active or failed stage
LogPanelabel, lines: {text, stream?: 'out' | 'err'}[], size?; follows the tail while at the bottom, offers "Jump to latest" when scrolled up; text only
Collapsiblelabel, summary?, leading?, mono?, open, toggle(open), children; the caller owns whether it is open, so a list that unmounts blocks keeps them open
Messageauthor, variant: 'reply' | 'reasoning' | 'prompt' | 'note', tone?: 'default' | 'ok' | 'err', text, notice?, open, toggle; plain text, closed it previews its first line
ToolCallname, state: 'pending' | 'ok' | 'failed', summary?, meta?, input: string | null, output: string | null, open, toggle; closed it is one line (name, summary, meta), open it shows input and output, a long output cut with "Show more"
StructuredReplyauthor, title, fields: ReplyField[] (prose, badge, fact, list, or group of fields), raw, open, toggle; badges sit on the title line, text is text, groups open on request, a "Raw" button shows the JSON
Threadlabel, items: ({kind: 'heading'} | {kind: 'message'} | {kind: 'tool'} | {kind: 'reply'})[] (each with a unique id); windowed over measured row heights, so any length costs a screenful; follows the tail, "Jump to latest" when scrolled up; keeps each block as the reader left it; give it a key per thread
Scrubberlabel, value, last, position (words), live, pastLabel, ticks?, change, backToLive
Statlabel, value, sub?
Chartlabel, points: {x, y, filled}[], steps?, reference?: {y, label}, playhead?, xTicks?, yTicks?, xTitle?; SVG in accent, muted and grid
SidebarItemlabel, href or press, icon?, status?: {tone, label}, count?, detail?, active?, menu?
SidebarSectiontitle, children, showAll?: {label, href | press}
EmptyStatetitle, body?, action?
AppFramelabel, nav, footer?, the main area as children; the window's sidebar column and main area, each scrolling
ThemeSwatchtheme, label, selected, press; a preview drawn in that theme's colors from the token table

Time-dependent values (elapsed, "quiet for", a scrubber position) are props the caller formats; no component reads a clock. Standard error in LogPane is drawn in --log-err, err mixed into log-text (LOG_ERR_MIX in ./tokens), because a light theme's err does not read on the log pane's dark background; a token test checks it in every theme.

apps/ui-gallery renders every state of every component, one page per theme (?theme=<name>), with fixed content: no clock, randomness or network, and the bundled fonts. Its registry is typed over the entry's component exports, and test_gallery_covers_every_public_component fails a component with no gallery entry. Run it with pnpm --filter @vibesys/app-ui-gallery dev. The gallery is a strict package ("design": "strict"), so it also shows that a page built only from @vibesys/ui passes the strict rules.

Its Playwright suite (in the tui_e2e group) runs in one theme, Ink: it fails on any axe violation and compares each component's screenshot against e2e/__screenshots__/ink-<Component>.png under reduced motion at a fixed 1280 by 900 viewport. The other themes are not screenshot-tested; the token test still holds every theme to the contrast floors. The baselines are Chromium on Linux, the tui_e2e job's browser and OS, so they are written in Playwright's Linux image, never on macOS:

pnpm --filter @vibesys/app-ui-gallery update:screenshots # rewrite the baselines (needs Docker)
pnpm --filter @vibesys/app-ui-gallery test:e2e:linux # compare against them locally

When the job fails on a screenshot, it uploads the actual and the diff as the ui-gallery-test-results artifact.

State​

Client state comes in two kinds, and each has one form.

Control state is an XState v5 machine. Control state is a mode with effects: it starts work, waits for it, retries, times out, cancels, and decides what a late answer means. The live run session (start-up, then the stream; single-flight recovery), the run-control banner (the latest command wins), the desktop connection manager (dial, back off, redial on wake), and a sheet that loads, submits, and cancels are all control state. Each is written as setup({types, actors, actions, guards, delays}).createMachine(...):

  • every operation is an invoked actor with an explicit id, declared with a placeholder (unprovided from @vibesys/machine) and provided by the shell with machine.provide, so the machine stays pure and the operation stays injectable;
  • a delay is an after transition, with the clock injected through createActor's clock option (clockFrom adapts a ScheduleTimeout, so tests drive it with a fake clock);
  • cancellation is leaving the state that owns the work: the operation stops, its delay is cancelled, and a stopped operation never reports, so there is no reply token to compare;
  • the view reads the actor through actorStore, which notifies only when the view changes.

A data fold is a plain reducer. A data fold turns ordered inputs into a value and has no mode: the run event fold (@vibesys/run-state), a host's listing, recent runs. It is a pure (state, action) -> state on @vibesys/store, tested with checkModel. A package that owns control state keeps its folds in their own modules and uses createStore nowhere else.

Resources. XState drops a late output but cannot stop the work that produces it: a fromPromise body keeps running after its actor stops. So an operation that opens a resource returns it, and the machine installs it in that operation's onDone action, from where leaving the state disposes it. An operation that holds a resource only while it runs either reads its signal and disposes what it opened when that is aborted, or leaves teardown to the owner of the connection it ran on, which tears down whatever they left open when the shell closes it.

Failures. Every invoke has an onError as well as an onDone. The shell's operations report expected failures as output (a snapshot load resolves to {ok: false, error}), and onError handles a rejection the same way, so an unexpected throw ends the flight instead of leaving its region waiting. An action can throw too (a fold or a connection method), which stops the actor; the shell observes that with an error observer and releases what it holds.

Tests. A machine is tested three ways, all scaffolded by pnpm new:package feature <name>: pure transitions through getNextSnapshot (or transition, which also returns the actions); checkMachine from @vibesys/machine/testing, which folds seeded random events (plain events, and the done, error, and fire outcomes of whatever is live) and checks after every step that each live operation and armed delay belongs to an active state, that no delay is armed twice or cancelled while not armed, that a final state holds nothing, and the model's own invariants; and, for stale replies, a live actor whose operations the test settles in a chosen order. Migrating a reducer to a machine keeps its seeded invariants (ported through the kit) and its shell's behavioral tests, changed only where an operation's reply now lands a step later (a test then waits for the published state instead of reading it at once), and deletes the reducer in the same change.

Why. Hand-rolled control state kept producing the same bugs: a reply token compared against the wrong slot, a timer not cleared on one exit path, an abort controller that outlived its owner. Each fix added bookkeeping that the next change missed. A machine makes "this work belongs to this state" structural, and the kit checks it over every seeded interleaving.

Rejected alternatives.

  • Effect.ts. Its fibers and scopes solve cancellation, but the model-based tests that are the core of this codebase's testing cannot enumerate or step a fiber: the seeded invariant harness would not port. XState keeps the transition pure (transition is a function), so it does.
  • No library. Reducers with reply tokens plus hand-written timer and abort bookkeeping in the shell were what we had. Cancellation and timing lived in the shell, outside the tested core, and kept regressing.
  • XState for folds too. A fold has no transitions to model: wrapping it in a one-state machine adds a runtime and an indirection and checks nothing more than checkModel does.

Pitfalls. Inside setup, enqueueActions does not type-check under exactOptionalPropertyTypes; use assign and parameterized actions. Actions in one transition see each other's assigns in order, so an action that must read the previous context runs before the assign. A placeholder actor typed differently from the real one makes provide reject it; type the placeholder with the operation's input and output. always guards should read context, not the event. Every invoke gets an explicit id (the kit requires it). In a parallel state, handle an event at one level only: a region's own transition for an event replaces its parent's, and regions coordinate through events they raise, which every region takes in the next step, with guards that read the state before it. A reenter transition declared on a region exits the whole parallel state, so re-enter a region's child from the child itself. An actor notifies its observers on every event, also one that changed nothing, so a shell that publishes changes compares snapshots by identity. An outcome can go stale between the operation's end and the machine taking it (a channel the operation opened ends in between): guard onDone with a guard the shell provides, and give it a fallback transition whose action hands the resource back, as @vibesys/connection's current guard and discard action do. A fromPromise signal aborts when its actor stops before its outcome was delivered, so an operation disposes what it opened on that abort. The pure transition keeps taking events in a final state, which the live actor does not: a model stops generating events once the snapshot's status is done.

Where XState may appear is rule 9: the external-dependencies table confines the import, and two GritQL rules (wired in biome.json) keep features' state/ and effects/ (and connection's state/) free of setTimeout, setInterval, and AbortController, and keep migrated control-state packages (run-control, connection, run-session) off createStore (except their fold modules, such as run-session's run.ts). The features still on the reducer kernel are migrated one per change. Until then the two effects modules that hand-roll a timer or an abort controller (hosts' path completion, gateway-connect's attempt) are listed as exclusions in biome.json, a list that only shrinks; the remaining migrations and those exclusions are tracked in #1969.

Control stateFormStatus
run-session's session lifecycle (sessionMachine, WebSession)machinemigrated (#2071)
feature-run-control's commands and bannermachinemigrated (#1950)
@vibesys/connection's HostConnectionmachinemigrated (phase 8)
feature-web-theme's current host-default requestmachinenative (#2168)
feature-run-view's startup log panel (startupLogMachine)machinenative
feature-start-run, feature-hosts, feature-gateway-connect, feature-replay screen storesreducer with reply tokensone feature per change

Build model​

The workspace stays fast as the package count grows because nothing waits on a build it does not need, and nothing repeats work whose inputs did not change.

Source condition. Every export carries a custom condition that points at its TypeScript source:

"exports": {
"./state": {
"@vibesys/source": "./src/state/index.ts",
"types": "./dist/state/index.d.ts",
"default": "./dist/state/index.js"
}
}

Vite (resolve.conditions), the Bun test runner (--conditions), and tsc (customConditions in clients/tools/tsconfig.base.json) all resolve that condition, so the dev server, tests, and type checks read dependencies from source and never need a prebuilt dist/. Node and Electron at runtime do not set the condition and load dist/. There are no paths aliases and no pre* "build dependencies first" scripts.

Project references. Each entry is a composite, incremental TypeScript project; the package's tsconfig.json is a solution file that references its entry projects and its test project, and each entry project references the entry projects of the packages it imports. tsc -b then type-checks and emits declarations per project, skipping every project whose inputs are unchanged, and checks a dependent against its dependencies' declarations instead of their source.

Task runner. Turborepo runs build, check, and test across the workspace in dependency order, in parallel, with a local content-hash cache in .turbo/cache at the repository root (shared by the git worktrees of one clone; CI keeps none between runs). A task whose inputs (the package's tracked files, its dependencies' hashes, and the shared configuration) match a previous run replays its output instead of running. test depends on a transit task rather than on ^build, so tests start at once while still being invalidated when a dependency's source changes. Turborepo was chosen over Nx because it needs one turbo.json and the existing package.json scripts, with no plugins, generators, or project files per package; Nx's extra capabilities (code generators, module-boundary lint) are covered here by the scaffold and the graph checker.

Command (from clients/)What it runs
pnpm check:clientsturbo run check: tsc -b in every package
pnpm test:clientsthe script tests, then turbo run test
pnpm build:clientsturbo run build
pnpm turbo run test --filter @vibesys/<package>one package, building only what it needs

Adding a package​

cd clients
pnpm new:package <layer> <name> [--runtime neutral|dom|node] # pnpm new:package domain runs
pnpm new:package feature <name> # pnpm new:package feature stop-run
pnpm install

tools/scripts/new_package.mjs writes the vibesys block first and derives everything else from it: the exports map with the source condition, one composite tsconfig.<entry>.json per entry with its runtime's lib and types, the test project and solution tsconfig.json, the build, check, and test scripts, a passing smoke test per entry, and the entries in tools/tsconfig.architecture.json that dependency-cruiser resolves. Knip derives its policy from the exports, and Biome and Turborepo cover the package by glob. A feature lands in features/<name> as @vibesys/feature-<name> with ./state, ./renderer, and ./main; an app in apps/<name> as @vibesys/app-<name>; a feature's ./state also starts from a control-state machine skeleton, its pure-transition test, and its checkMachine model (see State); any other layer (a library) in libs/<name> as @vibesys/<name> with one . entry (neutral unless --runtime says otherwise). Its test scaffolds one package of each shape and requires the graph checker, the workspace declarations, tsc -b, and the package's own tests to pass, and a Node import in a scaffolded renderer entry to fail compilation.

Add a workspace dependency with pnpm --filter <package> add @vibesys/<dependency>@workspace:* and a matching project reference in the entry project that imports it; pnpm check:ts-architecture names a missing or extra one.

Migration status​

PackageLayerStatusItem that creates or moves it
@vibesys/contractsfoundationexists (pilot: generated run-protocol and host-helper types, the one schema walker, error taxonomy, ./host-helper validation, ./testing fixtures)foundation item (#1869)
@vibesys/uifoundationexists: the batch 1 components (., dom; see Components), today's primitives (./legacy) and the design tokens (./tokens, ./tokens.css, ./fonts.css, and the components' ./components.css; see Design tokens and themes); the desktop and web features render the legacy primitivesdomain packages (phase 5 of #1878)
@vibesys/storefoundationexists: Step, Reducer, only, createStore (.) and the fold model-test kit (./testing: checkModel, Seeded, SEEDS); domain-freedomain split (#1949)
@vibesys/run-statecoreexists: the run event fold (.), with ./rounds, ./performance, and ./transcript projections (the run's transcript, and transcriptBySpan, which reads it by the span id the server stamped on each event); depends on contracts onlydomain split (#1949)
@vibesys/core-statecoredeleted: split into store and run-statedomain split (#1949)
@vibesys/transportcoreexists: . and ./testing (the shared FakeClock) are neutral, ./websocket is dombackend-client split (#1910)
@vibesys/transport-nodedomainexists: the Unix-socket ServerClient; desktop's ssh and local-host links join it with the connection managerbackend-client split (#1910)
@vibesys/hostscoreexists: host identity and keys, labels, the checkout and project path rules (HostValidationError), a host's listed runs with their phase text (instancePhaseText), path completion for host path fields (completionStep, moved down from feature-hosts), and what the window shows about a connection; depends on contracts, storedomain split (#1949)
@vibesys/runsdomainexists: run, task, and instance ids, a recent run's status from its host's listing, the exact-resume policy, the stop flow; depends on contracts, hostsdomain split (#1949)
@vibesys/machinecoreexists: actorStore, clockFrom, unprovided (.) and checkMachine (./testing); domain-freestate architecture (#1950)
@vibesys/run-sessiondomainexists: the run fold's wiring (runStep), the session lifecycle (sessionMachine, three parallel regions: start-up, recovery, and the fenced replacement snapshot), its ports, the run connection over a control transport (connectRun), and WebSession, with ./testing (an in-memory control transport and page lifecycle, and sessionWith)domain split (#1949)
@vibesys/entitiesdomaindeleted: split into hosts and runs; task arguments (shellWords) moved into @vibesys/feature-start-rundomain split (#1949)
@vibesys/ipcserviceexists: the run page's bridge (port messages, their validation, the preload contract web's desktop shell reads) and the welcome view's channels, events, and payloads, with each feature's requests (channels, bridge slice, and the parser the main process runs) in welcome/<feature>.ts, and ./testing (random window states for store models); depends on hosts, runsdomain packages (phase 5 of #1878); per-feature parsers with phase 8
@vibesys/connectionserviceexists (., node): the connection manager (ConnectionManager, HostConnection: an XState machine in state/ and a shell/ owning the links, channels, and the machine's clock), the ssh and local links, the bootstrap, and connectionStatus, with ./testing (FakeHelper, FakeLink, and the HostConnection contract suite); depends on contracts, transport, hostsdesktop main process (phase 8 of #1878)
@vibesys/settingsserviceexists (., node): the settings store (hosts.json, recent.json, preferences.json with the appearance, notifications and archived runs; one writer, atomic replace), with ./testing (FakeSettingsFilesystem); depends on hosts, runsdesktop main process (phase 8 of #1878)
@vibesys/feature-run-view, @vibesys/feature-gateway-connect, @vibesys/feature-replayfeatureexist: the run page's transcript, rounds, status, and banners (./state, ./renderer; the live session is @vibesys/run-session); the browser's gateway connect form (./renderer); the recorded-run load (./state, ./renderer)feature packages (phase 6 of #1878)
@vibesys/feature-web-themefeatureexists: the exhaustive Web palette and its runtime names (checked against the generated theme union), plus the XState lifecycle for resolving the current live host's default; the app injects its session operation and owns DOM application and persistenceWeb theme picker (#2168)
@vibesys/feature-hosts, @vibesys/feature-start-run, @vibesys/feature-run-control, @vibesys/feature-recent-runsfeatureexist, renderer side (./state, ./renderer): the host panel, checkout form, sign-in, "Connect to host", and path completion; the "Start a run" sheet; the title strip, stop, force stop, resume, and the banner; Recent. Each feature's effects call only the slice of @vibesys/ipc's welcome bridge its ./state declares. feature-hosts and feature-run-control also publish their main-process controllers (./main: Hosts; RunControl with its relay and stop flow)desktop renderer (phase 7 of #1878); ./main with the main-process split (phase 8)
apps/webappexists: composition only, on the per-entry layout (one dom entry, no exports): the browser entry and boot decision, the desktop shell's run page (desktop-main.tsx and the bridge transport), the one backend adapter, storage and lifecycle. Its run connection (connectRun) is @vibesys/run-session's and the run page's styles are the features' own (./styles.css). The desktop run page still moves to apps/desktop-renderer: what remains is the page lifecycle (platform/lifecycle.ts) and the live run page's composition (createLiveApp), which both apps would use, the bridge transport (desktop-shell.ts), and a second Vite page in that app's build, which apps/desktop-main's copy-ui.mjs then copiesfeature packages (phase 6 of #1878)
apps/desktop-rendererappexists: composition only (one dom entry, no exports): the welcome view's entry, its stores wired to the welcome preload's bridge, and the view composed from the desktop featuresdesktop renderer (phase 7 of #1878)
@vibesys/feature-app-shellfeature (strict)exists: the sidebar store (Live, Recent with the last 20 and Show all, Machines, the selected screen) over the hosts' listings, Recent and archived runs, with seeded-sequence invariant tests; the shell layout and placeholder screens (./renderer); the run notices a listing calls for, raised by ./main when Settings enables themredesign (#2842)
@vibesys/feature-settingsfeature (strict)exists: the Settings store (appearance and notifications, saved under tokens) and screen; ./main answers the preference requests and keeps Electron's nativeTheme on the appearance's moderedesign (#2842)
@vibesys/feature-machinesfeature (strict)exists: the Machines store (the saved machines, each one's status a selector over its last report: Ready, Checkout not found, Sign in, or Can't connect; the Add form and the checkout form, whose path suggestions carry each checkout's version), with seeded-sequence invariant tests, and the screen (./renderer); ./main lists the machines from hosts.json, reports a machine's facts from its connection's state and machine.describe, inspects paths (checkout.inspect) and removes a machine. Adding a machine or changing its checkout is feature-hosts' checked setCheckoutredesign (#2843)
@vibesys/feature-new-runfeature (strict)exists: the New run form and its rules (./state: the inputs a run starts with and their key, the extra arguments that may not repeat a typed option, the prefill from the last run on a machine) and its machine (parallel regions loading what the machine offers, the project's tasks and the debounced "Ready to start" checks, with model tests); the screen and its store (./renderer); ./main answers its requests with one host helper method each and records a started run in Recentredesign (#2844)
apps/desktop-shellapp (strict)exists: the redesigned window's composition (shell.html), loaded in the welcome view's place when VIBESYS_DESKTOP_SHELL=next; its Playwright journey runs against a Fake of the welcome bridgeredesign (#2842)
apps/ui-galleryappexists: development only, never shipped (one dom entry, no exports): the component gallery and its screenshot and axe suite (see The gallery and its screenshots)none
apps/desktop-mainappexists: composition only (Electron main, ., node): the lifecycle, page confinement, the window, launch arguments, and the welcome requests routed to the features' ./main controllers; and the two sandboxed preloads (./preload-run, ./preload-welcome, preload). Its build copies the two page bundles into dist/uidesktop main process (phase 8 of #1878)
@vibesys/backend-clientcoredeleted: split into transport and transport-nodebackend-client split (#1910)
@vibesys/desktopappdeleted: emptied into connection, settings, the features' ./main, and apps/desktop-maindesktop main process (phase 8 of #1878)
@vibesys/webappmoved: features into features/*, the composition into apps/webfeature packages (phase 6 of #1878)
@vibesys/tuiappdeprecated; kept building, not migratednone

Roadmap #1878 tracks this migration; each item gets a sub-issue there when it starts.