# Contract: runtime core and lifecycle (P1) Owner: **P1**. Files: `device/lgs.py`, `device/lgs_core.js`, `device/lgs_index.js`, `device/lgs`, `device/glass-shell.desktop`, `device/icon.png`, `device/rt/00-rt.js`. Sources: PLAN §2.3 P1, §1.17, §2.1 (flags, sync safety); SR §3.9, §7; IM §8; LAB.md; NATIVE.md "Runtime". **Status:** see "Status" in `docs/phase2/wp/P1.md`. Everything below is live from M1 unless marked *(planned)*. This file only grows; a behaviour change is listed in §11 with its date. Everything runs in Steam's **SharedJSContext** (devtools 127.0.0.1:8080), in memory only. Nothing is written to disk or to web storage. `lgs off`, a Steam restart or a reboot gives the stock UI. --- ## 1. Writing a runtime module One file per module, `device/rt/NN-name.js`, where `NN` is your theme number (PLAN §2.6). The file only calls `define` at load time. The loader evaluates each file inside a **strict-mode** wrapper function (since R1), so an implicit global throws at load instead of leaking past `lgs off`: ```js // device/rt/41-home.js (C2a) __LGS_RT.define({ name: 'home', // unique; other modules list it in deps deps: ['react', 'attention'], // installed first; if one is missing, off or failed, this module is not installed flag: 'wp.c2a', // optional: installed only while this flag is on install(rt) { // may be async (≤ 5 s); its return value is your API: rt.use('home') const off = rt.windows.track((w) => { // every current and future Steam popup window if (w.kind !== 'main') return; w.html.classList.add('lgs-home'); return () => w.html.classList.remove('lgs-home'); // on window close, removal, or teardown }); rt.listen(someWindow, 'pointermove', onMove, { passive: true }); // undone automatically return { open() { /* … */ } }; }, remove() { // may be async (≤ 3 s); undo what install did that rt.* did not track return { patchedLeft: 0 }; // optional report; P2 reports unrestored fiber patches here }, }); ``` **Rules.** 1. **Load time does nothing.** Top-level code only calls `__LGS_RT.define(...)` (constants and function declarations are fine). No DOM writes, timers, listeners or Steam calls outside `install`. The loader wraps each file in its own function, so top-level `const`/`function` names stay private to the file. 2. **Fail closed.** If `install` throws (or times out), the module is marked `failed`, its `remove()` is called, and every subscription it made through `rt` is undone. The CSS theme and every other module keep working. `remove()` must therefore be safe after a partial install, **and safe to call twice**: an `install` that times out (5 s) keeps running, so when it finally settles the runtime calls `remove()` once more to undo what it did outside `rt`. After a module is removed or failed, its `rt` is **dead**: `rt.windows.*`, `rt.listen`, `rt.setTimeout`/`setInterval`, `rt.on`, `rt.flags.on*`, `rt.bridge.on` and `rt.expose` do nothing and return a no-op `off()`, `rt.cleanup(fn)` runs `fn` at once, and `rt.action` resolves `{ok: false, error: 'removed'}`, so a late `await` continuation can no longer leak (review R1 M2). 3. **Clean removal.** After `remove()` nothing of yours may remain: no `lgs-*` class, no `data-lgs-*` attribute, no node, listener, timer, patched function or global (RT-4, G-REMOVE). Prefer the scoped helpers in §3: everything made through them is undone for you, in reverse order, even if `remove()` forgets. 4. **Globals.** Avoid them; return an API from `install` instead (`rt.use(name)`). A new global whose name starts with `lgs`/`__LGS` that your file or install creates is deleted by the runtime on teardown. 5. **Names.** Module names are short and unique (`react`, `input`, `attention`, `home`, `more`). Two files defining the same name: the second is marked failed (`lgs check` reports it offline). 6. **No web storage, no disk, no network.** Never `localStorage`, `sessionStorage`, IndexedDB or cookies. 7. **Work in progress** goes in `device/rt/_wip/`: the loader and `lgs check` skip `_wip/` folders and every file or folder whose name starts with `_` or `.`. **Load order.** `00-rt.js` (the core) first, then `device/shared/*.js`, then `device/rt/*.js` in name order. Install order is dependency order (ties keep name order). Removal is the exact reverse of install order. **Shared files** (`device/shared/*.js`, e.g. P5's `motion.js`) are plain scripts that may also run outside Steam (P8 prepends `motion.js` to `lgs_sg.js`). The loader evaluates them with `module` and `exports` in scope; whatever they put on `module.exports` (or, failing that, the one `__LGS*` global they define) is `rt.shared.`, e.g. `rt.shared.motion`. A standalone-safe pattern: ```js (function (root) { const api = { sample, settle }; if (typeof module === 'object' && module && module.exports) module.exports = api; else root.__LGS_MOTION = api; })(this); ``` --- ## 2. Module states (`lgs status`, `rt.status()`) | State | Meaning | |---|---| | `defined` | Loaded, not processed yet (only before `start()`) | | `installing` | `install` running | | `installed` | Live | | `off` | Its `flag` is off. It installs by itself when the flag turns on, and is removed when it turns off | | `blocked` | A dependency is missing, off or failed (`reason` says which) | | `failed` | Its file failed to load or parse, `install` threw or timed out, or its name is a duplicate (`error` says why). Stays failed until the next `lgs on`, **except a transient failure**: an error with `transient === true` (on the error or on its `cause` chain), e.g. P2's `ready()` while Steam's UI is still mounting, is retried after its `retryAfterMs` (at least 1 s), at most 4 times, each attempt logged (`reason: 'transient: retry n/4 in … ms'`, `retries` in `status()`); never after teardown, never after a timeout (since R1, REQ P2->P1) | | `removing` / `removed` | During and after teardown | --- ## 3. `rt`: the object `install(rt)` receives `rt` is your module's scoped view of `window.__LGS_RT`. Members marked **(tracked)** are undone automatically when your module is removed. ### 3.1 Registry | Member | Does | |---|---| | `rt.name` | Your module's name | | `rt.use(name)` | The API another module's `install` returned. Throws if that module is not installed. List it in `deps` | | `rt.has(name)` | `true` if that module is installed (for optional integrations, without `deps`) | | `rt.module(name)` | `{name, state, reason, error}` or `null` | | `rt.shared` | `{motion: …}` from `device/shared/*.js` | | `rt.data(name)`, `rt.data.` | JSON fragments the loader passes in. `rt.data('popups')`: every `theme/popups/*.json` in name order (`_wip` skipped), each object with `"file": ".json"` added (P6's popup wrapper). `rt.data(name)` returns a copy. A file with invalid JSON is left out and listed in `failedLoads` | | `rt.log(msg, data)`, `rt.warn(…)`, `rt.error(…)` | Into the runtime's log ring (300 entries, `rt.status({log: 40})`), tagged with your module name | | `rt.sel('%{Token} .x')` | Resolves `%{Token}` classes with the theme's class index (same syntax as theme CSS). Throws on an unresolved token. The index is `window.__LGS_INDEX`: read it at call time, never keep a copy (it is rebuilt when Steam registers new webpack chunks) | | `rt.W` | SharedJSContext's `window` | | `rt.version` | Runtime core version (1) | ### 3.2 Lifecycle helpers (tracked) | Member | Does | |---|---| | `rt.cleanup(fn)` | Runs `fn` on removal. Returns `off()` that runs it now | | `rt.listen(target, type, fn, opts)` | `addEventListener`, removed on removal. Returns `off()` | | `rt.setTimeout(fn, ms)`, `rt.setInterval(fn, ms)` | Cleared on removal. Return `off()`. A timer that fired or was cancelled is dropped from the module's tracked list at once (so is any tracked `off()` the module runs itself): `status().modules[].subscriptions` counts live subscriptions only | | `rt.on(event, fn)` | Runtime events: `'flags'` (changed names, values), `'module'` (name, state), `'teardown'` (reason), `'test:input'` (mode), `'test:actions'` (on) | ### 3.3 Windows: `rt.windows` (tracked) One popup-created and one popup-destroyed callback on `g_PopupManager` serve every module (so subscriber counts stay +1 however many modules there are). A new popup is reported once its `document.body` exists (polled every 50 ms, ≤ 5 s). A 2 s reconcile also catches any window a callback missed. An **entry** is `{win, doc, html, key, kind, name, popup, visible()}`: - `key`: the SteamVR overlay key, e.g. `valve.steam.gamepadui.barpopup.70880001`; - `kind`: the key without the `valve.steam.gamepadui.` prefix and numeric suffixes: `main`, `bar`, `barpopup`, `frame.menu`, `floatingfooter`, `tooltip`, `volumelevel`, `keyboard`, `notifications`, … (the LAB.md surface names); - `name`: Steam's popup name (`VR_uid0`, …); `popup`: Steam's popup object; - `visible()`: `true` while the window's document is visible (`visibilityState`). **Pooled popups.** Steam creates most popups once and then only shows and hides them: opening the "+" list shows the existing `barpopup` window, it creates none (probe 2026-10-07, RT-3). So "a popup opened" is `onShow`, not `onAdd`; `onAdd` fires for windows that are really created (new frame menus, a new desktop window). `track()` covers both: it sees every window, shown or hidden. | Member | Does | |---|---| | `rt.windows.track(fn)` | `fn(entry)` for every current and future window; `fn` may return a cleanup, called when that window closes or the subscription ends. Returns `off()`. **The usual way to put classes or listeners on every window** | | `rt.windows.onAdd(fn)`, `rt.windows.onRemove(fn)` | Future windows only (created / closed). Return `off()` | | `rt.windows.onShow(fn)`, `rt.windows.onHide(fn)` | `fn(entry)` when a known window's document becomes visible / hidden: a pooled popup opening or closing (the "+" list, bar menus, tooltip, frame menus). Return `off()` | | `rt.windows.each(fn)` | Current windows, once | | `rt.windows.list()`, `.main()`, `.byKind(kind)`, `.find(win)` | Lookups | ### 3.4 Flags: `rt.flags` | Member | Does | |---|---| | `rt.flags.get(name)` | Effective value (`undefined` if nobody set it) | | `rt.flags.enabled(name)` | Truthiness: `false`, `0`, `''`, `null`, `'off'`, `'false'`, `'no'`, `'0'`, `'none'` are off | | `rt.flags.all()`, `rt.flags.sources()` | Every effective value, and the layer each comes from | | `rt.flags.on(name, fn)` (tracked) | `fn(value, previous, name)` when that flag changes | | `rt.flags.onAny(fn)` (tracked) | `fn(changedNames, allValues)` | A module with `flag` needs no code for its own gate: the runtime installs and removes it as the flag changes. ### 3.5 Daemon bridge: `rt.bridge` (P8 → page) | Member | Does | |---|---| | `rt.bridge.set(key, value)` | Stores a JSON-able value and notifies subscribers **on every call**, also with an equal value (contracts/daemon.md §3: a repeated action reply must arrive). Returns `true` when the value changed. P8 calls it over CDP: `window.__LGS_RT && __LGS_RT.bridge.set('geom', {...})` | | `rt.bridge.get(key)` | Latest value or `undefined` | | `rt.bridge.age(key)` | ms since the last `set` (changed or not), `Infinity` if never | | `rt.bridge.on(key, fn)` (tracked) | `fn(value, previous, key, changed)` on every `set`; skip the call when `changed` is `false` if you only want changes | | `rt.bridge.keys()` | Keys set so far | Keys in use (the publisher defines the value shape in its contract, contracts/daemon.md §3): `daemon` (P8 heartbeat every 2 s), `geom` (P8: S, r, Hm, dashboard distance, frame-menu `fHeight` and clip height), `page` (P8: `activePage`, CC15), `acks` (P8), `reply` (P8: action replies, read through `rt.action`). New keys: name them in your contract. The bridge is reset by every `lgs on`; the daemon sees `get(name) === undefined` and re-sends every value within 1 s. ### 3.5a Daemon actions: `rt.action` | Member | Does | |---|---| | `await rt.action(type, args, {src, timeoutMs})` | Calls the daemon (contracts/daemon.md §5): `window.lgsAction(JSON.stringify({id, type, args, src}))`, then resolves with the bridge `reply` of the same `id`: `{id, ok: true, result, dry?}` or `{id, ok: false, error, detail?}`. **Never rejects.** `src`: the Steam window kind the call is made for (default `'main'`). `id` is `:`. Errors added by the runtime: `no-daemon` (no `lgsAction` binding: CSS-only without a daemon), `timeout` (default 2 s without a fresh daemon heartbeat, 12 s with one), `call-failed`, `teardown` (pending calls when the runtime is removed) | `nav` and `launch` actions are not run while the action logger is on: the daemon reads `rt.test.actions.enabled()` and records them there as `daemon.` (never-list). ### 3.5b Public members: `rt.expose` (tracked) | Member | Does | |---|---| | `rt.expose(name, value)` | Puts `value` on the public runtime object as `__LGS_RT[name]` (and so on every module's `rt` and for the lab), for the one-accessor APIs of PLAN §1.4 (e.g. P3's `rt.input`). Deleted when your module is removed. Throws if `name` is a core member or another module's. Returns `off()` | `rt.status().exposed` lists `{name: module}`. A member set by plain assignment (`__LGS_RT.input = api`) also works, but is only deleted at teardown, not when your module's flag turns off: prefer `rt.expose`. ### 3.6 Test hooks: `rt.test` (lab and acceptance tests only) Every hook expires on its own after its TTL (default **300 s**), so a crashed lab step never leaves one on. Teardown clears them all. | Member | Does | |---|---| | `rt.test.flags.push(obj, {ttlMs})` | Adds a flag overlay above every other layer; returns a token. Modules gated by those flags install or remove at once (await `rt.settled()` to wait). P10's `--flags a,b` uses this | | `rt.test.flags.pop(token)` | Removes that overlay; `true` if it existed | | `await rt.test.flags.with(obj, async fn, {ttlMs})` | push, wait for installs, run `fn(rt)`, pop and wait for removals, in `finally` | | `rt.test.flags.list()` | Active overlays with their remaining TTL | | `rt.test.input.set('pad' \| 'laser' \| null, {vrMode, ttlMs})` | Input-mode stub. Calls P3's `rt.input.stub` (contracts/interaction.md) when `input` is installed; changes only our classes, never Steam's getters. A stub set while no module provides `rt.input.stub` is applied as soon as one installs, with the TTL left (R1). Returns `{mode, applied}`. Also emits `'test:input'`. `null` clears it | | `rt.test.input.get()` | The stubbed mode or `null` | | `rt.test.actions.enable(on, {ttlMs})` | Action-logger switch. While on, P2's `actions.*` must call `rt.test.actions.record({fn, arg})` and **not run** (HA §14, RX-7) | | `rt.test.actions.enabled()`, `.record(e)`, `.log()`, `.take()` | Read the switch; append; copy; copy and clear | | `rt.test.reset()` | Everything above off | | `rt.test.state()` | `{input, actions, actionsLogged, flags}` | **Recommendation to P10:** turn `rt.test.actions.enable(true)` on at the start of every locked lab step and off at its exit, so no test can launch anything by accident (never-list). ### 3.7 Status | Member | Returns | |---|---| | `rt.status({flags, log, counts})` | `{runtime: 'loaded'\|'running'\|'stopping'\|'removed', version, build, since, modules: [{name, file, state, flag, deps, reason, error, installMs, subscriptions}], failedLoads: [{file, error}], flagsSet (non-builtin), windows: [kinds, "(hidden)" when not visible], bridge: {key: ageMs}, test, globals, exposed, actionsPending, listeners, idle: {ticks, tickMsTotal, t}}`; with `flags: true` also every flag and its source, `log: n` the last n log entries, `counts: true` Steam subscriber counts for leak checks: `popupCreated`, `popupDestroyed`, `navigationSource` (FocusNavController) and `vrNavigationType` (`vrGamepadInput.RegisterForNavigationTypeChange`, IM §8; since R1) | | `await rt.settled()` | Waits for pending installs/removals, then `status()` | | `rt.counts()` | The Steam subscriber counts alone | --- ## 4. Flags Layers, lowest first. The effective value of a flag comes from the highest layer that sets it. | Layer | Where | Who writes it | |---|---|---| | `builtin` | `BUILTIN_FLAGS` in `device/lgs.py` (table below) | P1 | | `defaults` | `device/defaults.json`: either a flat object, or `{"flags": {...}}`; keys starting with `_` or `$` are comments | V1 only | | `session` | `/tmp/lgs/flags.json` (RAM): a flat object | `lgs flags name=value`; P10's `--flags` writes it for one step and restores it. CLI `lgs off` deletes it | | `cli` | `lgs on --flags a,b=v` | That one `on`; the next `on`/reload drops it | | `test` | `rt.test.flags.push` overlays | Lab steps (TTL 300 s) | **Built-in defaults** (PLAN §1.17; "gated" flags stay off until V1 enables them in `defaults.json`): | Flag | Default | § | |---|---|---| | `rt` | `true` | Runtime master switch: `false` = CSS theme only | | `native` | `"auto"` | S1: `auto` \| `on` \| `off` | | `interactivePops` | `false` | S2 | | `sheetRecede` | `false` | S7 | | `frameHeight` | `false` | S8 | | `tabBarAlways` | `false` | S9 (gated) | | `winbarMove` | `false` | S10 (gated) | | `vrBindings` | `false` | S13 | | `ccPopup` | `false` | S15 | | `pointerProxy` | `"native"` | S16: `native` \| `on` \| `off` | | `haptics` | `false` | S17 | | `hudPlacement` | `false` | S21 | | `storeOrnament` | `false` | S22 (gated) | | `settingsDrill` | `false` | S23 (gated) | | `wp.` | unset (off) | Each package's runtime gate (PLAN §2.1) | | `rt.stubs`, `rt.stubThrow`, `rt.stubSlow`, `rt.stubTransient` | `false` | P1's own test stubs (§9) | | `react` | `true` | P2's kill switch for T3 (contracts/react.md §1) | | `reactLab` | `false` | P2's lab route, tests only (react.md §9) | | `actionsLive` | `false` | P2: Steam actions run only when `true` (react.md; V1 sets it at release) | | `actionsDryRun` | `false` | P8: `nav`/`launch` actions logged, not run (daemon.md §2) | | `sgDepthAnim` | `true` | P8/P7: depth motion kill switch (daemon.md §2) | | `shellThemeGraceS` | `600` | P8: seconds the unit stays dormant while the theme is off without `lgs off` (daemon.md §2) | A package that wants a built-in default for its own flag files a REQ to P1 (or states it in its contract; P1 copies it here). **Python readers** (the daemon, lab): `import lgs; lgs.flags_effective()` returns builtin + defaults + session merged (no test overlays: those live in the page; read them with `__LGS_RT.flags.all()` over CDP). `lgs.flag_layers()` returns the layers separately. **CLI.** ``` lgs flags # effective flags, each with its layer; plus the live runtime view lgs flags wp.c2a=1 native=on # session layer (/tmp/lgs/flags.json), applied live at once lgs flags wp.c2a= # unset one lgs flags --reset # delete /tmp/lgs/flags.json, applied live lgs on --flags wp.c2a,rt.stubs # cli layer, this "on" only ``` Values: `1/true/on/yes` and `0/false/off/no` are booleans (except for string-valued flags such as `native`, which keep the text), then JSON (`2`, `"x"`), else the text. --- ## 5. Lifecycle ### `lgs on` (also the lab's `--theme on`, `lgs reload`, `lgs dial`) **The CLI waits for a locked lab step** (since R1): `lgs on`, `off`, `toggle`, `reload` and `dial` from the command line (and so `glass.py on|off|reload`, and the "+ > Liquid Glass" launcher) first take `/tmp/lgs/lab.lock` when that file exists, waiting at most **90 s** for another agent's step to end (then they go on without it and log it; `LGS_NO_LAB_LOCK=1` skips the wait). A reload no longer replaces the runtime in the middle of a locked step (PLAN §7.1). `op()` itself takes no lock: lab steps and the daemon call it inside their own locks. 0. **Readiness (since R1, review M1).** Before anything is torn down or injected, `lgs on` waits until Steam's UI is up and the class index is plausible: `SteamUIStore`, `g_PopupManager`, the main window (with a body) and a live webpack runtime (`webpackChunksteamui.push` is not `Array.prototype.push`); then `lgsIndexShared()` gives an index with at least **200** CSS modules (healthy: about 550) and at most half of the theme's distinct tokens unresolved. It retries with backoff **2, 4, 8 s, then every 15 s**, up to **120 s** (`READY_WAIT_S`; `op(..., wait=)`), reconnecting each time (a SharedJSContext that reloads is followed), and writes each attempt to `/tmp/lgs/lgs.log` (`index: attempt N: waiting …`). If it gives up, **nothing changes** (the CSS and runtime stay as they were) and the result says `index: {state: 'gave-up', missing, size, attempts, waitedS}`, `runtime: {runtime: 'unchanged'}`. If only the unresolved share is off at the end (a theme older than the Steam build), it injects anyway (Phase 1 behaviour) with `index.state: 'gave-up'`. **The class index** (`device/lgs_index.js`). The harvest requires only "pure" factories, whose whole body is `e => { e.exports = {…} }` (every CSS module; never a factory with side effects, which Steam's `require` would cache half-built if it ran early). It reports `{ok, size, factories, pure, bundle, minModules, why, builtAt, ms, current()}`; `current()` is false once Steam registers new factories (a lazily loaded chunk), and the next `on` rebuilds. `lgsIndexShared({minModules})` is the one way to use the shared cache `window.__LGS_INDEX`: it reuses it while `ok` and `current()`, caches a new one only when `ok`, and drops a cached one that is not (a 3-module index like the one of 06:50 is replaced by the next `on`, and also by `off` + `on`). `lgsWebpackRequire()` gets Steam's `require` and splices its probe record out of the chunk array again. Other evaluations that include `lgs_index.js` (the lab helpers, P6's reporter) should call `lgsIndexShared()` instead of caching `lgsBuildIndex()` themselves (REQs P1->P10, P1->P6). Then one devtools session to SharedJSContext: 1. `await __LGS_RT.teardown('reload')` if a runtime is there (modules removed in reverse order). 2. The CSS core (`lgs_core.js`): one `