Repository navigation
Writing a Mod
A mod is a folder of JavaScript (plus optional CSS and an in-app help page)
that extends the Browserland desktop: taskbar chips, app-window kinds, Control
Panel settings, per-terminal title-bar widgets, menu items, key actions. A set
of them ships in the repo, under webterm/broker/mods/ (the README lists them),
and a broker can also be handed one at runtime through POST /mods/install.
This document is the authoring contract. It is derived from
webterm/broker/86_js_mod_loader.js and its 86a–86p companion fragments,
ui.py, modinstall.py and help_corpus.py — when
this file and those disagree, they are right and this is stale; please fix
it.
A mod is same-origin code running inside the desktop page, with the page's
full authority. It is not isolated, not sandboxed, and not confined by
anything in the ctx object it is handed.
Concretely, a mod can:
- read the browser's auth token, and every configured remote host's token;
-
fetch/file/write,/file/delete,/session/kill,/state,/mcp/*and every other route directly, exactly as core does; - reach into
prefs,windows, the layout blob and every other core global, because they are ordinary bindings in the same script scope; - rewrite
window.__modsitself, including the loader functions that are supposed to police it.
So ctx grants no privilege a mod does not already have. It is a set of
reviewed choke points, not a permission boundary. Its purpose is that
"don't merge a mod that reads ~/.ssh" stays a decision a reviewer can make
from one call site instead of auditing every line. The loader says this itself,
in the fragment header and again on ctx.file, ctx.serverStore,
ctx.session and ctx.clipboard.
The consequence for the runtime-install feature is the whole of its trust
model: the trust event is the operator's token-gated install, not the fetch
that follows. POST /mods/install requires the browser auth token; the
/mods/<id>/<gen>/<file> route the page then loads from is public, and has to
be, because a <script src> cannot carry an Authorization header. Installing
a mod is choosing to run somebody's code with this broker's full authority.
Review it the way you would review a patch to the broker itself.
Two corollaries that people get wrong:
-
tiersis declared, not enforced — see §5. - Nothing in this document is a security guarantee. The caps, the filename
grammar and the CSS-egress check in the install validator are hygiene and
defence-in-depth; a mod's own JS can
fetch()anything it likes once it runs.
| Shipped | Installed | |
|---|---|---|
| Lives in |
webterm/broker/mods/<id>/ (in the repo) |
<mods_dir>/<id>/<gen>/ on the broker (config mods_dir, default <state dir>/webterm_mods) |
| Reaches the page by | being spliced into the one inline <script> at import (ui._MODS) |
a separate classic <script src="/mods/<id>/<gen>/<file>.js"> injected at boot |
| Id | must not start with x-
|
must start with x-
|
| Load list |
ui._MODS, an explicit list |
/info's mods catalog, from the broker's in-memory index |
| Manifest names its scripts via | nothing (entry is inert; _MODS decides) |
scripts, which is required
|
| Default enabled | whatever defaultEnabled says |
always false, whatever the manifest says |
| Changing it needs | a broker restart (the page is assembled at import) | a page reload (never a process restart) |
| Its source is | public (it is in GET /) |
public (GET /mods/<id>/<gen>/<file>) |
Everything in §3–§10 applies to both. §11 is the extra contract an installed mod has to meet.
mods/clock/clock.js is the reference mod; this is its shape:
registerMod({
id: 'clock',
version: '1.0.0',
ctxVersion: 1,
tiers: ['taskbar', 'settings'],
init: function (ctx) {
const chip = document.createElement('div');
chip.id = 'clock-chip';
ctx.taskbar.addStatusItem(chip); // removed automatically on teardown
let timer = setInterval(function () {
chip.textContent = new Date().toLocaleTimeString();
}, 1000);
ctx.onUnload(function () { clearInterval(timer); });
},
});The rules that shape it:
-
Top level does nothing but call
registerMod({…}). Registration is bookkeeping; all work happens ininit(ctx). -
initis synchronous by contract.initModcalls it and ignores any returned promise. Kick async work off inside it, but everything another mod or core might observe (a registered window kind, a settings control) must exist by the time it returns. -
Everything
initdoes must be reversible. A mod can be disabled live from Control Panel → Mods, and its teardown has to leave no timers, listeners or DOM behind.ctx.onUnload(fn)registers a teardown; they run LIFO. Mostctxprimitives auto-register their own teardown (the taskbar item above removes itself), so you only needonUnloadfor what you created yourself. -
A throw in
initdisables only that mod.initModcatches it, runs the partial teardown, releases the slot and logs; core and every other mod keep running. That isolation is why the "make it throw" bugs in §11 are so easy to ship without noticing.
registerMod itself throws — synchronously, as your script executes — on a
malformed declaration (no object, no string id, no function init) or a
duplicate id. Fields it normalizes: version (non-string → '0'),
ctxVersion (non-number → null, meaning "pin nothing"), tiers and
requires (non-arrays → [], non-string members dropped, and empty strings
too for requires), defaultEnabled
(anything but literal false → true).
Every shipped mod has one. It is read by Python — ui._mod_css,
ui.mod_catalog, help_corpus.build_mod_sections — and never by the loader.
The registerMod({…}) call is read by the loader and never by Python. Some
fields therefore exist in both places, and a test
(test_mod_catalog_matches_the_registerMod_declarations) keeps the copies
honest in both directions.
Every field at once — no real manifest uses all of them:
Field by field:
-
id— used byui.mod_catalog()(falling back to the directory name if absent or non-string) and byhelp_corpusas the section's mod tag. It must match theidinregisterModor the catalog and the loader disagree. -
version,title,description— catalog text.titlefalls back to the id. These are what another broker's Control Panel shows when it edits this broker's mod pins, so writedescriptionfor an operator, not for yourself. -
ctxVersion— informational inmod.json. The install validator does check an installed mod's copy: absent, it is stored as1, and a value that is not an integer is refused (bad_manifest_field, "ctxVersion must be an int"). Neither the shipped nor the installed copy is what the loader checks. The one that acts is the number inregisterMod: a mismatch with the loader'swindow.__mods.ctxVersionmakesinitModrefuse the mod outright, and omitting it means "pin nothing". The contract is at1, and every capability added toctxsince has been additive — so feature-detect (if (ctx.theme)) rather than declaring a higher number. Within 1.x,ctxVersionstays 1, with additions only (Upgrading#what-10-promises). -
defaultEnabled—falseships the mod off until the operator opts in. Absent means on. Mirrored inregisterMod. For an installed mod this field is accepted and then ignored: the broker always reportsdefault_enabled: false. -
requires— see §6. -
styles— the only field that changes the served bytes. Each entry must be a bare<file>.cssname (ui._is_bare_cssrejects/,\,.., absolute paths, nested dirs and non-.css), must exist, must be valid UTF-8 with no BOM, must end in a newline, and must be ≤ui._MAX_LINES(2500) lines. Any failure is logged and the stylesheet is silently dropped from the page — the strict version of the same check lives intests/test_ui_assets.pyand fails CI. -
permissions— a closed-vocabulary declaration of whichctxcapability families this package's source actually uses. Every shippedmod.jsoncarries one, and it is read twice: the broker joins it onto the shipped rows of the/infocatalog, so Control Panel → Mods can show it, andtests/test_ui_assets.pysweepsmods/**to keep every declaration truthful and inside the vocabulary. What the shipped copy does not get is the install-time check: only the installed-mod door (modinstall.validate_package, run by bothPOST /mods/installand themods_dirscanner) lints a declaration against the source at the moment it is stored, which is why the pane words the two cases differently. The full rules — the vocabulary, the refusal, the absent-vs-[]distinction — are in §11.5. -
help—slug/label/order/iconfor the in-app Help section (§10). All optional; slug defaults to the mod id, label totitle, order to a computed value after the wiki pages. An installed mod may sendslug, but the validator drops it: an installed mod's section slug is always its id. -
entry— inert. Every shipped manifest declares it, and the test suite pins its value for most of them, but no runtime code reads it. What actually loads isui._MODS, an explicit list ofmods/<id>/<file>.jspaths. Editingentrychanges nothing. (Theeditormod ships two scripts,codemirror.jsandeditor.js, which is exactly the case a singleentrycannot express.) The installed-mod manifest replaces it with a requiredscriptslist, and the install validator rejectsentryas an unknown key. -
author— inert. Every shipped manifest declares it; nothing reads it, nothing asserts it, and it is not part of the id namespace. Second-level scoping for an installed mod is a convention baked into the id (x-<author>-<name>), not this field — see §11. - Anything else — ignored by the shipped-mod readers, and rejected by the
install validator (
unknown_manifest_key), so a typo is loud rather than silent.
Note what is not in a shipped mod.json: tiers. For a shipped mod it
lives only in the registerMod call, and ui.mod_catalog() deliberately does
not report it. An installed mod's manifest may carry tiers (§11.5's example
does): the validator accepts at most 8 non-empty strings of up to 32
characters each, and the token-gated GET /mods/installed reports them (the
/info catalog does not). As everywhere, they are declared, not enforced
(§5).
Two similarly-shaped declarations answer two different questions. tiers says
what a mod claims to touch, and nothing checks it. needs says what ctx
surface a mod cannot run without, and the loader refuses to init it if
that surface is missing. Read them together — they are opposites, not
variants of the same idea.
tiers: ['file', 'window'],tiers is a self-declared list of the ctx capability families a mod uses.
The Mods pane renders them as badges next to the enable checkbox so an operator
can see what a mod touches before switching it on; a mod that declares none
renders as unspecified.
Nothing enforces it. The loader filters out non-strings and stores the rest. A
mod that declares tiers: [] and then calls ctx.file.delete() is not stopped,
warned or logged — because, per §1, it could have called /file/delete directly
anyway. A tier declaration is a claim a reviewer checks, not a capability the
runtime grants.
The vocabulary in use across the shipped set is settings, taskbar, window,
file, session, storage, clipboard. It is open — no list is validated —
and it is deliberately imprecise at the edges: mods/mod-sync/mod-sync.js
declares tiers: ['settings'] while administering other brokers' mod pins,
because the vocabulary has no token for that and inventing one would imply an
enforcement that does not exist.
needs: ['file', 'windows.onTerminalCreate'],needs is an array of ctx-surface names this mod cannot function without — a
bare member name ('file') or a dotted path into one ('windows.onTerminalCreate').
Unlike tiers, it is checked: once initMod starts building this mod's ctx,
the loader resolves every entry against that live object — own properties
only at each segment, so a mod never "has" toString or constructor just
because the prototype does, and a member that throws on access counts as
absent too. An unmet entry blocks init outright: nothing partially
initializes, the claimed slot is released, and the Mods pane row reads
blocked (needs windows.onTerminalCreate) — naming every unmet entry —
instead of showing the mod as active while it quietly does nothing, which is
the failure mode mods/git/git.js guards against today with a plain
if (!ctx.windows) return; rather than a declared, pane-visible reason.
That label is a different thing from a requires-blocked row's needs: editor
text (§6) even though both use the English word "needs": requires names
other mod ids and is checked first, before a ctx is even built; needs
names ctx surface and is checked once init is actually about to run. A
mod that fails both is reported on the requires refusal, since that is the
one the loader reaches first.
A few things worth being precise about:
-
A pin does not override it. A pin is policy — should this mod be on;
needsis a fact about what this build'sctxoffers. A pinned-on mod with an unmet need still readsblocked (needs …); pinning it cannot conjure the missing surface. -
An older loader, or a page built without the ctx-extender registry,
silently drops the field.
needsis normalized the same waytiersandrequiresare, by a companion fragment (86c_js_mod_ctx_ext.js) that a given page may not carry; where it is absent, the gate simply never runs and the mod inits exactly as it would have beforeneedsexisted — absence is never an error (#157). Keep your owntypeof ctx.xguard as the second line of defence regardless;needsmakes the degradation visible, it does not replace it. -
needsis notrequiresand notpermissions.requirescascades through pins and take-downs (§6);permissionsis an install-time, server-side review lint over source text (§11.5).needsis client-side, checked once at init against the livectx— it asks what a mod can run against here, not what other mods must be active or what a reviewer permits. -
ctx.capabilities(§8) is the companion read — a mod can inspect what a build offers instead of only declaring what it requires.
requires: ['editor'],Each listed id must be an active mod for this one to init. If any is
missing, initMod returns {ok:false, reason:'requires', missing:[…]} — the
mod is cleanly blocked, with no slot claimed and no partial init, and the Mods
pane shows needs: editor. Enabling the dependency later brings the dependent
up through the same cascade (_bringUp); disabling a dependency tears its
dependents down first (_takeDown).
Order is resolved at runtime. _topoSortRegistered() (in
86b_js_mod_packages.js) runs at boot, after the installed packages have
loaded and before pins are resolved, and reorders window.__mods.registered
in place so a dependency always precedes its dependents. That in-place
reorder is what lets _bringUp, _takeDown, _applyPolicyLive and
_resolvePins keep their single forward/backward passes.
- Kahn's algorithm gives the order, with ties broken by the current registration index — so when nothing installed participates the sort is the identity permutation and the shipped order is preserved exactly.
-
Kahn's residual is not the cycle set. For
A→B, B→A, C→Athe residual is{A,B,C}, butCis merely blocked by a cycle. So the residual is split with Tarjan: an SCC of size > 1 (or a self-loop) iscycle, everything else in the residual isblocked-by-cycle.initModrefuses acyclemod with its own{ok:false, reason:'cycle'}rather than letting it read as an ordinary dependency block. - The graph is built over shipped ∪ installed ids. An edge to an id in
neither is dropped from the sort and recorded in
window.__mods.missingRequires— it must not contribute an indegree, or an installed mod requiring a shipped mod would come out marked cyclic. An edge to an id that the broker knows but this page did not register (its package 404'd, or it is off) is not an edge and not "missing" either; the row readsblocked. - The broker does the same analysis over the installed half, in
modinstall.catalog()(_kahn+_cycle_members), and stamps each row witherror: null | "requires_cycle" | "blocked_by_cycle". The client's verdict wins where they differ, because the client sees the whole graph and the broker only sorts the installed half.
For shipped mods the positional rule survives as a style rule.
test_requires_declared_before_dependency_in_mods_list still asserts that every
requires names a known mod appearing strictly earlier in ui._MODS. It used
to be the only thing standing between the project and a cycle; now it is there
because it keeps the shipped half of the graph readable top-to-bottom. It still
enforces all three of: the positional order, that the dependency exists, and
that a shipped requires never names an x- id (so shipped→installed edges
are unrepresentable, which is what lets /info emit every shipped row first).
requires is rare in the shipped set, and where it appears it is a mod reusing
the editor mod's window kind and its single shared CodeMirror build.
There is no index.html in this repo. ui.py assembles the served page at
import time from the ordered fragments beside it, in five segments:
core fragments up to 15_css_dialogs.css
+ every mod stylesheet declared in a mod.json `styles`
+ the rest of core up to 90_js_mod_boot.js
+ every path in _MODS, in order
+ 90_js_mod_boot.js and the tail
joined with the empty string (every fragment already ends in its own
newline), and the result is ui.INDEX_HTML, captured once onto
app.ctx.index_html.
Consequences you will hit:
-
A fragment or mod-script edit needs a broker restart. The page is built at
import and cached; nothing re-reads the tree per request. Editing
mods/clock/clock.jsand reloading the browser shows you the old bytes. This is the single most common way to waste an hour. (curlthe served page and grep it if you are unsure what is actually being served.) -
Every mod script lands in ONE
<script>, sharing one scope with core and with every other mod. Top-levelconst/let/functionnames are global. Aconst/let/classcollision is a compile error for the whole bundle — a blank desktop, not one broken mod — and a duplicatefunction/varis worse, because it silently overwrites. Prefix everything you declare at top level. -
A literal
</script>anywhere in a mod script terminates the element and breaks the page. Split it ('<' + '/script>'). -
_MODSis an explicit list, not a glob, and the drift guard (test_mod_scripts_exist_on_disk_and_match_mods_dir) is bidirectional: every path in_MODSmust exist, and every*.jsundermods/must be declared. A new mod file that is not in_MODSfails CI; so does a stray scratch copy left in a mod directory. - The CSP is
script-src 'sha256-<hash of the inline script>' 'self'; frame-ancestors 'none'(a broader policy from #190 is also sent Report-Only while it soaks; it is not enforced). The hash is computed from the very bytes served, so adding a mod moves it automatically — but it also means the page must have exactly one inline<script>, andui.inline_script_hashraises when the app is built if that stops being true. - A mod's CSS is spliced in regardless of whether the mod is enabled. It is present-but-inert: its selectors match nothing until the mod's JS adds the markup. Same posture as the spliced-but-not-initialized JS.
-
A shipped mod can never meet a loader other than the one it shipped beside.
ui.assemble()puts every_MODSpath into the same string as86_js_mod_loader.jsand its86*companions, and that one string is what the broker serves. There is no path by which an in-tree mod script is delivered next to some other build's loader, so for a shipped mod "does this build'sctxhave X" is settled at the commit rather than at runtime — which is what makes a feature-detect fallback in an in-tree mod dead code for anything already inctxat that commit (seectx.visibility, §8). An installed package is the opposite case (§11.2): it is fetched as its own<script src>by whichever broker it was installed on, and that broker may be older than the package, so a portable mod keeps its detection or declaresneeds(§5).
Adding a shipped mod is therefore: create mods/<id>/, write mod.json and
<id>.js, append the script path to ui._MODS after any mod it requires,
and restart the broker.
makeCtx(modId, rec) builds a fresh object per mod. Everything below is
additive to v1 — ctxVersion stays 1, so feature-detect rather than
bumping it.
-
ctx.id,ctx.ctxVersion— this mod's id, and the loader's contract version. -
ctx.onUnload(fn)— register a teardown. LIFO, each isolated in its owntry. Register it before anything that can throw (§11). -
ctx.visibility—pausableInterval(fn, ms)→ a{stop}handle, andonVisibility(fn)→ an unsubscribe. The interval slows to 10×mswhile the tab is hidden and coalesces the runs it skipped into exactly one catch-up when the tab comes back (including after a bfcache restore, where no tick fired at all). Both fail closed once the mod is tearing down, and both register their own teardown, so neither can outlive a disable.For a shipped mod this is a floor, not a maybe. It is a plain member of the object
makeCtxbuilds — not one an extender adds afterwards — so if yourinit(ctx)is running at all,ctx.visibilityis on thatctx; and per §7 an in-tree mod is served beside the very loader that put it there. Actx.visibility ? … : setInterval(…)fallback in a shipped mod is therefore unreachable code. No shipped mod carries such a fallback any more, and none should grow one: they were migrated out under #209 (mods/clock/, the first), #212 (mods/git/), #207 (mods/aistatus/), #221 (mods/task-manager/) and #224 (mods/update/, the last). A new one would be reintroducing dead code that the gate beside this page fails on — which is a migration of its own, not something to copy from.The floor stops at the repo. An installed package is loaded by whatever broker it was installed on (§7, §11.2), and that loader really may predate
ctx.visibility— so portable source keeps the feature detect, or declaresneeds: ['visibility'](§5) and blocks visibly instead. -
ctx.signal(#198) — oneAbortSignalper activation, aborted at the head of teardown. Pass it explicitly to the calls you want cancelled; it has its own section below. -
ctx.capabilities(#197) — a frozen, per-mod map,{family: <integer version>}, of thectxsurface this build's loader actually hands you — e.g.{file: 1, serverStore: 1, windows: 1, settings: 1, theme: 1, …}. It exists so a mod can ask "does this build offer X" directly instead of sniffing loader-private names the waymod-syncused to (typeof _pin). Additive onctxVersion1 like everything else on this page — feature-detect it (ctx.capabilities && ctx.capabilities.theme) before reading it on a build old enough to predate it. The map is built lazily on first read and then frozen: your copy cannot be mutated into lying to you, and mutating it never reaches another mod's copy or theneedsgate (§5) any mod is checked against. It is observed, not promised — a family whose own ctx extender threw during setup is simply absent from the map, exactly as it is absent fromctxitself, so the two can never disagree. -
ctx.events.on(name, fn)(#195) — subscribe to the core → mod event bus. Returns an unsubscribe fn, which also rides teardown. Five declared events, two of which replay their current state to a late subscriber; the table and its rules are the contract, so read them below before you use it.
-
ctx.storage—get(key)/set(key, value)/remove(key)overlocalStorage, namespacedwebterm:mod:<id>:<key>. Per-browser, string values, never synced. Every call swallows storage failures (private mode, quota) andgetreturnsnull. -
ctx.prefs(#196) — the sanctioned browser-local tier:get(key, default)/set(key, value)/remove(key)over its ownwebterm:modprefs:<id>:<key>namespace, with JSON values rather than strings. A sibling ofctx.storage, not a corner of it — the namespace is the whole contract, and it is what makes these records survive "Reset local view" and structurally unable to ride/state. See below. -
ctx.serverStore— a durable per-mod key/value store on the broker (/mod-store/<modId>), scoped to this mod's id so one mod can neither read nor write another's. Every call resolves to the parsed body plus the HTTPstatus, and never rejects:get(opts)→{status, rev, value, revisions:[{rev,ts}]};set(value, baseRev, opts)→{status, ok, rev, …}(a409carries the live value inlined so you can rebase in one round trip);getRevision(n, opts)→{status, ok, rev, value}. Writes ride the/statesingle-active-client lease: a non-active browser reads fine but itsset()resolves409 {error:'not_active'}.opts.hostroutes to another broker by host id, and an unknown id fails closed —{status:0, error:'no_host'}with no request made. Reading another broker is whystatusmatters: a401("it refused our password") and a200withvalue:null("nothing published there") are the same empty body and only the status tells them apart. The status is applied after the body, so a response body carrying its ownstatuscannot overwrite the transport's.A mod that stores credentials MUST set
opts.noHistory = trueon every write. The store keeps a revision ring, so without it the value each write replaces — the previous password, in the clear — stays readable throughgetRevision(n)and on the broker's disk. The flag is sticky per record: it suppresses new revisions, clears the ring on the write that sets it, and survives later writes that omit the option (absent means "leave it alone", which is what makes a 409 rebase safe — pass the sameoptsto the retry). Turning it back on is deliberate and one-way:noHistory:falseis refused unlesspurgeRevisions:truerides the same call. Only a strict boolean reaches the wire, and a broker that predates the flag silently ignores it — checkmodstore.noHistoryin that broker's/infobefore sending a credential, because the write that would stop the archiving is itself the write that archives.Two members are built on top of
get/setand have their own subsections below:update(fn, opts)(#196), the compare-and-swap that re-applies your reducer over the winner of a409, andsaveChain(opts)(#196), the debounced, single-in-flight save pipeline built onupdate. Use them. Three mods hand-rolled a compare-and-swap before these existed, and the rebase that is easiest to write by hand — adopt the winner'srev, re-PUTthe object you already had — is the one that erases every key another browser changed in between (#158; it is whymods/mod-sync/deliberately does not use core'sputHostState).
-
ctx.http.fetch(hostId, path, opts)(#200) — the sanctioned authenticated request to any broker, resolving to{status, json?|text?, error?}and never rejecting.hostIdis required (a missing one throws synchronously — there is no same-origin default) andtimeoutMsis a total deadline including the body read, unlike rawhostFetch. See below. -
ctx.hosts.list()(#200) — a frozen[{id, label, url, brokerId, self}]snapshot, no token.brokerIdisnulluntil the host has answered over/info. See below. -
Hosts reached from server (#252). A host the user set to from server
is an ordinary row:
idis the same,urlis the peer's own origin (not where requests go — they go through this broker's/peer/<id>/prefix), and nothing in the row tells you the mode.ctx.http.fetchto it works, but only the routes this broker relays answer (see the peer allowlist in Technical-Reference#peer-proxy-252), so/stateand the mod install routes do not. -
ctx.file—read/write/list/delete/upload/mkdir/copy/move/zip/unzip/stat/setattr/hash, plus the chunked family (readChunk,uploadBegin,uploadChunk,uploadCommit,uploadAbort). Paths are host-wide absolute native paths — there is no root confinement. Every call resolves to a parsed result object, never a rejected promise:{ok:true, …}or{ok:false, error}.opts.hostis a host id string; omitted /''/'local'means this broker, and an unknown remote id fails closed with{ok:false, error:'host_not_found'}and makes no request. -
ctx.session—procs(id, opts), the destructivekill(id, pid, opts), andgit(id, opts). Resolves to{status, json}so you honour the broker's status contract verbatim — note that409 {error:'session_gone'}fromkillis the session-destroy success path, not a failure. -
ctx.clipboard.observe(fn)—fn(dir, text)withdir'out'(copied out of a terminal) or'in'(pasted in), for every copy/paste core captures. Returns an unsubscribe fn and is also dropped on teardown, so capturing stops the moment the mod is disabled. -
ctx.hosts.invalidate(id)(#195) — "forget everything this page knows about host<id>": clears every registered per-host cache and repaints the host surfaces, returning a boolean. It notifies nobody and saves nothing — see below.
-
ctx.taskbar.addStatusItem(node)— mount a node in the taskbar (before#help-chip). Auto-removed on teardown. -
ctx.taskbar.onItemsRendered(cb)— after every chip rebuild. -
ctx.taskbar.interceptActivate(fn)—fn(key)runs before core resolves a taskbar activation; returntrueif you changed what the desktop shows. -
ctx.desktop—columnFilter(fn),onColumnCreated(fn),onPlaced(fn),onForgotten(fn),onReveal(fn),onLayoutRender(fn). Each family is one slot: a second registration throwsModConflictError, so two mods can never silently fight over the desktop. -
ctx.registerKeyActions([{id,label,run}])— merges into the live action list every reader goes through (dispatcher, the Keyboard Shortcuts pane, the help corpus). A duplicate action id throws rather than last-wins. -
ctx.registerWindowMenuItems(fn)/ctx.registerDesktopMenuItems(fn)— register a callback; the callback returns an array ofrenderMenuitems (separators included, so you own your own grouping —renderMenudoes not collapse a stray one). The desktop callback runs in both window modes and receives{ tiling }saying which — check it rather than assuming tiling. -
ctx.windows.onTerminalCreate(cb)— subscribes to every terminal window, replayed over those already open and fired for future ones.cbgets{ win, titleBar, host, wireId, addTitleBarItem, onDispose, onModTeardown }— one subscription's replay and its create-time emit are mutually exclusive, so each window reaches your callback exactly once and you need no decorate-onceWeakSet.onModTeardown(#195) is the per-window disposer; it has its own section below.
-
ctx.settings.boolean(key, def, opts)→ checkbox -
ctx.settings.radio(key, options, opts)→ radio group -
ctx.settings.select(key, options, opts)→<select> -
ctx.settings.combo(key, options, opts)→ searchable<input list> -
ctx.settings.text(key, opts)→ free text (the fifth primitive)
All five are read-through accessors onto the shared /state settings blob
(not namespaced localStorage), so a value syncs across your browsers, and all
five return {get, set, onChange}. opts carries label, title, def
(boolean takes its default as the second argument instead), isBrowserGlobal
(default true — hidden on remote-host tabs) and mount — 'browser' (beside the Hosts list), or a Control Panel
applet id: desktop, windows, input, terminals, startup, access,
mods or advanced. Absent or unrecognised → mods. An applet placement
keeps a mod badge on the section so the source stays attributed. Reads are
non-destructive: nothing writes the default back.
A malformed options list no longer kills the mod (#203). radio /
select / combo throwing on an empty, invalid or duplicate-valued options
list used to disable init, and with it the whole mod — a single bad
computed list nuking a mod's every other feature was disproportionate, and
mods/clock/clock.js once carried its own defensive zones dedup for
exactly this reason; it was removed under #209 once a malformed suggestions
list stopped being fatal. Instead the
accessor comes back degraded:
const s = ctx.settings.select('k', badOptions);
s.ok // false — true on a healthy control, ABSENT on an older loader
s.error // 'invalid_options' | 'duplicate_option' | 'async_validator'
s.get() // the coerced def, or '' — never throws
s.set(v) // no-op, the family's existing silent-drop idiom
s.onChange(fn) // accepted, never firesFeature-detect s.ok === false, never !s.ok. ok is a #203 addition —
a loader that predates it has no ok field at all, so !s.ok reads every
healthy accessor on an older broker as rejected. s.error is the same
vocabulary the Mods-pane row shows: a rejected control adds a {key, code, message} entry to that row's additive warnings array (__test.statusOf(id) .warnings), and code there is byte-identical to s.error — what the
operator reads in the pane is exactly what the mod branched on. state
itself does not change; a mod with one degraded control is still active.
No widget mounts in the Control Panel for a rejected control — nothing to
click, nothing to reflect against a broken list. s.error is a decision
input, not a verdict: a fallback default is fine for a cosmetic control and
wrong for a load-bearing one, and only the mod knows which, so it reads the
code and chooses to degrade in place or bail out of its own init()
entirely.
text is the only primitive that is not choice-constrained, and that
distinction matters — combo looks free but treats its list as the legal
domain and refuses anything outside it. text takes options as
suggestions (a datalist), plus placeholder, maxLength (capped at
MAX_MOD_TEXT_LEN, 1024 UTF-16 code units) and validate. Its three rules:
-
read()is structural only — a bounded, control-char-free, well-formed string is returned as-is, even one this build's own validator would refuse, and it never writes. So an upgrade, a different engine or a stricter validator can no longer make a stored value evaporate. -
coerce()gates what we write: String → strip control chars → drop unpaired surrogates → trim → cap. -
validateis write-only and its rejection is visible — the rejected draft stays on screen next to a.set-errmessage and the stored value is untouched.
validate must be synchronous, permanently — this is contract, not a
temporary limitation (#203). Two independent checks enforce it. At
registration, an async function is detected and the whole control is
routed down the degraded path above with s.error === 'async_validator' —
fail-closed, not validator-stripped: silently mounting the control with
its gate removed would just reintroduce the #168 bug (a Promise is truthy,
so a stripped-validator control would accept every write) one layer up,
invisibly. At write time the original #168 backstop still runs — a thenable
return, from a plain function that slipped past the registration check
(transpiled output and a bound function both read as an ordinary Function),
is rejected exactly as before, with .set-err showing an unhelpful "that
value could not be checked" rather than corrupting the write. Registration
detection is best-effort; the write-time check is the one that cannot be
fooled.
The reason this is not "await it, with a timeout" is structural, not a matter
of taste: validate runs on every write attempt, and more than once per
commit, and the commit pipeline that calls it is synchronous by contract —
the 400 ms debounce, the change flush, blur's flush-first ordering, and the
pagehide flush that catches a reload or tab-close mid-debounce cannot await
anything. pagehide in particular has nothing to await into — the page is
gone before a Promise would settle. And even where an await is technically
possible, an awaited verdict would judge a value that is stale by the time it
resolves: entry.last is set before the widget reflects (the #168
convergence-loop fix), specifically so a skipped or delayed reflect can never
mask non-convergence — a validator that suspends mid-pipeline reopens exactly
that gap.
Writes are debounced, and a pending one is flushed on teardown.
mods/clock/clock.js is the worked example: its time-zone box uses text
precisely because
Intl.supportedValuesOf('timeZone') is engine-dependent, and a combo refused
zones that the same engine would happily render.
-
ctx.registerSettingsPane(spec)— a full custom section, for anything richer.spec.render()builds the DOM and is called exactly once;spec.reflect(settings)syncs it on Control Panel open and on every/stateconvergence, so it must be idempotent and must preserve an in-progress edit.
ctx.theme.get() // -> {name, dark}
ctx.theme.vars() // -> {'--bg': '#1e1e1e', …}
ctx.theme.onChange(fn) // -> unsubscribe fnDerived from the live DOM, so it is right with the theme mod disabled, absent or replaced.
-
darkis the honest half: a YIQ test on the computed--bg. -
nameis a hint — the syncedthemekey, reported only when an inline--bgondocumentElementproves a theme mod actually applied one. Branch ondarkwhen you need certainty. -
onChange(fn)fires after the new vars are on screen, only on a real change of{name, dark}, and is not replayed on register — callget()in yourinit. It is auto-dropped on teardown. A change that moves a public var without movingnameordarkdoes not notify. -
vars()is a separate call on purpose: it costs agetPropertyValueper variable, which a subscriber that only branches ondarkshould not pay.
The public CSS-variable contract vars() resolves is --bg, --bg-2,
--bg-3, --fg, --fg-dim, --accent-default, --sel-bg, --ok, --warn,
--danger. Deliberately not in it: --accent (per-window, so there is no
document-level value — write var(--accent, var(--accent-default)) inside a
window instead) and the geometry vars (--taskbar-h, --title-h,
--handle-thick, --corner-size). Everything else in the stylesheets is
private core.
Values come back as the CSSOM reports a custom property — a substituted token
stream, not a resolved colour. A theme mod writes six of these as plain hex,
but the four derived ones (--sel-bg, --ok, --warn, --danger) read back
as e.g. color-mix(in srgb, #4aa3ff 28%, #1e1e1e). That is a valid CSS colour
string — assign it to a style property and it paints — but do not try to
parse a number out of it.
-
ctx.registerWindowKind(spec)— register a brand-new app-window kind:{ appKind, factory(appData) -> win, serialize(win) -> record|null, restore?(record), retainOnClose?(record) -> bool, menu?: { label, launch(), iconKey?, iconGlyph?, closedItems?() }, deleteLabel?, deleteMessage? }
It is the same registry core's built-ins use, so a mod kind is a first-class window everywhere the registry is consulted. A duplicate
appKindthrows (andinitModrolls the mod back); the kind is removed on teardown. An optionalrestoreis called directly, so it does not getopenAppWindow's dedup-by-id — it must checkwindows.get(record.id)itself, because a lease-loss rebuild re-runs it.The Delete item.
deleteLabelis an opt-in, non-empty string naming what the window holds; setting it gives the window's context menu aDelete <deleteLabel>item (Delete note,Delete file). The item asks first, then closes the window and removes its saved record, whateverretainOnClosewould have kept. A kind withoutdeleteLabelgets no Delete item, so × Close is its only way out.deleteMessageis an optional non-empty string for that confirm; unset, it readsDelete this <deleteLabel> permanently? Its saved content is removed.Set it when that is not what your Delete does. Registration throws on an empty or non-string value of either, and on adeleteMessagewithout adeleteLabel. The shipped users aremods/sticky/(deleteLabel: 'note') andmods/editor/(deleteLabel: 'file', with adeleteMessagesaying the file on disk is not touched).The launcher icon.
menu.iconKeynames an entry in core'sAPP_ICON_SVGtable, which is closed — a key core does not ship resolves to nothing.menu.iconGlyphis the mod-owned alternative: a short text/emoji glyph thatrenderMenupaints withtextContent. It is normalized by core'sappIconGlyph(), which refuses an over-long raw string, strips control characters, bidi controls, invisible-payload carriers, lone surrogates and all whitespace, requires at least one substantive code point left over, and caps the result atAPP_GLYPH_MAXcode points — read that function for the exact rules rather than trusting this paragraph.There is deliberately no
ctx.registerAppIcon(svg): the API offers no way to put markup into the shared menu renderer, partly because the value a mod passes need not be its own (it might come from a/mod-storeblob or a peer's/state). That is hygiene for this API — per §1 it constrains nothing a mod could do by reaching into the DOM itself.Two traps: an
iconKeyis not owned by anyone, so a mod can name a shipped key and get that icon — only the label identifies a launcher; andiconKeytakes precedence overiconGlyph. Declare one or the other, and do not rely on the fallback for a key core does not currently ship. -
ctx.registerHelpCards(cards)— contribute typed Help cards. Never raw HTML:{ slug, section, title, body:[block], keys?, search? }whereblock = {t:'p'|'bullet'|'sub'|'pre', spans:[span]}andspan = {t:'text'|'strong'|'code'|'kbd', v:String}. An unknown block type degrades topand an unknown span type totext; values are coerced toString, and a card with notitleis dropped. Removed on teardown, and an open Help window re-renders.
Nine shipped mods used to hand-build the same ~30-field app-window scaffold: a
win literal pushed straight into the core windows Map, a synthetic
kind:'app' session, a hand-appended taskbar chip, and a teardown that
iterated core state to find its own windows. Core now owns all of that; a mod
supplies only what is specific to its window. mods/clipboard/clipboard.js
is the worked example — it shipped as the reference migration alongside this
API, and its hand-built scaffold is gone (the CLIP_WIN_ID constant survives
only as the default id for a create with no stored record).
const h = ctx.windows.createAppWindow({
kind: 'clipboard', // required: the appKind
id: 'app:clip', // optional; see the singleton default below
title: 'Clipboard',
sid: 'clip', badge: '#clip', // chip / session short id, taskbar badge
appClass: 'app-clip', bodyClass: 'clip-body',
singleton: true, // open-or-focus, deduped on KIND — see below
geom: undefined, color: undefined, locked: undefined, floatGeom: undefined,
resizable: true, // false = no resize handles (recorder's playback window)
toolbar: function (el, win, h) { /* the .app-toolbar strip, optional */ },
body: function (el, win, h) { /* the window's content */ },
onClose: function () { /* replaces the × action; default is closeWindow */ },
});kind is the only required field, and it must be a kind this mod has already
registered with ctx.registerWindowKind — register in init(), build later
(on a launch, a restore, a click). Two things ride on that: a mod cannot name
another mod's kind and have the dedupe hand it that mod's live window (which its
closeAll(), or simply its being disabled, would then close), and a window of a
kind nobody registered would persist a /state record nothing can ever restore.
A create is also refused outright while the mod is being torn down — the
take-down below walks one snapshot, so a window opened after it would outlive
both the pass and its kind. Both refusals throw.
Everything else falls back to the same
defaults the deleted scaffolds computed by hand: title/sid default to
kind, badge to '#' + sid, appClass to 'app-' + kind, bodyClass to
appClass + '-body', geom to clampGeom(appDefaultGeom(kind)), color to
normalizeHex(defaultColor(id)), locked to true, and id (absent
singleton) to a fresh newAppId(kind).
Core owns, exactly as every deleted copy did it by hand: the chrome
(buildAppChrome + wireAppChrome + the eight resize handles), the desktop
insertion, the window record itself (the object that lands in the core
windows Map), the synthetic kind:'app' session and its taskbar chip
(#taskbar-empty removed once it exists), and the placement tail
(finishWindowPlacement). Your toolbar/body builders run after all of
that, against a window that already has a layout box and a record to hang
per-window state on — though not necessarily its final box, since
placement may still tile it.
createAppWindow returns a frozen handle, not the window record:
| member | does |
|---|---|
h.win |
the underlying window record — the escape hatch the trust model (§1) permits, not the API itself |
h.dom, h.body, h.toolbar
|
the chrome root, the body element, the toolbar element (null if you passed no toolbar) |
h.isOpen() |
still a live member of the core windows Map |
h.focus() |
reveal-and-focus |
h.close() |
close this window |
h.setTitle(text) / h.setColor(hex)
|
update title bar, session and taskbar chip together |
h.save() |
saveAppWindow, for a persisted kind |
h.addTitleBarItem(node) |
inserted before the minimize button |
h.onDispose(fn) |
win.cleanups.push, spelled the way onTerminalCreate's bag spells it |
Every member is gated on the window still being live — a handle you keep past its window's death (or past a later window reusing the same id) is inert, not lethal.
singleton: true formalizes the open-or-focus hack every windowed mod
re-derived by hand (the hand-rolled open-or-focus id check). With it, id defaults to
the stable 'app:' + kind, and a create call dedupes on kind over
every live window in the core windows Map — not just the windows this
mod's own registry already knows about, because core's unknown-kind restore
fallback (#167) may have rebuilt a window of this kind, under some other id,
before this mod ever loaded. Whichever window it finds is adopted (never
double-built) and, unless the call is a restore, brought to front. Without
singleton, dedupe is by id alone: a second create at a still-open id
adopts and focuses it; a create at an id mid-close throws (a reopen has to
wait for the close to finish); a create at an id already open under a
different kind throws rather than stranding the live window.
ctx.windows.list() — this mod's own live windows, oldest first, as a
fresh frozen array. Dead entries are pruned on the way past, so a list()
after a lease-loss rebuild reads empty instead of a wall of ghosts.
mods/clipboard/clipboard.js's renderAll() is the shipped example: a loop
over ctx.windows.list() replaced a hand-kept liveWins set.
ctx.windows.closeAll() closes every one of this mod's factory-built
windows and returns how many it closed. It is the same pass the loader
also stages automatically: a mod's factory windows are closed by core
before its onUnload chain runs, and while its window kinds are still
registered — so a migrated mod needs no teardown-ordering care of its own.
That property is why registerWindowKind's restore/teardown ordering used to
matter by hand: close a window after its kind is deregistered and the close
reaches saveAppWindow with no registry entry for the kind, falls back to
the shared serializer, and leaves a junk /state record for a kind nothing
can restore. Modeling the close as an onUnload entry would make it the
oldest entry in the LIFO chain and therefore run it last — after
deleteWindowKind, which rides that same chain — exactly the wrong order.
So it is never registered as an unload: the loader calls this same close
pass directly, at the head of teardown, before the first onUnload runs and
before any kind the mod registered is deregistered. This is what let
clipboard.js delete its own ctx.onUnload window-closing block entirely.
Call closeAll() yourself when you want a mod's windows gone without
disabling the mod.
The restore hook's third argument. registerWindowKind's restore is
called directly by core, outside any mod's ctx closure, so it is handed
the per-mod window API as a third argument:
restore: function (record, opts, api) {
const h = api.createAppWindow({ kind: 'scratchpad', id: record.id, … });
return h.win; // or `return h;` — api unwraps a handle either way
}api is {createAppWindow, list} — the same two members ctx.windows
exposes, scoped to this mod's record. Two defaults ride the call and both are
overridable in your own spec: spec.id defaults to record.id (so a restore
that builds elsewhere does not orphan the record it was handed, or step
around the id dedupe), and spec.restoring defaults to opts.restoring
(core's automatic restore pass always passes true) — the same suppression
openAppWindow's own restoring gives: no reveal, no un-minimize, no
re-home to whatever workspace the page happened to boot on (#152).
ctx.windows.onAppWindowCreate(handler) fires for app windows whose
appKind is one of this mod's registered kinds — every kind it has
passed to registerWindowKind, however the window was actually built,
including a window core rebuilt through its unknown-kind fallback before
this mod ever registered that kind. It is not scoped to "windows this
mod's factory built." Subscribing replays every matching window that
already exists, then fires for every one built afterwards — exactly once per
subscription: a replayed window is never re-delivered as a live create, and a
live create is never re-delivered by a later replay, so a mod needs no
WeakSet of its own to avoid double-decorating. This is sticky's init-time
catch-up pass (mods/sticky/sticky.js) made first-class — whether a mod's
windows already exist when its init runs is a race (#167), not an
ordering, and this hook makes both orders come out identical.
const off = ctx.windows.onAppWindowCreate(function (info) {
// info: {win, id, kind, dom, body, titleBar, replayed, addTitleBarItem, onDispose}
addChip(info.id);
info.onDispose(function () { removeChip(info.id); });
});off() also rides ctx.onUnload automatically, so a disabled mod stops
decorating windows even if it never calls the function it was handed.
THE TRAP. A registerWindowKind factory — factory(appData) — must
return h.win, never the handle: openAppWindow hands a factory's
return value straight back to its own callers (and its own id-dedupe branch
returns a window record too), so a factory that returns the handle breaks
every caller expecting a record. A restore hook is the one place a handle
is accepted — api unwraps it by identity before core ever sees it, so
return h; and return h.win; are both correct there, but a factory only
has the one right answer. mods/clipboard/clipboard.js's
openClipboardWindow says exactly this at its own return h.win line.
Every host- and state-lifecycle fact core wants to tell a mod has travelled as
an ad-hoc duck-typed protocol: a win._onHostAuth field core invokes by
name, a win._hostRemoved beside it, Help polling the core-private
_stateReady 20 × 500 ms for the initial adoption, workspaces reading the
core-private _deactivated. Nothing registers those and nothing types them, so
core cannot refactor its auth form, its host list or its boot sequencing without
auditing every mod. ctx.events is the one channel that replaces all of it —
and it is the channel to write new code against. Two shipped readers of
those private names remain: mods/editor/ still sets win._onHostAuth, and
mods/workspaces/ still reads _deactivated.
const off = ctx.events.on('state:adopted', function (payload, meta) {
// payload — the table's shape below, frozen
// meta — {name, shape, gen, replayed}, frozen
});
off(); // unsubscribe; `off` also rides this mod's teardown automaticallyThe table is the contract.
| name | shape | payload | replay on subscribe |
|---|---|---|---|
host:auth |
edge | {hostId} |
no |
host:changed |
edge | {hostId} |
no |
host:removed |
edge | {hostId} |
no |
state:adopted |
level | {gen} |
yes, exactly once |
lease:changed |
level | {active, gen} |
yes, exactly once |
A name this build does not declare is refused inertly — a console.info
and an unsubscribe fn that does nothing — never a throw, so a mod written
against a newer build keeps running here (§5's "absence is never an error").
Edge vs. level is the whole design, not a flag. An edge event is a thing
that happened: a subscriber that was not there missed it, and replaying it
later would be a lie about when it happened. A level event is a state that
is: a subscriber arriving late is entitled to the current one and gets it
immediately, exactly once, out of its own on() call. The two level events are
precisely the two a mod otherwise has to poll or read a core private for — the
replay is what a 20-tick _stateReady timer is for, done properly. A level
only replays once it exists: subscribe before core has learned the lease and
there is nothing retained to hand you, so you are told at the first transition
like everyone else.
Exactly once — replay XOR live. A subscriber that was there before the emit is told live; one that arrives afterwards is told by its own replay. Never zero, never twice — including the nasty case where a handler subscribes from inside another handler, i.e. after an emit has been applied but before it has been delivered. It is enforced per subscription, so two mods each get their own exactly-once and a mod that unsubscribes and re-subscribes is told again.
gen is stamped by the bus. A gen key on the payload a core site passes
is dropped; the bus owns that field. It is a monotonic counter over every
emit that actually happened, across all five names — an ordering aid, so you can
tell a replay of gen 3 arriving after a live gen 5 is stale. It is not
the /state revision and not a lease epoch. Only a level payload carries
it; an edge does not, but the meta bag carries it for both. Payloads are a
shallow copy of what core passed, and frozen.
A level emit that changes nothing does not happen. Equality is shallow
Object.is over the own keys, ignoring gen: no generation is taken, no
handler is called, nothing is re-retained. That is why state:adopted fires
exactly once ever even though core has two adopt latches — the boot pull and
the lease-loss rebuild both emit an empty payload, so whichever runs second is a
no-op — and why a repeated {active: false} from the lease socket is silent.
Fire-after-apply. Core emits after it has mutated, and the bus applies its own state (the generation, the retained level) before it calls anybody, so a handler observes finished state rather than a half-applied one. Two limits the code states out loud and this page will not overstate:
-
lease:changedis HOME only. The payload has nohostId— one retained level cannot speak for N brokers, and a remote broker's lease is a per-host mask with no event in this build's table. - On a lease loss the view is fully torn down before any handler runs; on a gain the rebuild is still in flight. The event names the lease, which core has genuinely learned, not the view.
Handler isolation. A throwing handler is logged and its siblings still run, and the throw never reaches the core mutation site that emitted.
Auto-unsubscribe at teardown. off is pushed onto this mod's LIFO teardown
chain as well as returned, so a disabled mod stops hearing about hosts even if
it never calls the function it was handed.
Re-entrancy is coalesced and bounded. An emit raised from inside a handler
joins the pass already running instead of nesting one, so a cascade's stack
depth is constant and the delivery order stays the order things were applied in.
Level events coalesce while still queued (two lease changes inside one pass
deliver the final state once); an edge never coalesces, because two auth
successes are two facts. The pass is capped — exhausting it drops what is left
with a console.warn, rather than livelocking the UI thread.
Only core emits. ctx.events carries on and nothing else. A mod that
could emit host:removed could lie to every other mod about core state, and
inter-mod messaging is a separate surface with its own trust story (#199).
Adding an emit later is additive.
Where each event comes from, so you know what it means:
-
host:auth— a completed host authentication, and a poll that finds a host's auth has recovered. -
host:changed— a committed host add/edit, the host colour swatch, the hidden toggle, and a host switched between direct and "from server". Only paths that actually mutated emit it. The edge carries an id, not a snapshot: re-read what you care about and decide. -
host:removed— after the whole removal has finished, so it means "that host is gone", not "it is going". -
state:adopted— the first/stateadoption, whether that is the boot pull or a lease-loss rebuild that beat it. -
lease:changed— the home single-active lease, per control frame.
Feature-detect it. events is its own capability family at version 1, so
ctx.capabilities.events, if (ctx.events) / typeof ctx.events.on === 'function' and needs: ['events'] all work. mods/help/help.js is the shipped
worked example — its corpus refetch on host:auth, replacing the
win._onHostAuth field it used to write. Note it feature-detects instead of
declaring needs: ['events'], deliberately: a needs blocks the whole mod,
and Help without the bus is missing one corpus refresh, while Help blocked has
no chip, no window kind and no hotkey. Weigh that trade the same way.
Core still invokes win._onHostAuth by name for the one shipped mod that
still sets it, mods/editor/. That is legacy, not a pattern — do not add
another.
A mod that decorates a terminal window has two exits to cover, owned by two channels, neither of which covers the other:
| chain | fires when | does not fire when |
|---|---|---|
win.cleanups — info.onDispose
|
the window closes (closeWindow, and the lease-loss rebuild) |
the mod is disabled with that window still open |
rec.unloads — ctx.onUnload
|
the mod is disabled | one of the windows it decorated closes |
So a widget needed a disposer on both, an idempotence flag so the first exit disarmed the second, and a set so the mod-side entry could still reach the open windows. Core keeps all of that now:
ctx.windows.onTerminalCreate(function (info) {
info.addTitleBarItem(btn);
if (!(typeof info.onModTeardown === 'function'
&& info.onModTeardown(teardown))) {
info.onDispose(teardown); // refusal / older build — see below
}
});- Exactly once, on the first of the two. Both exits fire the same per-(mod, window) state, and it is marked spent before its callbacks run — so close-then-disable, disable-then-close, and a callback that closes its own window or disables its own mod from inside the teardown it is running in are all the same single call.
- A disable does not close the window. A terminal belongs to core, not to the mod that decorated it: this removes the decoration and leaves everything else where it was. (#194's staged take-down does close a mod's windows at disable — those are the mod's own factory windows. A terminal never is.)
-
It returns a boolean, and a refusal is never a throw:
falsefor a non-function, for a window that is already gone, for this mod being mid-teardown, and for a state that has already fired. You can see thefalseand run your own cleanup, which is exactly what the fallback above is. - Callbacks run LIFO, each isolated, so one broken disposer strands neither the window's remaining ones nor the chain behind them.
-
The state is per (mod, window), not per delivery. A mod may hold more than
one
onTerminalCreatesubscription and every one is handed a bag for the same window; they all register into one state, so a window carries onewin.cleanupsentry per mod however many teardowns that mod registers on it. -
Where it sits in the LIFO chain. The mod-side entry is pushed at your
first accepted registration — normally inside
init(), on theonTerminalCreatereplay — so it is an early entry and therefore runs late, after disposers you registered afterwards. A self-contained widget teardown belongs there; if you need your widget gone before some other resource you own, tear that one down from the same callback. -
No new capability entry, and
needscannot name it.windowsis already a v1 family and this adds a member to it, so the map is unchanged;needsresolves a path onctx(§5) and the per-delivery info bag is not reachable fromctx. Feature-detect withtypeof info.onModTeardown === 'function'. - One accepted cost: a disable leaves its spent
win.cleanupsentry on the window. Splicing it out at fire time is the one thing this must not do —closeWindowiterates that array withfor…of, and removing an entry mid-drain skips the next one — so an enable/disable cycle on a long-lived terminal leaves one dead closure per cycle.
mods/git/git.js is the shipped reference migration. Its hand-kept
disposers Set, the ctx.onUnload block that drained it and the decorate-once
decorated WeakSet in front of both are all gone, replaced by the one
registration above at the same site. (The WeakSet needed no replacement:
onTerminalCreate already delivers each window once per subscription.) What it
keeps is the info.onDispose fallback, deliberately: onModTeardown is
absent on a page assembled without the extension fragment and returns false
on a dead window or a mod mid-teardown, and in those cases arming the
window-close half alone is still worth it — a leaked 15 s poll plus a live
document-level listener on every terminal is the worse failure. It is the one
line the mod rode before, not a second set.
hosts is a new family at capability version 1, and invalidate(id) is its
first member: "forget everything this page knows about host <id>".
It clears every registered per-host cache and then repaints the host
surfaces (the hosts list, the settings tabs, the host status chips and the
taskbar). The list is core's, not a caller's — each cache registers itself
beside its own declaration — so the eight this build has (the per-host state
cache and its save chain, the auth-prompt latch, the poll record, the /control
socket, and three config caches) and whatever the next fragment adds are
covered by the one call. The cautionary example is in the tree's history:
mods/host-registry/ used to carry its own hand-maintained copy of that list,
and its own comment called that copy "a known latent gap"; it calls this now
(#214). A list kept by a stranger is right on the day it is written and nothing
makes it stay right — call this instead.
It returns true when it ran and false for a blank / non-string id (nothing
to forget, and no repaint owed) or an internal failure — never a throw, so
calling it from inside an event handler cannot take a repaint down with a bad
argument.
Two things it deliberately does not do:
-
It does not emit. Invalidation and notification are separate acts: only
core mutation sites emit
host:changed. So ahost:changedsubscriber may callctx.hosts.invalidate()without recursing. -
It does not save.
savePrefs()is a mutation (localStorage plus a/statepush) and stays with the caller that actually changed something, so a mod can drop stale per-host state without writing prefs it does not own.
Clearing a cache does not cancel an in-flight request; it drops the stale value so the next request uses the new url/token, and the poll loop re-primes on its next tick.
Feature-detect with typeof ctx.hosts.invalidate === 'function' or
needs: ['hosts.invalidate']. On a build whose core has no invalidation path
the family is not installed at all, so ctx.capabilities reports hosts
absent — true — rather than handing you a member that silently does nothing.
const res = await ctx.serverStore.update(fn, {
host?, retries?, purgeRevisions?, noHistory? });
// -> {ok:true, rev, value} | {ok:false, error, status?, rev?, value?}get → fn(value, rev) → set, and on a 409 conflict it re-reads and
re-applies fn to the winner's value. The blob that lost is never re-PUT.
That single behaviour is the whole reason the helper exists.
fn must be pure over (value, rev). It is invoked once per attempt,
so a side effect inside it happens once per attempt — bumping a counter,
appending to a log, showing a toast, queuing another save. That is a caller
bug, and the code deliberately does not defend against it, because the only
available defence is to snapshot once and re-send that snapshot after a 409
— which is the #158 clobber, now with retries. Everything the write depends
on that can change between attempts (the live editor buffer, the current
selection, Date.now()) belongs inside fn, where it re-runs over the
winner.
Which is also why the shape is a reducer and not a snapshot-getter. An
update(gatherValue) API cannot rebase: it has nothing to apply the winner's
value to, so its only move after a 409 is to overwrite. The winner's value has
to flow through your function.
The rest of the contract:
-
valueis the winner's value, freshly parsed on every attempt, andnullon a store nobody has written yet.revis the revision it was read at. Mutatingvaluein place is safe, but the next value must be returned — a reducer that only mutates returnsundefined, which is refused (bad_value) before any write, because the server 400s on a missingvalueand a round trip is a slow way to learn that. -
An async reducer is first-class —
fnmay return a promise. -
Bounded, and the conflict is surfaced. One write plus a bounded number of
rebases;
retriesmoves that budget and is clamped — the ceiling is not the caller's to remove, because the mirror failure of a silent clobber is a silent infinite rebase against a hot writer, and every attempt also writes into the store's revision ring (#175). Exhausting the budget resolves{ok:false, error:'conflict'}with the last winner'srevandvaluestill inlined, and you decide whether to widen, warn or drop. -
not_activeis refused, not rebased. It is a409too, but it means this browser does not hold the/statewrite lease, so re-applyingfnat a fresher revision would burn the entire budget only to be refused again. It comes straight back with the liverevinlined. -
A
409that inlines the live value costs no extra round trip; one that does not (an older broker, or a remote one whose body is untrusted input) falls back to an authoritative re-read — and a failed read aborts without writing. So does the initial read: a response that is not this store's own200shape is a refusal, never "an empty store", because reading it as rev 0 /value: nullwould run your reducer down its "nothing stored yet" branch on a store nobody could actually read. -
optsis snapshotted once and rides every attempt, rebases included, sopurgeRevisions(#65) andnoHistory(#192) cannot be dropped by a retry.noHistorykeepsset()'s strict tri-state: it is presence-keyed, and absent is notfalse— absence means "leave this record's flag exactly as it is", so a retry that helpfully sentnoHistory:falsewould resume archiving the very credentials the flag exists to keep out of the ring. -
Fail-closed, inherited. Every request goes through your own
ctx.serverStore.get/set, so it is mod-scoped, lease-carrying and host-routed exactly as they are: an unknownopts.hostmakes no request and refuses before anyPUTis attempted. -
It never rejects. Every outcome resolves to a result, including
{ok:false, error:'fn_failed'}when your reducer throws (it is logged) andbad_fnwhen the first argument is not a function.
Feature-detect with typeof ctx.serverStore.update === 'function' or
needs: ['serverStore.update']. It is a new member, not a new family, so
there is no capability entry for it: ctx.capabilities is keyed by family name
(serverStore), never by dotted path — the same as
ctx.windows.createAppWindow. ctxVersion stays 1.
const chain = ctx.serverStore.saveChain({
host?, debounceMs?, retries?, purgeRevisions?, noHistory? });
chain.save(fn); // -> true (queued) | false (a dead chain, or a non-fn)
chain.flush(); // -> Promise<result>, never rejects
chain.onState(fn); // 'idle' | 'saving' | 'conflict' -> unsubscribe fnscratchpad's save pipeline, canned. It adds nothing to the write itself —
every send goes through update(), so the 409 rebase, the bounded budget, the
fail-closed host lookup and the verbatim purgeRevisions / noHistory
passthrough are inherited rather than re-implemented. What the chain owns is
when to write, how many writes to make, and what to tell the user while it
happens.
save(fn) takes the same pure (value, rev) reducer update() takes, and
the purity rule above applies unchanged — including this specific case: do not
call this chain's own save() from inside a reducer, or a rebase queues the
same work twice.
-
Coalesced by composition, not by replacement. Rapid saves queue, and the
queue is spliced into one batch composed in order —
fn2(fn1(value, rev), rev)— handed toupdate()as a single reducer. So two queued saves both apply; an earlier caller's delta is never dropped on the floor. For a reducer that produces the whole document every time (scratchpad's) that degenerates to last-wins, which is exactly the old behaviour. Two consequences: every reducer in a batch sees the same baserev, and one reducer that throws or forgets to return refuses the whole batch, underupdate()'s own error names. -
Single in flight, per chain. At most one write from a given chain is
outstanding; saves arriving during it leave as exactly one more write. Per
chain is the honest scope — two chains in one mod address the same
per-mod record on a given broker (
opts.hostonly picks whose copy), so they serialize with each other only through the server's revision CAS, each re-applying over the other's winner. Correct, but it spends conflict budget: write one chain per record. -
Trailing debounce. Each
save()pushes the deadline out, which is what makes a burst one write — and means a genuinely continuous writer only leaves via the in-flight andflush()paths.debounceMsoverrides the default and is clamped, for the reason in the next bullet: a pending batch is dropped at teardown, so an absurd debounce is data loss wearing a save's clothes. -
onStatereports'saving'while a write is outstanding,'conflict'for an unresolved refusable409(conflictornot_active— scratchpad's read-only banner),'idle'otherwise. It is deduplicated, it fires once on subscribe so a banner mounted after the fact is correct without reconstructing anything, a throwing listener cannot cost the chain its pump, and the returned unsubscribe is yours to call. -
flush()forces the pending batch past the debounce and resolves once an in-flight write and anything already queued have completed — never waiting on writes queued after it. With nothing outstanding it resolves{ok:true, idle:true}, except while a conflict is unresolved, where it reports that result rather than a freshok. -
It never retries a failed batch by itself.
update()already rebases a409up to its budget; past that the batch is dropped and the chain reports'conflict'. Scratchpad's answer is to re-attempt on the next interaction while the banner is up; yours is yours.
The trap: a chain drops its pending batch at teardown. Deliberately. The
timer is cleared, the queue is discarded, the state listeners are released, and
every outstanding flush() promise resolves {ok:false, error:'unloaded'}
rather than hanging the caller awaiting it. From the first instant of a disable
the chain reads dead — save() returns false, flush() resolves
{ok:false, error:'unloaded'}, and a disable landing between update()'s read
and its write is a drop, not a race.
That is not the same posture as the ctx.settings.text debounce, which does
flush on teardown, and the difference is the point: a settings commit reaches
localStorage synchronously, so a teardown can complete it, while a chain write
is a network round trip that a synchronous disable cannot await. "Flush on
the way down" is precisely the write that lands after a re-enable and clobbers
the new activation.
So a deliberate final flush must be called while the mod is still alive —
from a window-close cleanup (win.cleanups, which also runs on an ordinary
close, with the mod up) or from an explicit save action such as a Ctrl+S
handler. Never from ctx.onUnload, where the activation is already marked
dead and the batch is dropped by design. mods/scratchpad/ is the worked
example on both counts: it flushes from win.cleanups (capturing the editor
buffer first, because a later cleanup in the same drain destroys the editor
view while flush() is still resolving), and its Ctrl+S path is a save()
followed by a flush(). What no client-side check can recall is a request
already handed to the network; that residual window is named here rather than
papered over.
Feature-detect with typeof ctx.serverStore.saveChain === 'function' or
needs: ['serverStore.saveChain'] — a member, so no capability entry, same
as update() above. Scratchpad declares the needs and blocks: saving is
that mod, so a build without the chain should read
blocked (needs serverStore.saveChain) rather than open a notes window that
silently drops every keystroke. mods/help/ makes the opposite call for
ctx.events and feature-detects instead, because a missing bus costs it one
corpus refresh rather than all of its persistence. Block when the loss is
total; degrade when it is partial — the two shipped mods are there to be
copied from, in whichever direction fits.
ctx.prefs.get(key, default); // -> the stored JSON value, or `default`
ctx.prefs.set(key, value); // -> true once it reads back
ctx.prefs.remove(key); // -> true (false only for a bad key)A mod's own preferences, per browser, with a contract instead of a
coincidence. Before this, both ways of reaching for core's prefs object were
wrong: a bare key is wiped by "Reset local view"'s non-underscore sweep, and
an underscore key stays local only because the /state blob happens to
serialize _settings and _layout and nothing else — one edit there and it
syncs, exactly as _settings already does (#153).
The namespace is the decision. webterm:modprefs:<id>:<key>, one
localStorage record per key, a sibling of the webterm:mod:<id>:
namespace ctx.storage owns — never a sub-prefix inside it, and never a key on
the core prefs object. Three consequences, each of them the point:
-
"Reset local view" cannot reach it. That sweep deletes keys off the
prefsobject and re-serializes it; amodprefsrecord is not in it. -
The sync path cannot reach it. The
/stateblob is built fromprefs._settingsandprefs._layout, so actx.prefswrite leaves the serialized state byte-identical and no/statePUTis attributable to one. It is not "local by convention" — it is structurally unable to sync. A future "sync my prefs" is therefore a new tier, not a flag on this one. -
ctx.storagekeeps its semantics exactly — no reserved-prefix policing, and no way to clobber a preference record through the raw surface, since a mod id may not contain:and both namespaces end in one.
Values are JSON, not strings (that is the other difference from
ctx.storage). The failure posture is ctx.storage's, copied deliberately:
-
get(key, default)returnsdefaultfor a missing key, a bad key and a corrupt record — a half-written or hand-edited value is a miss, never a throw that takes down theinit()reading it. -
setreturnsfalsefor a bad key or a value JSON cannot represent (a cycle,undefined, a function) — and in that case nothing is written, so a refusedsetnever destroys what is already stored. - A
localStoragethat refuses (private mode, quota) is swallowed and the value is kept in memory for the life of the page — not the activation — so a disable and re-enable still finds whatlocalStoragewould have kept. A refusedremovereads as removed for the rest of the session rather than resurrecting the record it could not delete.
prefs is a new family at capability version 1, so it does have a
capability entry: gate with ctx.prefs && typeof ctx.prefs.get === 'function',
ctx.capabilities.prefs, or needs: ['prefs']. ctxVersion stays 1.
And it is storage, not a boundary: like everything else on this page (§1), the
records sit in the page's own localStorage, where any script on the origin can
read them. Namespacing keeps mods from colliding, not from looking.
ctx.signal // an AbortSignal for THIS activation
const res = await ctx.file.read(path, { signal: ctx.signal });
if (res.aborted) return; // this resumes AFTER teardown finished —
statusNode.textContent = res.error; // check before touching anything of yours
if (!ctx.signal.aborted) { // `.aborted`, never bare `if (ctx.signal)`
const t = setTimeout(poll, 5000);
ctx.signal.addEventListener('abort', function () { clearTimeout(t); });
}One AbortSignal per activation, aborted when that activation ends. It
replaces the dead-flag pattern mods hand-rolled to stop an in-flight loop from
touching a desktop it no longer belongs to. mods/update/ is the worked
example: its two hand-rolled flags, restartOpDead and applyOpDead, became
one opDead() that reads ctx.signal.aborted (#224).
-
Per activation, and freshness is structural. The controller hangs off the
per-mod record
initModbuilds, and it builds a new one every time a mod comes up. A disabled-then-re-enabled mod therefore gets a new, un-aborted signal; the old activation's signal stays aborted forever, which is the point — its loops must not come back to life when the mod does. -
The abort is the first thing teardown does. In order: the record is marked
as unloading, then the signal aborts, then #194's staged close of the
mod's factory app windows, then the LIFO
onUnloadchain (which is where #195's per-windowonModTeardowndisposers ride, as an entry on that list). Abort listeners fire synchronously, so a listener is told to stop before anything starts taking your windows away, and before everyonUnload. That ordering belongs to listeners and to nothing else — read the next bullet before you assume it covers anawait. -
Cancelling the work is not cancelling the continuation. The take-down
path is entirely synchronous: by the time an aborted
awaitresumes — a later microtask — the staged window close and the wholeonUnloaddrain have already run to completion. So the line after yourawaitexecutes against a torn-down activation, and forctx.filethat is worse rather than better, because an abort resolves{ok:false, aborted:true, error:'cancelled'}instead of throwing: there is no rejection to fall out of, and the naivestatusNode.textContent = res.errorwrites into a window that is gone. A continuation that touches activation-owned state must check first — the result'saborted, orctx.signal.aborted— which is precisely the jobmods/update/'sopDead()does. The signal cancels the request for you; it does not return you to a live mod. -
if (ctx.signal)is a capability check, not a liveness check. The ctx is not dismantled at teardown, soctx.signalstays truthy forever — it is the aborted signal by then. An async callback that resumes after teardown, sees a truthyctx.signal, arms asetTimeoutand registers an abort listener to clear it has built a leak: an already-aborted signal never replays its abort event to a listener added afterwards, so that listener never runs and the timer fires against torn-down state. (Core codes to the same fact —hostFetchtestscallerSignal.abortedup front rather than relying on a listener.) Checkctx.signal.abortedbefore arming anything on a path that can resume late. -
An abort listener is teardown-time mod code. It runs after the record
reads dead, so from inside one a
saveChainis already dropping batches and a settings write cannot re-enter your own theme subscriber. It is not a place to rescue a last save from — see the pairing rule below. -
Explicit pass, never ambient. The loader injects
ctx.signalinto nothing.ctx.fileandctx.sessionforwardopts.signalonly when you hand it over, andhostFetchcomposes it with that call's deadline instead of replacing it, so a cancel and a timeout stay distinguishable at the result. An ambient signal would silently change every existing call site and abort work a mod deliberately wants to finish. -
An aborted call still resolves; it never rejects.
ctx.filemaps the abort to{ok:false, aborted:true, error:'cancelled'}—'cancelled', the word the file family already used for a user pressing Cancel, distinct from its{ok:false, timedOut:true, …}deadline.ctx.sessionkeeps its status-carrying envelope and resolves{status:0, json:{ok:false, error:'AbortError: mod "<id>" was torn down'}}— the stringified reason, named so a console says which mod's teardown cancelled the request. -
Feature-detect it as a value, not a function.
if (ctx.signal),ctx.capabilities.signal, orneeds: ['signal'];typeof ctx.signal === 'function'is the detection that would be wrong, because unlike every family whose members are callable this one is the value — which is also why it owns a capability entry rather than being gated as a member of one. A build whose engine has no constructibleAbortControllergets noctx.signalat all rather than a placeholder that never aborts, andctx.capabilitiesreports it absent, soneeds: ['signal']blocks with a reason instead of running against a signal that quietly does nothing.ctxVersionstays1.
The pairing rule: work that is supposed to outlive the teardown must not
carry the signal. They are two halves of one decision, and reading only the
first half is how a migration breaks a save. The shipped referent is #196's
deliberate final flush(): mods/scratchpad/ calls it from a window-close
cleanup precisely so a last edit reaches the server, and neither
serverStore.update() nor saveChain takes a signal at all — there is no
option to pass one, and that is deliberate. The failure to avoid is threading
ctx.signal through the writes you make on that same path (a final
ctx.file.write, a last ctx.session call): a disable would then abort exactly
the work you added the cleanup for.
The rule is about the work's lifetime, not the callback it starts in, and
this is worth being exact about, because the clearest teardown-surviving write
in the repo starts from an onUnload. mods/recorder/ stops a live recording
from its unload disposer and saves what it captured — and that disposer runs
after the abort, by construction. It is safe today only because nothing hands
it a signal; give it one and hostFetch short-circuits an already-aborted
caller signal before it ever calls fetch, so every disable with a live
recording would lose the segment. So do not read this as "close-time paths are
the risky ones": if it must survive the teardown, it does not take the signal,
wherever it is started from.
// the PROVIDER, from its init:
if (typeof ctx.provide === 'function') {
ctx.provide('pattern', { apply: applyPattern });
}
// the CONSUMER, PER USE — not once at init:
function apply(name) {
applyTheme(name);
try {
const p = (typeof ctx.consume === 'function')
? ctx.consume('pattern', 'pattern') : null;
if (p) p.apply(getSettings().pattern); // absent/inactive -> undefined
} catch (_) {}
}One mod publishes a named api; another asks for it by provider id and name.
It replaces the cross-mod call this repo actually had — mods/pattern/ left
applyPattern at the top level of the one shared <script> and mods/theme/
probed typeof applyPattern === 'function' before calling it. That probe
answers exactly one question ("did some fragment declaring that name
evaluate?") and a top-level function outlives the mod that declared it, so it
reads true for a mod switched off ten minutes ago and the call lands in a
torn-down closure. The pattern/theme pair above is the shipped reference
migration; mods/scratchpad/ consumes mods/editor/'s 'codemirror' service
the same way.
-
consumeimplies no dependency edge. It returnsundefinedfor a provider that is absent, not installed, installed but not loaded this page, disabled, mid-teardown, or that never published that name — never an error. That soft miss is the design, not a gap: an implicit edge would turn a soft probe into a take-down cascade, so disablingpatternwould takethemedown with it.requiresstays the only way to force ordering and bring-up. Corollary: if you need the service at your owninit(), declarerequires; otherwise consume lazily, per use, which is what the theme example does and why nothing there has to be revalidated — every call asks the loader's active-mod map afresh. -
What you get back is a revocable proxy, one per provider activation.
After the provider tears down every read returns
undefined, every call no-ops, and there is exactly oneconsole.warnnaming the service, ever (the flag lives on the entry, so a dead proxy hammered in a render loop warns once). A function-shaped member read off a dead proxy returns a no-op function rather thanundefined, sop.load()on a dead service does not throw "p.load is not a function" inside your code; which keys are function-shaped is snapshotted atprovidetime, string keys only. A function you plucked while it was live (const apply = p.apply;) re-checks liveness at call time and no-ops too. -
Deadness starts at the head of teardown, not at some disposer's turn in
the LIFO drain: the record is marked
unloadingfirst, and every trap reads that. A synchronous frame already inside the provider still runs to completion — no proxy can unwind a stack — but every further operation through every proxy for that activation is dead, including one issued from the in-flight call's own continuation. -
The honest limit: this is teardown-race hygiene, not a security boundary.
Values a consumer already extracted through the api are not revoked. A
function read off the proxy is (see above), but a nested object
(
p.config), a value returned by a member, and anything obtained by any other route are the provider's own objects and keep working. Deepening the proxy would not change that — a service can always hand out a live closure — and a mod that can call another mod's api can already do whatever that api does. Per-useconsumeis the recommended pattern precisely because it needs none of this. -
A throwing member is isolated: logged, and you see
undefined. So a consumer cannot distinguish "member threw" from "provider dead" from "no such member" — all three areundefined, which is what a soft seam costs. Only a synchronous throw is caught; a rejected promise a member returns is yours to handle. -
provideis the strict half, and the only one that throws: a non-empty string name and an object-or-function api are required, and re-providing a name this activation already has live raises the loader'sModConflictError— two halves of one mod claiming one name is a programming error, unlike consume's miss. A throw frominit()is already handled (the mod is rolled back). Callingprovidefrom an async continuation that resolved after the mod was switched off is not an error: it publishes nothing, quietly. -
Two mods providing the same name is a non-event, and a mod consuming
itself, or a name it also provides, just works: there is no global service
table to collide in — entries live on each activation's own record and
consumenames the provider. -
Feature-detect, and expect both or neither. They install as a pair:
if (ctx.provide)/ctx.capabilities.provide/needs: ['provide'], same forconsume. An engine withoutProxy/Reflect/WeakMapgets neither member — not aconsumethat would have to hand back the raw api and silently drop revocation — andctx.capabilitiesreports both absent, soneeds: ['consume']blocks with a reason. Note that aneedsdeclaration blocks the mod on a build that lacks the seam, which is whymods/theme/theme.jsdeliberately does not declare one: it degrades to "no pattern" instead.ctxVersionstays1. -
Keep the
typeofguard even with the seam. The theme example'stry/catchis not the oldReferenceErrorguard:ctx.consumeis absent on a build without the seam, where calling it is aTypeError, and the code around the call is your own. The proxy isolates only throws from inside the provider's member.
const off = ctx.commands.register('save', {
scope: 'window', // 'global' (default) | 'window'
when: function (where) { return where.win.kind === 'note'; },
run: function (args, where) { return save(where.win); },
}); // -> registered as 'scratchpad:save'
const r = await ctx.commands.execute('scratchpad:save', args);
// {ok:true, value} | {ok:false, reason:'absent'|'inactive'|'blocked'|'error'}A named, owned dispatch target, replacing the duck-typed fields core pokes
by name — scratchpad's Ctrl+S rode win._saveToServer, and help hoisted
functions to the top level on purpose because the mod system could not
contribute a keybinding target at all. Both failed the same way: a typo'd id, a
disabled mod's undefined field and a real failure are all "nothing happened".
Both have moved: scratchpad registers save, and help registers toggle and
refresh-corpus. The editor's win._saveToServer is the field core still
reads, from the unsaved-changes prompt.
The outcome vocabulary is the point, and it is closed and branchable:
reason |
means |
|---|---|
absent |
nothing ever registered that id in this page — a typo, a mod that is not installed, or one that has never been switched on this session (a disabled mod never ran register, so its ids are absent rather than inactive, and enabling it does fix them). |
inactive |
the id was registered by a mod that is not active now — switched off, mid-teardown, or torn down since. Fixable by toggling that mod on. |
blocked |
the command declined this invocation: when() said no, or it is window-scoped and no window is focused. |
error |
the command failed to answer — run threw, its promise rejected, or when() threw. |
execute always returns a Promise and that promise never rejects, because
its callers are a key dispatcher and a menu click handler: one bad command must
not take either down. Branch on reason; a blocked/error result also
carries a detail ('no-window' / 'when') or the error object, both
informational only — reason is the part that is closed and stable.
-
registertakes a LOCAL id and auto-prefixes'<modId>:';executetakes the FULL id. A local id containing':'is refused with a throw, which is what makes a cross-mod id unrepresentable rather than merely discouraged: modbcannot spell'a:save', and a mod helpfully passing its own'a:save'gets a throw at init instead of a livea:a:savenothing dispatches. A duplicate within one mod is aModConflictError.registerreturns an unregister function, and also rides the activation's unload chain, so teardown releases it whether you call it or not. -
A window-scoped command with no focused window is
blocked, and neitherrunNORwhenis called. It does not run withwin: null. Handing every window-scoped command a null it must re-check would rebuild, inside every mod, exactly theif (typeof win._saveToServer === 'function')guard this exists to delete.scopeis a precondition, and an unmet precondition isblocked. -
A
globalcommand gets{win: null}even when a window is focused.scopeis a contract, not a hint: passing the focused window opportunistically would make a global command quietly window-sensitive, and would make a laterglobal→windowchange a no-op that alters nothing until the day nothing is focused. A command that wants the focused window says so. -
A throwing
when()iserror, notblocked, andrunis not called. A guard that threw did not say no — it failed to answer, and mapping that onto a deliberate veto hides a broken guard as a normal "not right now" forever.blockedis a decision;erroris a defect. -
An async
when()is resolved, not coerced.!!promiseistrue, so the coerced version would let every async guard allow everything (shipped once already, as #168's async-validator bug). It costs the command its synchronous dispatch, though — see the next bullet — and after the guard resolves the activation is re-checked, so a mod torn down while its own guard was pending answersinactive. -
runis called synchronously, in the caller's tick; only the result is wrapped in a Promise. A command dispatched from a click or a keydown may need the user gesture (clipboard writes, fullscreen,window.open), so a gesture-gated command wants a synchronouswhen. -
A disabled mod's command answers
inactive, notabsent. Teardown does not delete the entry; it replaces it with a tombstone keeping the id and the owner and dropping the closures. Deleting would collapse "turn that mod on" into "that id does not exist". Mid-teardown counts: a command executed from an abort listener or another disposer already readsinactive. -
The menu path never calls
when()at render time to grey an item out. Two arbiters — a synchronous render-time guard and an asynchronous dispatch-time one — is how a menu shows an item enabled and then does nothing.executeis the single arbiter, at click time, so no mod code runs during a menu render. -
Feature-detect it:
if (ctx.commands),ctx.capabilities.commands,needs: ['commands'](or the finerneeds: ['commands.register']). Without a usableMap/Promiseor a per-activation record there is noctx.commandsat all rather than aregisterthat returns nothing dispatchable.ctxVersionstays1.
Keybindings and menu items can name a command instead of carrying a
callback, which is the motivating case for replacing win._saveToServer:
ctx.registerKeyActions([
{ id: 'save', label: 'Save', command: 'scratchpad:save' },
]);
ctx.registerWindowMenuItems(function (win) {
return [{ label: 'Save', command: 'scratchpad:save', commandArgs: null }];
});commandArgs (optional) is passed to the command as args. The two paths
differ deliberately when an item declares both forms: a key action with both
command: and run: throws at registration (inside init, where a throw
is a clean rollback and a clear message), while a menu item with both keeps
its action and does not throw — that pass runs at render time, where a throw
would cost the whole context menu. A command menu item defaults to
enabled: true. Both normalizations are identity-preserving: declare no
command: and the very same objects reach core.
const r = await ctx.http.fetch(hostId, '/info', {
method: 'GET', // default
json, // request body; sets content-type
timeoutMs, // TOTAL deadline — including the body read (default 15000)
signal, // composes with ctx.signal
});
// -> {status, json?|text?, error?} NEVER rejects.One sanctioned way for a mod to make an authenticated request to a broker —
this one or a peer. It wraps core's raw hostFetch (core keeps hostFetch
unchanged); it does not replace it.
-
hostIdis required, and a bad one throws synchronously. There is no same-origin default:'',null,undefinedand any non-string throw aTypeErrorat the call site rather than resolving to an error value. Passing no host is a program defect, and a mod that swallows every result shape must not be able to swallow it into an accidental same-origin write. Targeting this broker is spelled out: pass theidof theself: truerow fromctx.hosts.list(). -
A
pathmust be a route, not an authority. It must begin with/, and//…or/\…throws. This is not pedantry. The URL is built as(host.url || '') + pathand the self entry'surlis'', so'//evil/x'is not a route on this broker — it is a protocol-relative URL that resolves to another origin, andhostFetchwould attach this broker's bearer token to it. The realistic case is a mod building a path out of a value it did not write itself (a route name from a server response, a saved recording's id).hostIdchooses the target; apathmay never re-choose it. -
An unknown
hostIdfails closed with no request issued —{status: 0, error: 'host_not_found'}. That is a resolved value, not a throw, because a stale id is data (the user deleted a host between a poll and its retry). -
It never rejects, and
statusis always present. On a complete answerstatusis the HTTP status and there is noerrorkey at all — which is what keeps{status: 401, text: ''}and{status: 200, text: ''}different objects.errornever substitutes for an HTTP status.status: 0+erroris reserved for the cases with no answer to report: an unresolved host, a transport failure, a cancel, a deadline. A route a peer does not have arrives that way too (the preflight surfaces an opaqueTypeErrorwith no status behind it), which is exactly what cross-broker callers feature-detect on. -
timeoutMsis a TOTAL deadline, including the body read — and that difference is the reason this family exists. RawhostFetch's deadline stops at the headers:fetch()resolves the moment the status line lands, so a route that answers and then stalls its body hangs forever, and every mod that cared hand-rolled its ownAbortControlleraround the read.ctx.httpkeeps its timer armed acrossr.text()(and callshostFetchwithtimeoutMs: 0, deliberately disarming the headers-only one, so the two cannot race). A body that dies mid-stream is{status: 0, error: 'TimeoutError: timeout'}— never a{status: 200}with a truncated string.timeoutMs: 0(or any non-positive / non-finite value) opts out, and that opt-out is genuinely unbounded: there is no headers-only deadline left underneath. -
JSON is parsed only when the content-type says json and the parse
succeeds; otherwise you get
text. A body the peer mislabelled comes back astextfor you to decide about, and a plain-text42stays a string. -
signalcomposes withctx.signal— it is never replaced, and an already-aborted caller signal aborts beforefetchis reached, so no request leaves a dead activation. The #198 pairing rule applies here specifically: work that must outlive the activation takes no signal.recorderpasses it nowhere, because its save path is reached from anonUnloaddisposer; an already-aborted signal there would drop the segment on every disable-with-a-live-recording. -
No auth side effects. A 401 comes back as a 401. Nothing in
ctx.httppops the auth overlay or touches the auth bookkeeping — a background poll opening a modal was the #161 bug class. Only an explicit user action should re-prompt; recovery belongs to the mod. - A body on a
GET/HEAD, and ajsonthat does not serialize (including one that quietly stringifies toundefined— a function, a symbol, atoJSONreturningundefined), also throw. -
Feature-detect it:
ctx.http && typeof ctx.http.fetch === 'function',ctx.capabilities.http,needs: ['http']. A build with no constructibleAbortControllercannot honour the total deadline, so it gets noctx.httpat all rather than one whosetimeoutMssilently does nothing.ctxVersionstays1.
Whether to declare needs: ['http'] is a loss question, not a taste
question. recorder declares it (and is defaultEnabled): every byte it
owns lives on a broker, so without ctx.http a capture would still arm, fill
memory for an hour and have nowhere to go — data loss dressed up as a working
feature. needs blocks the mod, and blocked-with-a-reason is the better
failure. Contrast help and theme, which deliberately decline a needs for
the surfaces they use: their loss is partial, and a needs would block the
whole mod over a degraded corner.
ctx.hosts.list();
// -> [{id, label, url, brokerId, self}] frozen snapshotA read-only snapshot of this browser's host registry. Every row is a fresh
frozen object of copied scalars and the array is frozen too, so you cannot
reach the live record through it and a later registry edit cannot mutate a
snapshot you are holding. There is no token on a row: it is not identity,
no mod needs it to key a cache, and ctx.file / ctx.http already carry it
for you without showing it. Mutation stays with core (the Hosts form) and
ctx.hosts.invalidate.
Identity is two fields, and neither pretends to be the other:
-
id— the local registry handle. Stable for this browser's list and meaningless anywhere else:'local'here and'local'in another browser are two different brokers. Use it to address a host through the rest of thectx— it is what you pass toctx.http.fetch. -
brokerId— cross-URL identity: the broker said this, over/info, and it is the same string under every address that broker answers on. Use it as a cache key, or to notice that two rows are one machine.
brokerId is null until the host has answered. Not a provisional hash of
the URL, not the URL, not the id — null. A guess later swapped for a
verified value is worse than an absence: every cache keyed on the guess
survives the swap while pointing at the wrong broker. "Unverified" is a real
state (an older broker with no /info, a wrong token, a host that is simply
offline), it can last the whole session, and a caller branching on identity
must handle null explicitly rather than falling back to the URL — that
fallback is precisely what update's old hostFingerprint = host.url did
wrong. The same broker at 127.0.0.1, localhost and its Tailscale address is
three "fingerprints"; a URL repointed at a different broker keeps the same
one.
A URL repoint resets brokerId to null, and it is never carried over.
Carrying it would label a new — possibly hostile — endpoint with the old
broker's verified identity. After a repoint the field goes null and then either
comes back the same (one broker under a second address) or comes back
different (a genuinely different broker) — the transition URL-as-fingerprint
could not represent at all.
Neither field is ever a merge key for registry entries. That is #174's
lesson and it cost a host-repoint vector: matching an incoming entry's id
against a local record and then updating the local URL from it let anyone who
could write the shared blob re-point a broker entry at a machine they
controlled. brokerId is no safer for that job — it is a string a remote
endpoint chose to say. Both fields are for change detection and cache keys on
this page only; a mod that syncs host records must key its merge on something
the user authorized.
Exactly one row has self: true — the local broker (id 'local', url
''). Its brokerId is learned by a different path from every other row, but
the same null-until-answered rule applies, so no "is this me" branch is needed
beyond the flag:
function selfHostId() {
for (const h of ctx.hosts.list()) if (h.self) return h.id;
return null;
}list is a member of the existing hosts family (#195 created it for
invalidate), so it owes no capability entry of its own: feature-detect with
typeof ctx.hosts.list === 'function', or declare the finer
needs: ['hosts.list']. ctxVersion stays 1.
These are members of the info bag ctx.windows.onTerminalCreate hands you.
They are the sanctioned replacement for monkey-patching an xterm instance:
before them, a mod that wanted to see terminal traffic replaced term.write by
assignment, a scheme whose own comment in mods/recorder/recorder.js admitted
two patchers cannot coexist. Recorder now captures through these taps (#218).
ctx.windows.onTerminalCreate(function (info) {
const off = info.tapOutput(function (data) { /* bytes that were written */ });
info.tapInput(function (data) { /* bytes that were sent */ });
info.onResize(function (d) { d.cols; d.rows; });
info.onModesChanged(function (modes) {
modes.mouseTracking; // 'none'|'x10'|'vt200'|'drag'|'any'
modes.mouseActive; // mouseTracking !== 'none'
modes.altScreen; // the alternate buffer is up
});
});Each registrar returns an idempotent off(). A non-function argument is a
silent no-op that still hands back a callable off() — the posture
onTerminalCreate itself takes.
They are observers, and that is enforced rather than requested. Every tap
fires strictly after the underlying write or send has been dispatched, and
the output dispatch sits inside the same try as the write — so a write that
threw never reports its bytes as delivered. A mutable payload is copied
per tap, so tap #1 cannot rewrite what tap #2 observes: a typed-array view
is slice()d, and so is an array, which is the case that mattered — the
JSON output frame is server-supplied, so data.data can be [65], and xterm
is still holding that same array for its asynchronous write queue. A payload
that is neither string, view nor array is handed out as null rather than
as a live reference; "I cannot show you this safely" is truthful where a shared
reference is not. Taps run in registration order over a snapshot with a
liveness recheck, isolated per callback: a throwing tap loses only the rest of
its own callback, and the terminal never sees the throw.
tapInput fires per wire frame, after the send. Input is chunked into
≤256 Ki-char frames before it goes out, and the tap fires once per frame, so it
observes what actually left rather than what was about to. That is also what
makes it cover the hand-bracketed ConPTY paste path (#138), which goes out
through the same sendChunked('input', …) call and not through term.paste().
No history replay. onTerminalCreate replays over terminals that are
already open, so a tap registered from that replay sees traffic from then
on. Reconstructing the scrollback that came before stays the recorder's job
(its serialize-addon keyframes) — do not expect a tap to hand you the past.
onResize is fed from xterm's own resize event, not from core's
term.resize(cols, rows) call site. Both spellings would cover the
broker-confirmed resize, but only this one also covers a mod calling
fitAddon.fit(), which resizes inside xterm with no core call site involved.
It fires once per applied resize whichever path drove it, and each tap gets a
fresh {cols, rows}.
onModesChanged replays the current snapshot synchronously on subscribe.
Otherwise every subscriber would have to re-invent an initial sample, which
matters because onTerminalCreate replays over terminals already running an
app that may not write again for minutes. A terminal that has never seen a mode
reports the real read of xterm's defaults ('none' / false / false). An
unreadable getter replays nothing at all — announcing 'none' for a
terminal that may well be mouse-active would make the next successful sample
report a transition out of a state core never observed. A subscription on an
already-disposed terminal is refused outright.
It fires on group transitions, not per-flag flips. xterm's DECRST of any
mouse-tracking mode clears the whole group — the vendored build keeps a single
active protocol, not independent 1000/1002/1003 flags — so per-flag events are
not expressible here. mouseTracking is the group's resolved value:
'vt200' → 'any' is one event, and any DECRST that drops the group is
exactly one event to 'none'. It follows for free that RIS (which resets the
mouse service) reports a transition to 'none' while DECSTR (which does not)
reports nothing — the sampler reads state after the parse and never
interprets the escape bytes.
The honest limit: it is coalesced, and it is a state notification, not an
event log. The sampler only arms a requestAnimationFrame, so two changes
inside one frame collapse into a single delivery of the final state; and
requestAnimationFrame does not run in a hidden tab, so any number of changes
made while the tab is hidden collapse into one delivery when it is shown again.
The subscriber sees the final state, never the intermediate ones. Because these
events describe persistent state, a missed intermediate is not a lost fact —
but nothing here is a suitable trigger for one-shot side effects.
Lifecycle: auto-removed at terminal dispose and at mod teardown, both
armed at the moment of registration. win.cleanups fires only on window
close, so the teardown half is what stops a disabled mod from leaving taps
running on every terminal that is still open.
Core never patches the instance. Core hooks its own call sites and
nothing here touches term.* by assignment — onModesChanged is a
subscription to xterm's public onWriteParsed, onResize to its public resize
event. That kept the old patching scheme working while recorder still used it:
recorder checked "is term.write still the function I installed" before
restoring it, and a core wrapper on the instance would have made that check
false. One consequence worth stating: a tap never sees anything a mod
writes straight to win.term (recorder's own playback included), because that
is not a core call site. docs-terminal-funnels.md enumerates the boundary and
its named limits.
ctx.windows.onTerminalCreate(function (info) {
const d = info.cellDims(); // {width, height} | null
info.setFont('Iosevka, monospace');
info.setFont(null); // drop MY override
});
ctx.terminals.defaults.fontFamily; // the core baseline, frozencellDims() returns null before the first render, and on a disposed
terminal. There is no fallback and there must never be one: the hardcoded
9×17 that mods/recorder/recorder.js falls back to is the anti-pattern this
replaced — that is not a measurement, it is a number that happens to be close
on one machine. A zero width or height is pre-render, not a reading, and is
reported as null too. A disposed terminal answers null even though xterm can
leave its last dimensions object resident, because a stale measurement
presented as a current one is the same class of lie. A caller that needs a
cell box before anything has rendered has to handle null. Asking is also
non-destructive: nothing is cached into settings on the way out.
setFont is last-writer-wins with an owner record. Core keeps, per
terminal, the ordered list of which mod set which family; the last entry is
what is on screen. Ownership is by mod id, so calling setFont twice on one
terminal updates your own entry rather than stacking. The record is the whole
point — it is what makes the revert chain correct:
- disabling the last writer reverts the terminal to the earlier surviving writer;
- disabling all writers reverts it to
ctx.terminals.defaults.fontFamily; -
setFont(null)(or any empty / non-string family) is the same code path as a teardown, so a mod cannot get a revert core's own unload does not also get.
A plain "remember the previous value and restore it on unload" scheme — which is what a mod can implement for itself — gets both interesting orders wrong: two font mods on one terminal either strand a dead mod's font on screen or stomp a live mod's font back to the baseline, depending on which one is turned off.
A dead activation cannot take ownership. setFont from a stray callback
after your mod was disabled is refused, because teardown's release is one-shot:
re-creating the entry afterwards would leave a live terminal wearing a dead
mod's font permanently. The release is armed on both ends (dispose and mod
teardown) at the moment the override is set.
Core owns the apply: the family is written to term.options and followed by
core's own refit, once, so the revert path is the same code as the set path and
cannot drift from it.
ctx.terminals is a new top-level family and carries its own capability
entry (terminals, version 1) — feature-detect with ctx.terminals, or declare
needs: ['terminals.defaults']. The four info members above are members of
the existing v1 windows family and owe no capability entry of their own:
feature-detect with typeof info.tapOutput === 'function'. ctxVersion stays
1.
Another device can take over one terminal's input and size (#238; the first
such client is Notifier on a phone). While it does, the terminal's desktop
window is greyed and holds no socket (#239), so every surface above goes
quiet: the taps see no traffic and onResize sees no resize. These two members
say why, per terminal:
ctx.windows.onTerminalCreate(function (info) {
info.control(); // {state:'free'} | {state:'controlled', label, since, gen}
const off = info.onControlChanged(function (c) { /* same shape */ });
});onControlChanged replays the current state exactly once on subscribe,
then reports each change, so a subscriber arriving mid-control is told at
once. It is a level surface like onModesChanged, but nothing is sampled or
held for a frame: it fires once per change core applied.
It fires after core has applied the change. On controlled the window's
socket is already closed. On free the reattach has already been issued. A
window popped out into its own browser window (#226) is told too, but is not
reattached by this page — its own page does that.
Every subscriber hears the same sequence. If a handler causes a change of its own, the remaining subscribers are still told the state the pass started with, and then everyone is told the new one. No subscriber is told one state twice.
The payload is frozen and shared. Between deliveries, info.control()
returns the very object the last delivery carried. {state: 'free'} has no
label; since is epoch milliseconds or null; gen orders changes within
one broker run and nothing more. info.control() is a live read, though:
if a handler's own change (or an earlier subscriber's) makes the state being
delivered stale, info.control() already answers the newer state inside that
handler — and the next delivery brings exactly that object. label is the controlling device's own text: render it with
textContent, never as HTML.
Lifecycle: auto-removed at terminal dispose and at mod teardown, both armed
before the replay runs, so a replay that disables its own mod leaves nothing
behind. From a disabled mod onControlChanged registers nothing; control()
is a plain read and still answers.
Both are members of the v1 windows family: feature-detect with
typeof info.onControlChanged === 'function'. There is no capability entry and
no ctx.events name (the event is per terminal), and ctxVersion stays 1.
Resizing. The mod system has no way to set or hold a PTY size.
info.onResize observes the page's xterm grid, info.cellDims() measures
cells and info.setFont changes the font and lets core refit; none of them
asks for cols/rows. Core's own sendResize(win, force) and isResizable(win)
are plain functions in core's script, not members of ctx, info or win.
Like every core global they can be called by bare name from a mod (§1), which
is outside the contract — and on a controlled terminal sendResize does
nothing: isResizable answers false, and the window holds no socket
(win.ws is null). What info.win itself exposes is the page's own xterm,
win.term and win.fitAddon. A mod can resize that through win
(win.fitAddon.fit(), win.term.resize()): it fires onResize and changes
only the local grid — the PTY never hears of it, and the controller's size
stands. No size reaches mods from the controller: the onControlChanged
payload says who holds the terminal, not how big it is. On release, the
reattach asks for the browser's size, but onResize fires only if the
page's grid actually changes — usually it does not, because the page's grid
stood still while the terminal was controlled. Requesting a size would be a
separate info.requestSize design with its own ownership rules (like
setFont's owner record).
const d = ctx.dialog.open({
title: 'Unlock registry',
fields: [{ name: 'pass', label: 'Passphrase', type: 'password' }],
submitLabel: 'Unlock', // default 'OK'
cancelLabel: 'Not now', // default 'Cancel'
}); // the handle comes back SYNCHRONOUSLY
d.replace(spec); // re-spec THIS dialog
d.close(); // dismiss THIS dialog
const vals = await d.result; // {pass: '…'} | null (dismissed)A field is {name, label, type, value, placeholder}. A field with no string
name is skipped. type: 'select' renders a <select> from options
('a' or {value, label}); type: 'password' renders a real password input;
every other type falls back to text, so a spec cannot smuggle an
arbitrary input type onto the page. result resolves to an object of
name -> string on submit, and null on cancel, dismissal, or any teardown.
Mod-facing opens ALWAYS queue. There is no cancel-the-current-dialog mode
on this surface at all — not a flag, not an option — which is the whole point:
one mod can never dismiss another mod's prompt. A second open() while one is
on screen waits its turn and is shown when the first settles. Core's own
openDialog is a singleton that cancels whatever is live, so a mod that
prompts through it has to keep its own prompts from overlapping, or it
silently dismisses its own first prompt. That workaround class is what this
surface removes. (mods/host-registry's _registryActionBusy flag looks like
one and is not: it serialises a whole registry action, a wider window than a
dialog on screen, so this queue does not replace it.)
The named limit: core still wins. openDialog is unmodified, so a core
dialog still cancels whatever is live, including a mod's — and that mod's
caller reads null, indistinguishable from a user dismissal. The asymmetry is
deliberate: core is the application, a mod is a guest. Its consequence is that
the queue also refuses to show anything while a foreign (core) dialog is up; it
waits politely rather than cancelling it.
The handle is returned synchronously, before display, so close and
replace exist for the whole queued life of the dialog and not only once it is
on screen — and each acts on this dialog only. Closing a queued entry drops
it (it is never shown, and its result resolves null); closing the shown
entry finishes only that dialog; replace on a queued entry just swaps the
spec it will eventually be shown with, and on the shown one re-renders it
without ever releasing its turn, so a replace cannot let another mod's queued
dialog jump in front of it.
A dead mod never wedges the queue. On teardown that mod's queued entries
are dropped and its on-screen dialog is closed, so the next owner's entry is
shown immediately; every dropped entry resolves null, the same value a
dismissal produces. An open() from an already-dead activation is refused
outright (a handle whose result is already null) rather than queued, so a
dead mod's dialog cannot flash on screen.
Password values reach exactly one place: the caller's d.result. Core
never logs them, never persists them (no prefs / state write lives on this
path), never puts them in the help corpus, and drops its own reference to the
inputs the moment the dialog settles. A commit that races a teardown resolves
null rather than a half-read object.
dialog is a new top-level family with its own capability entry
(dialog, version 1) — feature-detect with ctx.dialog, or declare
needs: ['dialog.open'].
ctx.mods.list(); // [{id, active, pin, version, tiers}]
ctx.mods.isActive('git'); // true | false
ctx.mods.pinOf('git'); // true | false | null
ctx.settings.describe('git', 'branchStyle'); // {type, options, default}|null
ctx.settings.describe('branchStyle'); // own-mod shorthand
ctx.helpCards.list(); // the sanitized typed-span card DATAThese replace the window.__mods scraping the shipped mods used to do:
mods/help now reads cards through ctx.helpCards.list() (the raw registry
only as a fallback) and mods/mod-sync reads pins through ctx.mods.pinOf;
mod-sync still walks __mods.registered / settingToggles and is the
remaining scraper. window.__mods is a test fixture, not a contract — it is the
loader's own mutable bag, and it carries live records (unload arrays, control
read/reflect/onChange closures, live DOM sections). Reading it reaches
into core's internals and pins every field of them as a de-facto API.
Everything here returns a fresh, FROZEN clone, built per call out of
primitives. Fresh means a caller cannot reach core state through what it got;
frozen means a mutating write is a visible no-op rather than a local copy that
silently drifts from the truth. The next call returns a different object graph,
so nothing is shared between two callers and nothing is cached. The shapes
are contract now: adding a field later is additive, changing or removing one
is a break — so they are deliberately minimal. No unloads, no section, no
closure, no Map and no DOM node ever leaves this surface. mods.list()
likewise omits ctxVersion, defaultEnabled, requires and init.
describe is two-arg, describe(modId, key), because the caller must be
able to name whose setting it is asking about — the motivating caller
describes other mods' settings, never its own. describe(key) is the own-mod
shorthand and is chosen by argument count (exactly one), so
describe(a, undefined) is a two-arg call with no key and answers null.
It returns null for an unknown key, an unknown mod, or a mod that is off
(a disabled mod has no mounted control, which is also the answer the Control
Panel gives) — never a throw and never a half-filled object with a made-up
default.
A text descriptor's options are SUGGESTIONS, not a domain. They are
reported because the pane renders them; a caller must not use them as a
validation set. The type says which it is: 'text' is the one kind whose
options do not bound it. For radio / select / combo the options are the
domain, and default is the declared fallback the primitive itself would use.
Introspection is LOCAL-PAGE ONLY. It answers about this page: the mods
this build registered, the pins this broker resolved at boot, the controls
mounted in this Control Panel. A remote broker's pins still come off the wire
(GET /mods/policy) and must keep doing so — there is no remote form of any of
this on purpose, because a getter that silently answered about a different host
is the worst possible shape for a sync tool.
Two limits worth reading twice:
-
pinOfreturningnullmeans "no pin in force", NOT "no such mod". An unpinned known mod and an id nothing ever registered answer identically, as the loader's own pin lookup does. Existence islist()'s question. -
An installed-but-not-loaded package is ABSENT from
list(), not listed as inactive:list()enumerates what actually registered on this page, so a package that 404'd, cycled, or registered the wrong id simply is not there. The union catalog/status model is a different question with a different answer, and a frozen contract may not blur the two.
list()[i].active and a later isActive() for the same id may disagree —
both read live state at call time, and a snapshot is a snapshot.
helpCards.list() reads the card registry live, so a card contributed by a mod
that has since been disabled is gone (ctx.registerHelpCards splices its own
entries out on teardown). Every card is re-coerced to strings on the way out,
so what you get is structured-cloneable data.
mods and helpCards are new top-level families with their own capability
entries (version 1 each). settings.describe is a new member of the
existing v1 settings family and owes no entry of its own — feature-detect
with typeof ctx.settings.describe === 'function', or declare
needs: ['settings.describe']. ctxVersion stays 1.
const p = ctx.popover.anchor(node, anchorEl, {
placement: 'bottom-start', // | bottom-end | bottom | top-start
// | top-end | top (default bottom-start)
gap: 2, // px between anchor and node, clamped 0..64
onClose: function (why) {}, // 'close' | 'outside' | 'escape'
// | 'anchor-gone' | 'node-gone'
// | 'teardown'
});
p.close(); // acts on THIS popover only
p.reposition(); // re-measure now (after a synchronous fill)
p.isOpen(); // false once anything has dismissed itCore appends the node to document.body, gives it the mod-popover class and
position: fixed, measures it off-screen for one frame so it is never painted
at 0,0, and removes the node itself when the popover closes — a mod that is
gone is in no position to remove anything.
Anchoring hands the node to core, and that is unconditional: a node that
already lives inside your own window is moved to the body, not left where it
was. This is not tidiness — placement is in viewport coordinates, and only
html/body are guaranteed not to be a containing block for position: fixed
descendants, so a node left inside a transformed ancestor (an app window
mid-drag has one) would be positioned against the wrong origin. Build the node
detached and hand it over; do not expect it back where you put it. The
symmetric half is that core removes what core inserted.
The popover also closes with 'node-gone' if your node leaves the page
while the anchor is still on screen. Without that check the entry would be
immortal — isOpen() true, onClose never called, the document listeners
still bound, and the frame loop measuring a detached element for the life of
the page. The handle comes back synchronously
and always: a refused anchor (dead activation, missing node or anchor)
answers with an already-closed handle rather than a null, so a caller never
has to branch on whether it got one.
There is NO motion at all — no entrance transition, no exit transition, no
keyframe, and nothing in this range reads matchMedia. This is not "animate
unless prefers-reduced-motion", deliberately: anything gated on that query is
simply instant for a viewer who has it set, so it reads as broken-or-fine
depending on who is testing (the precedent is stated in 15_css_dialogs.css).
A popover appears and disappears, identically for everyone.
It re-measures every frame while at least one popover is open, which is one
loop instead of a resize listener, a capture-phase scroll listener and a
ResizeObserver — three partial answers to the same question. That single pass
is what covers a window being dragged (which fires neither resize nor
scroll), an inner container scrolling, and a node the mod refilled after
anchoring. It costs nothing when nothing is open (the loop is not running) and
nothing in a background tab (rAF does not fire).
Placement clamps with a FLIP: a bottom-placed popover that would run off the bottom is put above the anchor instead (and vice versa), because sliding it up would cover the very control it points at; only the remaining overflow is clamped, to a 6px viewport inset. The horizontal axis is a pure clamp.
Dismissal is judged per popover. Outside-click uses one document listener
for the whole page and tests each popover against its own node — with two
mods' popovers open, a click inside one closes the other and leaves the one it
landed in. The anchor counts as inside, so a toggle button does not
close-then-reopen and never close. Escape closes the topmost popover only
and swallows the key, so stacked popovers peel one press at a time. An anchor
that leaves the page closes its popover with 'anchor-gone' rather than
leaving a box floating at coordinates that no longer mean anything.
anchor() on a node that is already in an open popover replaces that
entry — two entries would fight over the same left/top every frame.
Teardown is per mod activation (ctx.onUnload), not per window: a popover
is not owned by a window at all — a taskbar-dot preview has no window anywhere
in the chain — so every popover an activation opened closes on a disable, a
reload and an uninstall alike, with why === 'teardown'.
popover is a new top-level family with its own capability entry
(popover, version 1) — feature-detect with ctx.popover, or declare
needs: ['popover.anchor'].
const u = ctx.assets.url('sprites.css');
// installed mod: '/mods/<id>/<gen>/sprites.css'
// shipped mod: undefined -- feature-detect the RETURN VALUE
if (u) { /* fetch it, inject it, hand it to a worker */ }An installed mod already has a content-addressed serving pipeline behind it —
GET /mods/<id>/<gen>/<name>, the same route the loader uses to inject that
package's scripts and styles — and until now no handle on its own bytes.
ctx.assets.url is that handle, and it rides the existing generation
machinery: the <gen> comes from this page's load record and the URL is built
by the same builder the <script src> injection uses, so a mod's asset URL and
its script URL can never disagree.
A shipped mod gets undefined, on purpose. A shipped mod is spliced into
the one inline <script> at assembly time and its files live in the shipped
tree, which no route serves per file. Adding one would extend forced-public
serving to the shipped tree — a new exposure decision that gets its own issue,
not a side effect of this one. Faking it with a relative path would be worse:
that "works" in a hand test and 404s in the page. The assets family is
present either way, for shipped and installed mods alike, so feature-detect
the value and not the family — otherwise every caller writes two detections
for one fact.
Never persist a URL across an upgrade. The <gen> segment is the
package's content hash and the route serves immutable, max-age=31536000, so
an upgrade publishes a different URL and the old one stops resolving once
that generation is reaped. A URL stashed in ctx.prefs, ctx.storage or
/state keeps working right up until the mod is updated and then 404s with no
error anyone can attribute. Call url() at the point of use, every time — it
is a property read and a string concat, and there is nothing to memoize.
It answers only names the route will serve. The rule is membership, not
sanitisation: a name is answered only when it is a key of the package's own
integrity map — which the broker builds from that generation's files filtered
by content type — so an unservable name is unrepresentable rather than defended
against. A name grammar ([A-Za-z0-9][A-Za-z0-9._-]{0,63}, plus a .js/.css
suffix) refuses first, before the map is consulted, because the route is
forced-public and a plausible-looking URL that escapes the package directory is
the case worth two refusals. Everything below is undefined, uniformly, never
a throw:
- traversal or any embedded slash (
'../../etc/passwd','a/b.js'), and a leading slash; - a query string or fragment (
'a.js?v=2','a.js#top'); - an empty name or a non-string — there is no coercion, so
url({})cannot build'…/%5Bobject%20Object%5D'; - a name the package does not ship, or one it ships that the route cannot serve
(
help.md,mod.json); - a package with no usable generation.
There is deliberately no url(modId, name) form: the lookup is keyed by
the ctx's own id, so a mod can only ask about its own package. One mod
addressing another's assets is a different feature with a different exposure
argument.
The route is forced-public: no secrets in assets, ever. A <script src>
cannot carry an Authorization header, so /mods/<id>/<gen>/<name> serves
without a token by design. Every byte a mod ships there is readable by anyone
who can reach this broker and knows the id, the gen and the file name — ship
code and static data, never a credential, a host list, or anything derived from
one. And because the answer comes from this page's boot record, a package
uninstalled or upgraded mid-session still answers with the generation this page
loaded (the running code is that generation); the bytes may nevertheless be
gone from the store, so a fetch of a returned URL can 404 at any time — which
is the other half of why you must not persist one.
assets is a new top-level family with its own capability entry (assets,
version 1) — but a shipped mod has the family and no URLs, so
needs: ['assets.url'] gates on presence, not on usefulness. ctxVersion
stays 1.
Every ingredient below already ships. This is the recipe, written down once, with its traps named — apply it to a shipped mod or an installed one; nothing here is install-only.
-
Start an alt-port broker with
cwd= repo root, orPYTHONPATHpointing at it. If neither holds,import webtermcan resolve to a different install onsys.path— some other checkout, or a package installed into the interpreter — and the broker you just started serves that copy's assets. You edit a fragment, reload, see nothing change, and the instinct is to suspect the edit. It is almost always this. -
INDEX_HTMLis built once, at import (ui.py'sassemble(), held at module scope rather than per-request —INDEX_HTML = assemble()inui.py). A fragment edit on disk is invisible to an already-running broker; it needs a restart. Before debugging anything else,curlthe page you are testing against andgrepfor the string you just edited — if it is not there, you are looking at stale bytes, not a bug. This one check saves more time than everything after it combined. -
Navigate with the auth token and
?nomods=1. The flag makesloadMods()return before it fetches or inits a single mod (_nomodsRequestedin86b_js_mod_packages.js) — zero catalog fetches, zeroregisterModcalls, a clean desktop with nothing riding on load order or another mod's side effects. Start every test page here, not on the real desktop with 19 shipped mods already up. -
Drive the fixture through
window.__mods.__test.run, not a parallel harness:const rec = window.__mods.__test.run({ id: 'x-fixture', ctxVersion: 1, init(ctx) { /* … */ }, });
runis a thin call ontoinitMod(__test.runin86_js_mod_loader.js) — the exact function real boot and a live install both call. There is no separate "test mode"initModto drift out of sync with the real one; a fixture that passes here passes for the reason production code would accept it, not because a stand-in was lenient. -
Assert through the read-only inspectors —
isActive(id),statusOf(id)(name it as such; the Mods-pane status/warningsshape),themeSubscribers()— and tear down withdisable(id), then assert the fixture'sonUnloads actually ran (a dropped subscriber count, a removed DOM node, whatever the fixture registered). Do not userun/disableto test persistence or the theme/announcement channel. Both bypasssetModEnabledand the post-cascade announcement pass by design — they exist to driveinitMod/disableModin isolation, not to exercise the enable-persistence or notification paths a real toggle goes through (86_js_mod_loader.js:936-940). A claim about persistence (does the pick survive a reload / sync across tabs) or about the announcement channel (doesctx.theme.onChangefire, does a subscriber count move) needs__test.setEnabled(id, on)or an actual Mods-pane click instead — those ridesetModEnabled, the path a real operator uses.
?nomods=1 earns a second mention here for the same reason §11.3 gives it
one: it is written up elsewhere (§11.3) purely as the rescue hatch for a
bricked desktop. Steps 3-5 above are what make it a testing tool as well —
the same read-only, never-persisted flag, used deliberately instead of
stumbled into.
A mod may ship a help.md beside its mod.json, in the same wiki Markdown the
wiki/ pages use. help_corpus.build_mod_sections() parses every mod dir that
has both files, turns it into a Help section tagged with the owning mod id
(so the frontend hides it while the mod is disabled), and
build_full_corpus() merges it with the wiki corpus, sorted by
(order, slug). A slug that collides — with another mod or with a wiki page —
is a hard BuildError.
webterm/broker/help_corpus.json is the packaged, tooling-generated
fallback, and it bakes the shipped mod sections in. It is never hand-edited.
An installed mod's help.md is captured into the broker's index at install
or scan time and layered onto the served corpus at serve time only — never
into the packaged JSON, which is why the regenerator's output does not depend on
what happens to be installed on your machine. It is also the one part of an
install that does appear without a page reload.
If you add, remove or edit any
help.md(or thehelpblock in amod.json), you must run:python -m webterm.broker.help_corpus
tests/test_help_corpus.py::test_packaged_json_in_sync_with_wiki regenerates
the corpus and asserts a byte-exact match against the checked-in file. It
fails on the smallest drift, and the fix is always to run the regenerator, never
to edit the test.
help.md is optional. Skip it when the UI already explains the whole feature —
a mod that is one Control Panel control has nothing a Help page would add.
That is an authoring judgement, not a rule.
An id is reserved for first-party (shipped) mods iff it does not start with
x-; an installed mod's id must.
The id shape is the same on both sides — _MODSTORE_ID_RE in app.py and
MOD_ID_RE in the page (50_js_constants.js), both
[a-z0-9][a-z0-9-]{0,63} — and x-notes
already matched it before the rule existed. That is the point: the rule is a
prefix convention layered on top, so no key a shipped mod owns moves, and
neither validator has to change.
What the one-character prefix buys is that a single lexical test separates all five namespaces a mod id keys, because all five derive from the same string:
-
/mod-store/<id>— the durable per-mod server store; - the per-broker mod-policy pin map (
webterm_mod_policy.json,POST /mods/policy); - the
webterm:mod:<id>:localStorage prefix behindctx.storage; -
webterm:mods:disabled, the per-browser enable-override set; - the catalog —
ui._MODS/mods/<id>/on one side,<mods_dir>/<id>/on the other.
It is enforced in three places, and a change to the rule has to update all
three: the install validator (400 reserved_id), the scanner (a non-x-
directory in mods_dir is skipped with a loud log, so a hand-dropped clock/
cannot shadow the shipped clock), and CI (no shipped id starts with x-, no
shipped requires names an x- id).
x-<author>-<name> (e.g. x-johnconnornpc-notes) is a documented
convention, not an enforced field. Nothing validates the middle segment and
there is no vendor key in the manifest. Carrying a real scope component in the
id would mean widening both validators, the policy sanitiser and the mod-store
key filter in lockstep — four places to keep synchronized, for a component
nothing would enforce anyway.
Not covered, plainly: DOM ids a mod writes in its own markup. The
loader-generated ids are built from the mod id and so are namespaced; a mod that
writes id="clock-chip" itself is not. That is authoring convention, exactly as
it already is for shipped mods.
There is no automatic migration of pre-existing x- state. /mod-store and
the pin map already accept any id-shaped key, so a browser can hold
/mod-store/x-notes, a pin for x-notes, and webterm:mod:x-notes:* before
anything called x-notes was ever installed. Installing it adopts all of it.
The install response says so — adopts_existing_state: {mod_store, pin} — for
the server-side half; localStorage cannot be inspected server-side and is not
covered.
An installed mod is loaded as a separate classic <script src>, not as an
import() and not spliced into the bundle. Classic (not module) is deliberate:
a module would impose module scope plus strict mode and would not publish its
top-level function declarations. CSP 'self' authorises both identically, so
this is a source-mobility decision, not a security one.
A spliced fragment and a separately-loaded classic script are not equivalent. The real differences:
| spliced into the bundle | loaded as <script src>
|
|
|---|---|---|
"use strict" at the top |
a plain string expression — inert | a directive — the whole file is strict |
| declaration instantiation | all of the inline script's declarations exist before any of its statements run, so your top-level function is visible to code earlier in the bundle | your declarations do not exist until the file is fetched and executed |
| execution timing | page-script eval, before loadMods()
|
after /info, asynchronously, possibly after boot |
document.currentScript |
the one inline <script> (no package stamp) |
the injected element, carrying dataset.modPackage
|
a literal </script>
|
fatal — closes the element, breaks the page | harmless |
a const/let/class name collision |
a compile error for the whole bundle — blank desktop | a compile error for that script only — one dead mod |
So the contract, if you want source that moves in both directions unchanged:
-
Nothing at top level runs except the
registerMod({…})call. No DOM writes, no fetches, no timers, no reads of desktop state — all work ininit(ctx). Plain declarations are fine (see rule 5); side effects are not. - No top-level
"use strict"directive. -
Nothing outside your package may depend on your top-level names. A
top-level call into your functions from another fragment cannot work: as an
installed package, your declarations do not exist when the bundle evaluates.
Cross-mod calls at runtime are fine, but only once the provider is
active — declare it in
requiresand do not assume every package has executed by the time boot finishes (the load deadline explicitly lets boot proceed with a script still in flight). This is why some shipped mods are not portable as-is: core's app-window store callsopenNoteOrEditorWindow, which theeditormod declares at top level. - No literal
</script>. -
Prefix every top-level
const/let/functionname. In-tree a collision with core kills the page; out of tree it kills your mod.
Rules 1, 2 and 4 are enforced by the portable-mod lint in
tests/test_ui_assets.py, and every shipped mod passes them. Rule 3's known
in-tree exceptions are pinned there too, as an exact set of three edges:
| top-level name (owner) | reached from |
|---|---|
openNoteOrEditorWindow (editor) |
core 54_js_app_windows_store.js, sticky
|
loadCodeMirror (editor) |
scratchpad |
The set is a drift guard both ways — a stale edge fails as loudly as a new
one — so decoupling a mod forces it to shrink. It has: #177 retired
agent-docs (see webterm/broker/mods-deprecated/README.md), which took two
edges with it, editorFile (editor) ← agent-docs and
openAgentDocsWindow (agent-docs) ← editor. The surviving call site in
editor.js is a typeof openAgentDocsWindow === 'function' guard, which owns
no shipped name and so is not an edge. #199 moved applyPattern (pattern)
← theme onto ctx.provide / ctx.consume, and #213 moved help's three
edges from core (findHelpWindow, refreshHelpCorpus, toggleHelpWindow)
onto the help:refresh-corpus and help:toggle commands.
So editor cannot be republished as an installable package unchanged. Rule 5 is not enforced — check it yourself.
The lint is a floor, not a proof: it reads source text, so it cannot see a
call hidden inside registerMod's own argument.
POST /mods/install, /mods/uninstall and /mods/rescan change what the
broker serves immediately — the catalog, the asset routes and the Help
corpus all swap at once — but they change nothing in an already-open
desktop. Install and uninstall say so in their own response, with
"applies": "next_page_load".
This is forced, not unimplemented. JavaScript global lexical bindings cannot
be removed. A mod whose top level says const DB = … cannot be re-executed in
the same page: the second execution dies with
SyntaxError: Identifier 'DB' has already been declared, and a var-based mod
instead hits registerMod's duplicate-id throw. _takeDown() is a teardown,
not an unloader — it reverses init, it does not un-execute a script. So there
is no live replace, no live-uninstall teardown, and no discovery of an install
another client performed. That is the same contract the per-broker mod pins
already ship.
What is delivered is what the feature was for: no process restart and no source edit. Drop a mod into a live broker, reload the tab, and it is there.
Related loader behaviour worth knowing:
-
Packages load in parallel; the scripts within one package load in
manifest order, one after the next. Across packages, order is irrelevant
because the topological sort runs afterwards, and document-ordered async would
let one slow file head-of-line-block every other mod. Within a package the
topo sort says nothing at all — it orders mod declarations, not the files of
one mod — so
scriptsis honoured as an ordered list. A multi-script package must callregisterModfrom its LAST script: a registration is acted on the moment it arrives, so registering from the first file would let the mod init before its siblings had loaded. - Stylesheets are injected but not awaited, and are live regardless of enable, pin or init — deliberately the same posture as a shipped mod's CSS. A teardown cannot remove them.
- Each
(id, gen, file)URL is fetched at most once per page load, through a shared in-flight promise map, so the post-login retry cannot double-execute a package. -
MOD_SCRIPT_TIMEOUT_MSis a proceed-anyway deadline, not a cancel — nothing can cancel an in-flight<script>. A package that lands later still executes, and_lateRegisterbrings it up if this page requested it. -
registerModbinds a declaration to the package whose script is running (document.currentScript.dataset.modPackage) and refuses a declaration whose id is not that package's, with awrong-idrow. This is a correctness convention, not a boundary: while a stamped package script runs synchronously, a declaration for a different id is refused instead of silently colliding with (say) the shippedclock. A package that registers from a promise or timeout hascurrentScript === nulland is simply unattributable, at which point the duplicate-idModConflictErroris the only backstop. -
?nomods=1on the page URL makesloadMods()return before it fetches or inits anything. It is the rescue hatch for a mod that bricks the desktop and therefore makes the Control Panel — where you would uninstall it — unreachable. It is read-only and deliberately not scrubbed from the URL the way?token=is, because replaceState-ing it away would mean a reload silently re-enabling the mod you are there to remove.
GET /mods/<modId>/<gen>/<name> is public, like GET / and /vendor/*,
and that is forced rather than chosen: a <script src> cannot carry an
Authorization header, ?token= is structurally banned, and a fetch+blob:
workaround would need blob: in script-src. The posture is the existing one —
GET / is public and already carries every shipped mod's source.
So an installed mod's source and its stylesheets are readable without a token — by anyone who knows the id and the generation and the file name. Do not put a secret in a mod — not in its code and not in its stylesheet.
help.md is a different matter: only .js/.css are servable, so this route
cannot hand it out at all. Its only surface is /help-corpus.json, which is
public for the same bootstrap reason GET / is (the Help window has to render
on the login page) — but since #173 it serves the installed mods' help sections
only to a caller holding the token. Without one it is the wiki +
shipped-mod corpus alone, so installed ids, help text and manifest label/icon
are not enumerable. Treat that as not published, not as secret storage: it
is one token away, and a mod's help is written to be read.
The generation is not a second secret. <gen> is a content hash of the
package, so anyone holding the exact bytes of a publicly distributed mod
recomputes it and can ask this broker for the file: a 200 rather than a 404
confirms that mod is installed. So #173 removed enumeration — the list of
installed ids, and the help text with it — not every last bit about a mod
someone already knows to ask for. A mod whose bytes were never published stays
unguessable in both segments.
POST /mods/install // browser auth token; NOT lease-gated
{ "manifest": { "id": "x-notes", "version": "1.0.0", "ctxVersion": 1,
"title": "Notes", "description": "…",
"scripts": ["notes.js"], "styles": ["notes.css"],
"requires": ["editor"], "tiers": ["settings"],
"permissions": ["file"],
"help": { "label": "Notes", "icon": "📓", "order": 2100 } },
"files": { "notes.js": "…", "notes.css": "…", "help.md": "…" },
"replace": false }
POST /mods/uninstall {"id": "x-notes", "purge": false}
POST /mods/rescan {}
GET /mods/installed // operator detail for the Control PanelAll four are browser-realm token-gated, serve_ui-gated (a node — a broker with
serve_ui: false — registers none of them; terminals: false does not affect them), and deliberately not lease-gated — this is broker
configuration, and the point is being able to administer a broker somebody else
is using.
The admin token. When the broker's config sets admin_token, the three
POSTs above (and POST /mods/policy, POST /update/policy) also need that
token in the X-Webterm-Admin header, on top of the page token; without it they
answer 403 {"error":"admin_required"}. GET /mods/installed is not
admin-gated. The Control Panel asks for it in a dialog titled Admin token
required and holds it in memory until the page reloads, never saved or synced.
With no admin_token configured the page token alone suffices, and the broker
logs one line at start saying so. (admin_required is not in the install
refusal list below: it comes from the admin gate, before the install code runs.)
The admin token guards routes, not files. The admin token does not stop a
holder of the page token from writing files into mods_dir through /file/*:
those routes are host-wide and carry no admin gate, and a broker that serves
the UI scans mods_dir at its next boot and serves whatever passes the scanner
there, asking for no admin header (the scanner is a coherence check, not a
barrier against someone who can write mods_dir). So admin_token closes "an
installed mod installs the next one" through POST /mods/install, not through
a direct file write. A
check that refuses /file/* writes inside mods_dir is not part of 1.0; keep
mods_dir as protected as the broker's own configuration.
The payload is JSON with text files, deliberately not an archive. No zip
parser means no zip-slip, no zip bomb, no symlink entries. Mods are text; binary
rides data: URIs. mod.json is written by the broker from the canonical
manifest and is a reserved key in files, so a payload cannot ship a manifest
that disagrees with what was validated.
Differences from a shipped manifest, all enforced:
-
scriptsis required and is an ordered non-empty list of.jsnames, all present infiles.entryis not accepted (unknown_manifest_key). -
idmust pass the id regex (bad_mod_idotherwise) and start withx-(reserved_idotherwise). - Unknown keys are rejected, not ignored.
-
help.slugis accepted and dropped — an installed section's slug is forced to the mod id, which isx--prefixed, and CI pins that no wiki page stem and no shipped mod'shelp.sluguses that prefix. The merge still checks for a collision and drops the installed section if one somehow appears, because merging installed help must never be able to blank the Help window. -
defaultEnabledis accepted and ignored; the catalog always reportsdefault_enabled: false. Install and enable are separate steps, which matches how the shipped default-off mods already behave. This is not containment: an installed package's scripts are fetched and executed on every page load whatever its enabled state — being off only meansinit()is not called, and its stylesheets are live either way. -
permissionsis checked against the source text, not merely displayed — the install-time capability lint (#193), covered on its own below. - Filenames must match
[A-Za-z0-9][A-Za-z0-9._-]{0,63}with a.js/.css/.mdsuffix, must avoid?,#,%and:(an NTFS alternate data stream:base.css:payload.jspasses a naive bare-name test), must not be a Windows device stem (CON,PRN,AUX,NUL,COM1-9,LPT1-9), and must not collide with another name undercasefold(). - Every file must be valid UTF-8, BOM-free, newline-terminated, and
within
ui._MAX_LINESlines — the same rule set the spliced fragments obey, imported fromuirather than retyped. - A
.cssfile may not reference an external origin:@import, absoluteurl(http…)/url(//…), and a backslash anywhere outside a comment (a CSS escape such as@\69mportwould hide the others from the check; write the character itself) are rejected (css_external_reference). The app's CSP sets onlyscript-srcandframe-ancestors, so a stylesheet would otherwise be a silent egress channel. Defence in depth, not a boundary — the mod's own JS canfetch()anything. - Caps, at the time of writing: 32 mods per broker, 32 files per mod, 256 KiB
per file, 512 KiB per mod, 2 MiB request body (64 KiB for the uninstall and
rescan bodies). The constants in
modinstall.pyare authoritative, andGET /mods/installedreports the live values underlimits— read them from there rather than from here.
Every refusal carries a distinct code, because "your mod was rejected" with no
reason is a support ticket: too_large · bad_json · bad_mod_id ·
bad_generation · reserved_id · id_in_use (409) · not_installed (404) ·
bad_file_name · reserved_file_name · too_many_files · file_too_large ·
total_too_large · bad_encoding · bad_scripts · bad_styles ·
bad_requires · bad_manifest_field · unknown_manifest_key ·
css_external_reference · undeclared_capability · too_many_mods (409) ·
write_failed (500).
modinstall.ERROR_STATUS is the authoritative map; a client must tolerate a
code it has not seen.
The permissions capability lint (#193). Unlike tiers (§5), which is a
self-reported claim nothing checks, permissions is a closed vocabulary
(modinstall.PERMISSIONS), and validate_package scans every .js file the
package ships for direct, syntactic use of the capability each name covers:
| permission | what it covers |
|---|---|
clipboard |
ctx.clipboard |
egress |
fetch( or hostFetch(
|
file |
ctx.file |
remote-admin |
one of the broker's admin route strings, named literally — at the time of writing /mods/install, /mods/uninstall, /mods/rescan, /mods/policy, /update/policy (built from app.ADMIN_ROUTES, so a new admin route becomes remote-admin automatically, with no second list to keep in sync) |
session |
ctx.session |
A capability the source uses but permissions did not declare is refused with
undeclared_capability, naming the first offender by file (sorted) then
by position within it — e.g. notes.js:12 uses 'ctx.file', which needs permissions: ["file"] in the manifest as the response's detail text. Declaring
more than you use is fine; only a used-but-undeclared capability is refused.
Absent and [] are not the same declaration. No permissions key at all
means "written before this lint existed" — an already-installed generation
from before #193 is grandfathered past the check (see /mods/rescan below)
and keeps serving. permissions: [] is a positive claim — "this package's
source reaches none of the five" — and is checked exactly like any other
declaration. A new package should always declare truthfully, writing []
when it genuinely uses none of the five; there is no reason for a new
manifest to omit the key.
A mention inside a comment never counts, for any permission — the scanner
blanks // and /* */ comments (and regex literals) before it looks for
anything, the same blank_js_literals machinery the portable-mod lint (§11.2)
uses. For clipboard / egress / file / session, a mention inside a
string literal doesn't count either — those four are matched against
source with string bodies blanked too, so "call ctx.file to persist" trips
nothing. remote-admin is the one exception: its evidence is a literal — a
mod naming /mods/policy inside a fetch() call — so that one check alone
keeps string bodies intact and blanks only comments and regex literals. A
route named in an actual string is a real use; the same text sitting in a
// comment still is not.
The rule runs on both doors: the same validate_package call underlies
POST /mods/install and the mods_dir scanner (POST /mods/rescan and
every boot), so a package dropped by hand faces exactly the check the wire
does — see the grandfathering note under /mods/rescan below for the one
carve-out (an already-installed generation).
Generations. A mod's assets live at <mods_dir>/<id>/<gen>/, where gen is
a sha256 over the canonical manifest bytes plus the sorted (name, sha256)
pairs. Because that hash is in the URL, a replacement can never be served
under the old URL — Cache-Control: immutable is honest and SRI has something
stable to pin. <id>/CURRENT is the atomic commit pointer, and
RETAINED_GENERATIONS older generations are kept so a page that started booting
against one survives a replacement mid-flight; an old enough generation is
eventually swept and then 404s.
Uninstall is not idempotent at the HTTP level: a retry after a lost
response answers 404 not_installed even though the first attempt succeeded.
Catching a typo was judged worth that ambiguity — treat "404 after a retry" as
possible success. purge: true additionally clears this broker's
/mod-store/<id> and its pin, in a fixed lock order and data-first,
code-last so a crash leaves code without data rather than data without code.
It cannot touch what other browsers hold in localStorage.
POST /mods/rescan re-reads mods_dir. It is an operator convenience and
not a trust boundary: it treats anyone who can write mods_dir as
authorized to supply code the broker will serve and the desktop will execute
with full authority. Keep that directory as protected as the broker's own
configuration. What the scanner does owe is coherence: it refuses
symlinks and reparse points, refuses anything whose realpath leaves mods_dir,
refuses a non-x- directory, and validates every byte it captured under exactly
the same rules an install obeys — including the permissions lint above, so
a hand-dropped package cannot dodge it by skipping POST /mods/install
entirely. The one exception is a generation this store already installed
(its own .gen.json names this directory and lists exactly the files in it):
that generation is grandfathered past the lint, so it keeps serving — across a
rescan and a restart — even with permissions absent, rather than going dark
retroactively the day the check shipped. Replacing or reinstalling it, through
either door, requires the declaration like any new package. The destructive
sweep refuses to run at all in a directory without the .browserland-mods
marker file. Unpacking an archive into a live mods_dir is not the recommended
path; use the API, or stop the broker first.
Control Panel → Mods is where an operator lives with all of this: it lists
the union of catalog packages and registered declarations, joined on id, badges
each row shipped or installed, and carries the Install a mod… and
Uninstall actions. The union matters — cycle rows, 404s and SRI mismatches
never call registerMod, so a pane driven off the registration list simply
could not show them.
Each row's state is one of the following. The vocabulary is produced by the
loader's status model, so treat an unfamiliar value defensively rather than
assuming this list is complete:
active · off · blocked (enabled, but a requires is not active) ·
cycle · blocked-by-cycle · failed (deps satisfied, init() threw) ·
fetch-failed (404, transport failure, or SRI mismatch) · timeout ·
loading · no-register (the script loaded but never called registerMod —
the compile-error case, which still fires load on the element) · wrong-id.
TDZ inside init() disables the whole mod. If a hoisted function is
invoked before a const/let it reads has been initialized — typically
because the declaration sits further down the same scope — the read throws a
ReferenceError ("Cannot access X before initialization"), initMod catches
it, and the entire mod is disabled, not the one feature. It has happened
more than once. Declare mutable state before anything that could call the
closures reading it, and prefer function-local declarations over fragment-level
ones that a hoisted function reads.
CI never executes UI JavaScript. The Python suite asserts source text and
served bytes. A TDZ error, a typo in a callback or a wrong property name is
invisible to pytest and only appears in a real browser. Load the page and
check the console.
Register ctx.onUnload before anything that can throw. If init throws
after allocating a timer but before registering its teardown, initMod's
rollback runs an unloads list that never learned about it — the mod is
disabled and the timer runs forever.
A fragment edit needs a broker restart. See §7. The page is assembled at import.
prefs/settings values sync. A ctx.settings.* key lives in the shared
/state blob, so it is not browser-local even if it feels like a local
preference. Use ctx.prefs (or ctx.storage) for genuinely per-browser state,
and isBrowserGlobal only controls which Control Panel tab shows the widget.
A saveChain flush from ctx.onUnload is a no-op. The chain drops its
pending batch the instant a teardown starts, so a "flush on the way down"
registered as an unload disposer saves nothing and resolves
{ok:false, error:'unloaded'} — by design, because a disable is synchronous and
cannot await a network write, and one that landed anyway would clobber the next
activation. Put the deliberate final flush somewhere the mod is still alive: a
window-close cleanup, or an explicit save action (§8).
ctx.signal on a close-time save aborts the save. The signal exists to kill
work that must not outlive the activation, so threading it through every call
reflexively is exactly wrong for the one path that is meant to survive — the
deliberate final flush and the writes around it (§8). If it has to land after
the teardown starts, it does not take the signal.
A reducer is re-run, so it must not do anything. serverStore.update and
saveChain.save invoke your fn once per attempt, and a 409 means another
attempt. A side effect in there — a counter, a toast, another save() — runs
once per attempt, and it is the one part of the CAS contract nothing can check
for you.
Reflect must be idempotent. spec.reflect on a settings pane runs on every
/state convergence, not just on open. Rebuilding your DOM there destroys an
in-progress edit.
renderMenu never collapses separators. If you contribute menu items
conditionally, contribute your separator conditionally too, or you get a stray
rule.
A per-mod enable is per-browser; a pin is per-broker. A broker pin outranks
the per-browser set, which in turn outranks your defaultEnabled, and a pinned
mod refuses setModEnabled outright. Your default is a starting point, not a
promise about what any given browser is running.
-
webterm/broker/mods/<id>/mod.json—idmatching the directory and theregisterModcall, plusversion,ctxVersion,title,description. -
webterm/broker/mods/<id>/<id>.js— one top-levelregisterMod({…}), prefixed top-level names, no</script>, everything ininit(ctx), teardown registered first. -
tiersdeclared honestly inregisterMod(§5). -
defaultEnabledandrequiresidentical inmod.jsonandregisterMod— a test asserts both directions. - Path appended to
ui._MODS, after anything itrequires. - Any
.csslisted instyles, UTF-8, no BOM, newline-terminated, withinui._MAX_LINES, with prefixed selectors (§14). - Optional
help.md+ thehelpblock — then runpython -m webterm.broker.help_corpus. -
python -m pytest tests -qclean, and the page loaded in a real browser with the console open. - Restart the broker before you believe anything you see.
Prefix everything you own. A mod's stylesheet is concatenated into the same
<style> zone as core's, so an unprefixed .row or .item is a page-wide
selector. Use <id>-… or a class on your own root node.
Contract surface — shared primitives you are expected to reuse rather than restyle:
-
.set-*— the Control Panel primitives (.set-section,.set-row,.set-check,.set-title,.set-hint,.set-err,.set-browser-global), which thectx.settings.*primitives emit for you. -
.app-toolbarand its children (.app-host-btn,.app-file-name,.app-dirty) — the app-window toolbar chrome. -
.tb-btn— title-bar buttons. -
.app-tab,.app-tabs,.app-tab-dot— in-window tab strips. -
.app-dialog-*— the shared dialog primitives behindopenDialog,openTextPrompt,openConfirmDialog,openInfoModal. - The Help card primitives, for anything rendered into the Help window.
Private core — do not target, do not depend on: everything in
13_css_tiling.css and 14_css_dragdrop.css (the tiling strip, gutters, drop
zones), and any selector not listed above. It changes without notice.
Known debt, not a pattern to copy. Some mods that were extracted from core
still leave their stylesheets behind in it — at the time of writing editor,
file-manager, task-manager and sticky (.app-editor*, .app-fm*,
.tm-*, .app-note in 11_css_apps.css). That is migration debt to unwind,
not an example: a new mod ships its own stylesheet through the manifest's
styles.
Prefer the theme variables (§8) over hardcoded colours: --bg, --bg-2,
--bg-3, --fg, --fg-dim, --accent-default, --sel-bg, --ok, --warn,
--danger, and var(--accent, var(--accent-default)) inside a window.
Browserland — a web-based terminal desktop. This wiki is the end-user guide; for the wire protocol, auth model, and HTTP/MCP contract see Technical Reference. Released under the MIT License.
Start here
Building layouts
The desktop
Windows & content
Multi-host & AI
For developers
{ "id": "notes", // the id; matches the directory name by convention "version": "1.0.0", "ctxVersion": 1, "title": "Notes", // shown in the catalog / remote policy editor "description": "A short sentence an operator will read.", "defaultEnabled": false, // optional; absent == true "requires": ["editor"], // optional; ids of mods that must be ACTIVE "styles": ["notes.css"], // optional; bare filenames in this mod's dir "permissions": ["file"], // closed vocabulary; checked only via the install door — see §11.5 "help": { // optional; only meaningful with a help.md "slug": "notes", "label": "Notes", "order": 2100, "icon": "📓" }, "entry": "notes.js", // INERT — see below "author": "JohnConnorNPC" // INERT — see below }