@ai-ecoverse/slicc-bios 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,462 @@
1
- # @ai-ecoverse/slicc-bios
1
+ # slicc-bios
2
2
 
3
- 🐣 This package name has been claimed with [fledgling](https://github.com/dmno-dev/fledgling) for [https://github.com/ai-ecoverse/slicc-bios](https://github.com/ai-ecoverse/slicc-bios), and a real release will land here soon.
3
+ A slim loader for [SLICC](https://github.com/ai-ecoverse/slicc), from the boot-process idea in [ai-ecoverse/slicc#3793](https://github.com/ai-ecoverse/slicc/issues/3793): load a small BIOS first, put everything else into OPFS, and serve the UI from there.
4
4
 
5
- This is a placeholder — there is nothing to install yet.
5
+ The page shows six boot steps as they run:
6
6
 
7
- ---
7
+ 1. **Acquire OPFS** – open the origin private file system and ask for persistent storage.
8
+ 2. **Create installer worker** – start `installer.js`, a shared worker that does all of the BIOS's OPFS writes and is shared by every tab.
9
+ 3. **Install packages from npm into OPFS** – the installer reads [`src/packages/package-lock.json`](src/packages/package-lock.json), the lockfile the BIOS ships with. It skips packages that name an `os` or `cpu` (native binaries such as esbuild's, which can't run in a browser). For every other locked package it downloads the tarball from `registry.npmjs.org` and checks its `integrity`, then unpacks it with the built-in `DecompressionStream` and [`modern-tar`](https://github.com/ayuhito/modern-tar) into OPFS at its lockfile path (`node_modules/…`), dropping `.` segments from entry names and skipping any entry with `..`. While it unpacks, it hashes every file (SHA-256) and records the hashes in IndexedDB (see [Integrity](#integrity)). It also records a receipt in `var/lib/bios/`. A package whose recorded integrity matches the lockfile's and whose files are still there isn't downloaded again, so pinning a new version replaces just that package. Replacing a package keeps its nested `node_modules`, and packages the lockfile no longer lists are deleted along with their receipts and hashes. A package without recorded hashes is downloaded again, even with a receipt, so the first boot after integrity checks arrived downloads the base set once. Last, the installer copies the deployment's `package.json` and `pnpm-lock.yaml` to the OPFS root for pnpm, and the lockfile once more to `var/lib/slicc/pnpm-lock.yaml`, which records what is installed (see [Updates](#updates)).
10
+ 4. **Add UI page to OPFS** – the installer copies `src/seed/` to `os/` in OPFS and records each file's hash. This is a stand-in until the UI ships as a downloadable asset.
11
+ 5. **Intercept same-origin requests** – `sw.js`, a module service worker, answers in-scope requests from OPFS and passes everything else to the network (see [What the service worker serves](#what-the-service-worker-serves)). The code it serves from OPFS carries `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Resource-Policy: same-origin`, so the UI page and the workers it starts are cross-origin isolated. Scripts it serves get their bare imports resolved (see [Modules](#modules)).
12
+ 6. **Serve UI from OPFS** – navigate to `os/` (keeping the URL fragment) with a cross-document view transition. That page imports [`@ai-ecoverse/slicc-kernel`](https://github.com/ai-ecoverse/slicc-kernel) and [`@ai-ecoverse/slicc-spectrum`](https://github.com/ai-ecoverse/slicc-spectrum) by name from `node_modules/` in OPFS, starts the kernel on the OPFS root and boots SLICC's UI, `<slicc-app>`, on spectrum's kernel model. The agent comes later, from its own pnpm project (see [Agent](#agent)). Commands come from the installed packages: `bash`, coreutils, `grep`, `sed`, findutils, `gawk`, `diff` (with `cmp`, `sdiff` and `diff3`), `patch`, `rg` (ripgrep), `jq`, `less`, `tree`, `tar` with `gzip`, `xz` and `bzip2` (and their `gunzip`, `zcat`, `unxz`, `xzcat`, `lzma`, `bunzip2` and `bzcat` names), `curl`, `mount`, `umount`, `clear`, `tput`, `tset`, `reset`, `pnpm` with its `npm` and `npx` shims, and `playwright-cli` and `curlwright` from slicc-cdp. The shell starts in `/home` as **root** (slicc-kernel ≥ 1.44.0 has users: `/etc/passwd` and `/etc/group` list root, nobody, sudo, users and nogroup), so the prompt ends in `#`, `whoami` and `id` say root, `ls -l` and `ps` show user names, and root's `HOME` is `/root` (mode 0700) with `PNPM_HOME=/usr/local/share/pnpm`. bash, coreutils, procps and git are the homescoop H1 rebuilds that know about users; older builds claimed uid 1000. `tar` forks the compressors for `-z`, `-J` and `-j` through `/bin/sh`. Hard links in archives aren't recreated, and there's no `--zstd`. xz is single-threaded: `-T` is accepted but has no effect. The `zgrep`, `xzgrep` and `bzgrep`-style wrapper scripts aren't installed as commands. Whatever the shell writes stays in OPFS across reloads. The kernel terminates HTTPS with `wasm-tls-engine`, so `curl https://…` works over whichever transport is picked (see [Network](#network)). `wasm-git` is left out for its size (53 MB); it can come later through pnpm.
8
13
 
9
- Want to claim a name of your own? Run `npx fledgling add <name> --new --yes` — it reserves the name on npm and can set up token-less (OIDC trusted) publishing for when you are ready.
14
+ ## What the service worker serves
15
+
16
+ The kernel's root is the OPFS root, so any file a kernel process writes, such as `/home/x.html`, has a URL on seven's origin. The service worker serves only BIOS-managed code as live same-origin content:
17
+
18
+ - `/os/` (the seed: the UI page and its modules and workers), `/node_modules/` (the base set: kernel, spectrum, slicc-cdp and the wasm packages), `/opt/agent/` (the agent and its workers) and `/opt/grammars/`;
19
+ - the aliases `/auth/callback` (→ `/os/callback.html`) and `/oauth/mcp-callback` (→ the agent's `dist/oauth/mcp-callback.html`), and the node builtin stubs under `/__slicc/node/`;
20
+ - bios's own files (`/`, `/index.html`, `bios.js`, `bios.css`, `installer.js`, `sw.js`, `manifest.json`, and `sw/`, `seed/`, `packages/`, `icons/` and `fonts/`), which always come from the network, even when OPFS has a file of that name.
21
+
22
+ The live content also carries `Content-Security-Policy: frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`, so only seven's own pages can frame it. That leaves no clickjacking and no framed captures of seven by another site. The hosting sends the same headers for the files it serves itself (sliccy-ai#22).
23
+
24
+ Every other OPFS file is served sandboxed: `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals; frame-ancestors 'self'` (without `allow-same-origin`), `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, and no COOP or COEP. An HTML page there still renders and runs its scripts, but in an opaque origin, with no access to seven's IndexedDB, `localStorage`, OPFS or keys. JavaScript and TypeScript files there are served as `text/plain` and wasm as `application/octet-stream`, and a request for a script, a worker or a worklet there gets a 403, so none of seven's pages can import, run or instantiate them. Bare imports are resolved only in the live paths. Kernel symlinks live in the kernel's metadata, not in OPFS, so the service worker never follows one. The kernel itself reads binaries from OPFS directly, not through the service worker.
25
+
26
+ ### Integrity
27
+
28
+ A kernel process can still write to `/os/` and `/node_modules/` (any process while the kernel's users are off, root after that). So the service worker checks what it serves from there against the hashes the BIOS recorded, never against bytes on disk:
29
+
30
+ - **The trusted hashes** are in IndexedDB `slicc-bios-integrity`. For `/node_modules/` they come from the npm tarballs the installer checked against `package-lock.json`. For `/os/` they come from the seed files it copied from the deployment. The in-place update (see [Updates](#updates)) runs pnpm, then has the installer fetch, check and hash the changed packages' tarballs again, all under the Web Lock `slicc-packages`.
31
+ - **Checking:** for a file under `/os/` or `/node_modules/`, the service worker computes its SHA-256 and compares it with the recorded one. It caches a match by path, size and modification time for its lifetime, and serves the bytes it checked. Package manifests it reads to resolve bare imports are checked the same way.
32
+ - **Failing closed:**
33
+ - **A changed file, or one with no recorded hash,** gets a 500 (`sw: <path> was changed after seven installed it`). The service worker notes the path in IndexedDB and sends every open `/os/` tab back through the BIOS, once per path a minute.
34
+ - **During an update**, while `slicc-packages` is held, a mismatch gets a 503 and isn't counted as a change.
35
+ - **A navigation** that fails the check goes to the BIOS too. So does any navigation while a tree has no hashes yet, which happens only on the first boot after integrity checks arrived.
36
+ - **The log:** every integrity event is appended to `/var/log/slicc/integrity.log`, one JSON line each, which the kernel shows as root's with mode 0644. Each line has `time`, `event` (`changed`, `restored`, `failed` or `stopped`), `path`, the `expected` and `actual` SHA-256 (`actual` is `null` for a removed file) and `changed`, when the change was found. The service worker writes `changed` and `stopped`, and the BIOS installer writes `restored` and `failed`.
37
+ - **When the UI's own code is refused:** `os/start.js` imports the UI and catches a failed import. Instead of a blank page it then shows *Seven couldn’t start*, with **Reload**, which goes through the BIOS. Usually the service worker has already sent the tab there. When seven stopped restoring the file, the message says which file and that seven tries again in about 10 minutes; it asks the service worker through `/__slicc/integrity`, which answers the stopped paths as JSON.
38
+ - **No restore loops:** seven stops restoring a path when restoring it failed, when it changes again within a minute of being restored, or when it changes three times in ten minutes. It keeps refusing the file, doesn't send tabs to the BIOS for it, and the BIOS leaves it as it is. The UI shows *Seven stopped restoring a file*: "Seven keeps finding <path> changed. It stopped restoring it and won’t run it. Something on this computer may be changing seven’s files." The list stays in `localStorage` (`slicc-bios.stopped`) until a BIOS run no longer reports it. Ten minutes after it was stopped, the next BIOS run tries again.
39
+ - **Putting it back:** the BIOS's packages step downloads again every package that owns a changed file, and the seed step copies the seed again. Both say how many changed files they put back. The UI then shows a warning notice, *Seven’s files were changed*, naming the files, from `localStorage` (`slicc-bios.repaired`), since the COOP switch from the BIOS to `os/` doesn't keep `sessionStorage`.
40
+ - **Not checked yet:**
41
+ - `/opt/agent/` and `/opt/grammars/` are served unchecked until their pnpm-installed files get hashes too.
42
+ - The kernel reads wasm binaries and Emscripten glue from OPFS directly, not through the service worker, so this check doesn't cover them.
43
+
44
+ ## UI
45
+
46
+ `os/` is `<slicc-app>` from `@ai-ecoverse/slicc-spectrum/ui` on `createKernelModel` from `@ai-ecoverse/slicc-spectrum/kernel`, with the agent spread over it once it's up. The page offers six sections:
47
+
48
+ - **Agents:** the rail of cones and their scoops, with each one's status (spectrum ≥ 1.23.0). Selecting a scoop opens its chat, and **Drop** stops it after a confirm. The header picker lists the cones and creates new ones with **New cone**. A cone starts scoops with the `agent` and `subagent` commands that slicc-agent installs in `/usr/local/bin`, mode 755, on every user's `PATH` (slicc-agent ≥ 3.35.0; it removes the copies earlier versions put in `$PNPM_HOME/bin` once the new ones are in place): `agent --prompt …` runs a scoop until it answers, and `agent --async …` starts one that reports back to its cone as a lick.
49
+ - **Chat:** SLICC's agent, a chat per cone or scoop, once it's installed (see [Agent](#agent)). Its tab joins the terminal's group without taking focus. The model is AWS Bedrock (`us.anthropic.claude-sonnet-5-5` in `us-west-2`) with a Bedrock API key entered under **Settings → Accounts**. The key stays in the agent worker's encrypted credential store. Bedrock sends no CORS headers, so chat needs a local proxy or the extension (see [Network](#network)).
50
+ - **Terminal:** `bash -i` sessions on the kernel, starting in `/home`, as many as you open with **+**.
51
+ - **Files:** the OPFS root as a tree. `/.slicc` (the agent's own state, mode 0700 from slicc-agent 3.35.0) and `/.slicc-unlinked` (where the kernel keeps a deleted file that a program still has open, slicc-kernel ≥ 1.37.1) are hidden (`hide`, spectrum ≥ 1.9.0); `/node_modules`, `/opt/agent/node_modules`, pnpm's store and `/home/.cache` are listed but not scanned. The tree follows what the terminal writes (a rescan every 2 s and on `FileSystemObserver` records). Files open in tabs, and **Edit** turns a tab into a text area that **Save** or `Mod+S` writes back to OPFS. Mounted folders show in the tree too (see [Folder mounts](#folder-mounts)).
52
+ - **Memory:** what the agent remembers (slicc-agent ≥ 3.14.0, spectrum ≥ 1.28.0). Each scope is one `MEMORY.md`: `/home/.pi/agent/memory/` for everyone and one per cone, `/home/.pi/agent/agent-memory/<path>/` for roles. An entry is a `###` title under a `##` section, with an optional `tag:` line before its body. The agent writes them with `memory_write`, the panel's **Save** and **Forget** write the same files, and a file edited by hand shows up in the panel.
53
+ - **Changes:** what agents changed in git repositories, by repository, with the diff and **Accept** or **Revert** per change or for all (slicc-agent ≥ 3.21.1, spectrum ≥ 1.37.0). It sits next to the Freezer, closed by default, and opens from the rail or View. Git isn't preinstalled, so until someone runs `pnpm add -g @ai-ecoverse/wasm-git` it says so and shows that command.
54
+ - **Freezer:** every conversation in the session database (slicc-agent ≥ 3.23.0, spectrum ≥ 1.39.0). Live cones show **Open**. A cone deleted with **Delete cone** in Agents (which asks first), an earlier chat left by New chat, or a stopped scoop or agent run stays there with its title, model, kind and message count. **Thaw** brings it back as a cone, and the row then says what it was thawed as. **Remove**, which asks first, deletes its working folders; the conversation stays in the database.
55
+ - **Settings:** the theme, the model, thinking, diffs, and the Bedrock and Adobe accounts. Below Accounts, **MCP servers** lists each server in `~/.pi/agent/mcp.json` whose headers use a `${NAME}` placeholder, and **Connect** takes its token (slicc-agent ≥ 3.33.0, spectrum ≥ 1.50.0). A server without an `Authorization` header that answers 401 needs an OAuth sign-in: its row reads *Needs sign-in* with **Sign in**, or *Sign-in expired* (slicc-agent ≥ 3.36.0, spectrum ≥ 1.54.0), which opens a pop-up on the server's authorization server. The agent registers `https://<origin>/oauth/mcp-callback` as its redirect URI, and the service worker serves that path from the agent's `dist/oauth/mcp-callback.html` in `/opt/agent`, which hands the code back over `BroadcastChannel('slicc-mcp-oauth')`, clears its address and closes. Tool cards label MCP calls `server · tool`. The agent's `models` command lists the models of the connected accounts and switches the caller's model (slicc-agent ≥ 3.34.0).
56
+ - **Keeping seven's files:** at the first chat send in a page session, `os/storage.js` calls `navigator.storage.persist()`, unless storage is already persisted. If Chrome declines, every chat panel shows a warning notice (`model.notices`, spectrum ≥ 1.43.0, from `os/notices.js`): *Seven’s files may be cleared*, saying Chrome may clear seven's files and chats when disk space runs low. Chrome grants persistence by engagement, not by a setting, and installing seven as an app is the reliable way. So when Chrome offers to install (`beforeinstallprompt`), the notice says to install seven as an app and offers **Install**, which opens Chrome's install prompt. Otherwise it says to bookmark seven. **Try again** asks again. After `appinstalled`, seven asks by itself and drops the notice once storage is kept. Dismissing the notice is remembered in `localStorage`. The folder-mount request (see [Folder mounts](#folder-mounts)) stays as it is.
57
+ - **Installing as an app:** [`src/manifest.json`](src/manifest.json) makes seven installable. Its name is *SLICC seven* and its launcher label *seven*. It opens standalone, its `id`, `start_url` and `scope` are `/`, and its icons in `src/icons/` come as `any` and `maskable`. The app starts at the BIOS, which checks the packages and goes on to `os/` as in a tab, so the service worker, COOP/COEP and the boot path are the same. Both pages link the manifest and set `theme-color` (`#f8f8f8` light, `#1b1b1b` dark); `os.css` gives `html` the same background, so nothing flashes before the UI paints. Each `<branch>.sliccy.ai` is its own origin, so a preview installs as an app of its own.
58
+ - **Adobe sign-in:** **Connect** on the Adobe account signs in to Adobe IMS. IMS only redirects to `www.sliccy.ai/auth/callback`, and seven is cross-origin isolated, so the popup has no opener to report back to. Which path seven uses depends on where it runs:
59
+ - **On `https://<label>.sliccy.ai`:** seven asks the relay for its own origin (state `{ source: 'origin', origin, nonce }`). The relay navigates the popup to `<origin>/auth/callback`. The service worker serves that page from `os/callback.html`, which hands the redirect to the tab over the same-origin `BroadcastChannel('slicc-sign-in')`, clears the token from its address and closes. Neither slicc-node nor the extension is needed.
60
+ - **Elsewhere, with slicc-extension installed:** the extension signs in with `chrome.identity`.
61
+ - **Elsewhere, through slicc-node:** seven registers a nonce with `POST /api/oauth-state`, sends IMS to the relay's `local` source, and polls `GET /api/oauth-result` once a second.
62
+
63
+ Setting `localStorage['slicc-os.sign-in'] = 'relay'` forces the first path on a dev origin; the chat test does that. Seven checks the nonce on every path. While it waits, the Adobe row in Settings › Accounts reads *Signing in…* with **Cancel** (slicc-agent ≥ 3.19.0, spectrum ≥ 1.34.0). The agent calls `login(providerId, options, { signal })`, and Cancel aborts the signal. Cancel, or the 10-minute timeout, stops the wait. On the slicc-node path, Cancel and a `404` also drop the nonce. The token goes to the agent worker's credential store, and the header shows the weekly budget as *Budget N%* (`#usage`), a plain status while seven offers no Monitor panel.
64
+
65
+ Each screen class has its own layout, kept in `localStorage` (`slicc-os.layout.<screen>`). On desktop and tablet chat and the terminal share the middle, the agents and files are on the left, and file tabs open between them. On a phone it's chat and the terminal, with chat, the agents and files in the bottom rail. Every panel keeps its rail icon while it's open; while it's visible the icon shows selected (`.selected`, `data-visible`, labelled "{Title}, shown", spectrum ≥ 1.53.0), and a click brings it to the front. The header's tray connection (`#chip`) opens Network at its **Sync** section: the tray's state, the float's name, the join link with **Copy**, the followers, and **Disconnect** (after a confirm) or **Reconnect**. The agent's tray port is a placeholder today (offline, no join link), so the section reads offline; seven's own tray leader shows under Linked devices (see [Network](#network)). Memory opens from the View menu. Changes, the browser and the monitor come back as their backends land. Adobe Clean comes from `/fonts/`, which the sliccy-ai worker serves on every host.
66
+
67
+ ### Folder mounts
68
+
69
+ A local folder can be mounted into seven as on Linux ([#73](https://github.com/ai-ecoverse/slicc-bios/issues/73)). `os/mounts.js` gives slicc-kernel the hooks it needs:
70
+
71
+ - **From any shell**, the agent's `bash` included, `mount -t fsa none /mnt/x` (from `@ai-ecoverse/wasm-mount`) mounts an empty drive at once. The Files panel lists `/mnt/x` as waiting for a folder and offers **Insert folder…** (spectrum ≥ 1.32.0). The click opens the folder picker (`showDirectoryPicker`), and the folder goes in without a remount. `umount /mnt/x` ejects it; the folder itself is untouched.
72
+ - **From the Files panel** (spectrum ≥ 1.27.0), **Mount a folder…** picks a folder and mounts it at `/mnt/<name>`, then selects it in the tree. A **Mounted** strip lists every folder mount, shell mounts included, each with **Eject**. The files port behind them has `mountFolder(path?)`, `mounts()`, `needsFolder()` and `eject(path)`. `needsFolder()` lists the mount points waiting for a folder, and `mountFolder(path)` inserts one there. The port emits `mounts` when either list changes, then `files`, then `file` for each path that went away, so a tab open on an ejected file notices.
73
+ - **Remembered:** `hostfs` mounts, and `fsa` mounts where the profile allows it (see below), are kept in `localStorage` (`slicc-os.mounts`) and mounted again on the next load. The kernel keeps each folder's handle in IndexedDB, so a folder whose permission still holds comes back at once, and one that needs permission again waits in the Files panel, where **Insert folder…** asks for it.
74
+ - **Off the record, failing safe:** Chrome 153 crashes the browser when a folder handle goes through IndexedDB in an off-the-record profile (Incognito, or a CDP browser context such as the test harness's), as soon as an `fsa` mount touches the kernel's handle store. The page remembers folders only with positive evidence that the profile isn't off the record: `navigator.storage.persisted()` is granted and the storage quota is at least twice the JavaScript heap limit. Otherwise it gives the kernel `media: false` (slicc-kernel ≥ 1.18.0): folder handles stay in memory and the kernel never opens its `:media` database, while POSIX metadata and the kernel's TLS CA still persist, shared with seven's other kernels. It also neither remembers nor remounts `fsa` mounts then. The kernels seven starts to install the agent and the grammars never mount folders, so they always get `media: false`. In that session mode a folder is asked for again after a reload. Chrome grants persistent storage by engagement, so ordinary profiles can start in session mode too: **Insert folder…** also calls `navigator.storage.persist()`, and when that is granted and the quota check passes, the session switches to remembered. Folders inserted before the switch are asked for once more after the next reload, after which their handles are kept. `document.documentElement.dataset.folders` says which mode is on: `remembered` or `session`.
75
+ - **Host folders:** with a local proxy that exports folders (`npx @ai-ecoverse/slicc-node --mount <path>[:<name>][:ro]`, or slicc-swift), `mount -t hostfs <name> /mnt/h` mounts one with no picker. The page asks the proxy for a token scoped to that folder (`POST /api/hostfs/grant` with the proxy key); the kernel gets only the token, and neither the key nor the token shows up in `/proc/mounts`. A name that is neither exported by the proxy (`POST /api/hostfs/mounts`) nor a linked CLI's folder (`<device>/<name>`) is refused at once (exit 32, permission denied), with or without a proxy.
76
+ - **Deleted but open files:** the kernel keeps a file that a program still has open after it's deleted in `.slicc-unlinked/` at the mount's root until it's closed (slicc-kernel ≥ 1.37.1). In a mounted folder that directory is visible on the host, so a repository there may want it in `.gitignore`.
77
+ - **Owners** (slicc-kernel ≥ 1.61.0): every mount bios makes, a folder picked in the Files panel and each remembered mount it mounts again, gets its owner as `uid=` and `gid=` from one function, `owned()` in `os/mounts.js`. While the kernel's users are off, that's root (`uid=0,gid=0`), and `/proc/mounts` shows it. When agents run as their own users, it gives the user's ids, so their programs see the folder as their own.
78
+ - **No modes or symlinks** in an `fsa` folder: the File System Access API has neither, so `chmod` there succeeds and changes nothing (`stat` keeps `644`), and symlinks can't be made there.
79
+ - **The tree** lists mounted folders through the kernel (spectrum's file port reads only OPFS), skipping `node_modules` and `.git` and stopping at 5,000 entries. It follows changes made through the kernel at once and others every 10 s. Reads, writes and removals below a mount point go through the kernel too.
80
+
81
+ ### Agent
82
+
83
+ [slicc-agent](https://github.com/ai-ecoverse/slicc-agent) and its dependencies (about 100 packages, most of them provider SDKs) aren't part of the first boot either. They are a separate pnpm project, [`src/packages/agent/`](src/packages/agent/), installed like the grammars, so the BIOS boots as fast as without an agent. slicc-agent takes `@ai-ecoverse/slicc-kernel` as a peer, so the project pins the same kernel as the base set, and a test keeps the two pins equal. Its `pnpm-workspace.yaml` overrides `@google/genai` and `esbuild` to nothing (`'-'`): a Bedrock turn never loads them, and dropping them and their trees takes `/opt/agent` from 99 packages to 58. Seven's pnpm reads overrides only from the workspace file, so the installer copies it into `/opt/agent` next to the lockfile:
84
+
85
+ 1. **After every update check,** `os/agent.js` compares the deployed `packages/agent/pnpm-lock.yaml` with `var/lib/slicc/agent/pnpm-lock.yaml`. If they differ, it writes the manifest (merged with the user's packages, see [Your packages](#your-packages)) and lockfile to `/opt/agent` and runs `pnpm install --frozen-lockfile --trust-lockfile` there under its own Web Lock, `slicc-agent-install`. The **Agent** row of [Install / Update](#install--update) shows pnpm's progress: *Downloading n of m* while pnpm fetches packages (reused from its store or downloaded), then *Linking n of m* once it starts adding them, because pnpm adds nothing until the fetching is done. Only once pnpm succeeds does it record the lockfile. A failed install shows the error, pnpm's log and **Retry**, and the next check tries again.
86
+ 2. **Once it's installed,** the page imports `page.js` and `spectrum/index.js` from `/opt/agent/node_modules/@ai-ecoverse/slicc-agent/dist/`. `startAgent` takes the Web Lock `slicc-agent`, starts `agent-worker.js` from there as a module worker and hands it a port to the page's kernel, so the agent's `bash`, `read`, `write` and `edit` run on the same kernel and files as the terminal. The page swaps in `createAgentModel()` when the worker connects. On later loads the agent starts at once, before any network check, so chat works offline. If another tab owns the agent, this tab has terminals and files only.
87
+ 3. **The conversation** is kept in SQLite in OPFS (`/.slicc/agent/`).
88
+
89
+ **The `panel` command** (slicc-agent ≥ 3.43.0, spectrum ≥ 1.60.0) lets the agent arrange seven's panels. Every local tab, the one that owns the agent and the tabs that follow it in the same browser, gives `createAgentModel()` a `page` bridge with its tab id, and the bridge runs spectrum's `panelCommand(app, argv, caller)` on that tab. A page that follows seven over a link never gets one. So `panel open monitor left`, `panel reset` and the other verbs in the agent's `bash` act on the tab showing the caller's cone, `panel tabs` lists the tabs, and `--tab <id>` picks one. Each terminal a tab opens has `SLICC_TAB` set to that tab's id, so `panel` typed in it acts on that tab.
90
+
91
+ `/opt/agent` is an ordinary pnpm project, so `cd /opt/agent && pnpm upgrade` updates the agent in place, and the pin survives seven's updates ([Your packages](#your-packages)), and the service worker drops its resolution cache when `/opt/agent`'s lockfile or `.modules.yaml` changes. When an update installs a new `/opt/agent` while the agent runs, the **Agent** row says *Ready to apply* and offers **Restart agent** (D16). It waits until the agent is idle, then stops the agent worker, imports the newly installed `page.js` and `spectrum/index.js` again and starts the new `agent-worker.js` with them; durable resumes the conversation from SQLite, and the page reconnects and swaps in the new connection and the new adapter's `createAgentModel()`, closing the old connection. The kernel, terminals and the page itself keep running. The page holds one module instance per URL, so each restart, and each retry after a failed import (Chrome keeps a module's failure under its URL too), imports the adapter as `page.js?run=<n>` with a new `n`; the service worker ignores `run` when it reads the file and adds the same `run` to every import that resolves inside `/opt/agent`, so the adapter's own files and dependencies load from the new install while the base set's modules stay shared. If the restart fails, no agent worker is left running and the row offers **Retry**, which starts the installed agent.
92
+
93
+ ### Tabs
94
+
95
+ Several tabs of one origin share one kernel and one agent (spectrum ≥ 1.46.0 for `model.tabs`).
96
+
97
+ **The owner** is the tab holding the Web Lock `slicc-agent`. `os.js` asks for it with `ifAvailable` before it starts a kernel. The owner does everything a single tab did before, and only the owner does it:
98
+ - it runs the kernel and the agent worker (`startAgent({ held: true })`);
99
+ - it handles folder mounts, Tailscale, the tunnel, and the installs and update checks.
100
+
101
+ **Every other tab is a follower.** It starts no kernel. It shows the full UI:
102
+ - the chat, through its own pi-protocol connection to the owner's agent worker;
103
+ - terminals on the owner's kernel;
104
+ - Files on OPFS;
105
+ - the Install / Update rows, mirrored from the owner, with their buttons sent to the owner.
106
+
107
+ Each tab picks its own cone. A follower can't mount folders, since the kernel's folder handles are the owner's page's.
108
+
109
+ **When the owner closes, reloads or crashes,** the first waiting tab gets the lock and becomes the owner in place: it starts a kernel and the agent, which resumes from SQLite. Terminals in every tab print `[the tab running SLICC closed; starting a new shell]` and open a new shell on the new kernel. Anything typed meanwhile goes to the new shell once it has printed its prompt, so it shows once.
110
+
111
+ **`model.tabs`** says what this tab is:
112
+ - `role`: `owner`, `follower`, `connecting`, or `alone` (without SharedWorker, a second tab shows only "SLICC is open in another tab");
113
+ - `stalled`: the owner hasn't answered for 5 s, typically because Chrome froze it;
114
+ - `skew`: another tab runs a different `os.js` (an 8-byte SHA-256 of the tab files) or a different agent version. A follower with skew doesn't attach to the agent.
115
+
116
+ A hidden owner is fine. In R1, 30 minutes in the background throttled only the owner page's own timers, so no handover happens then. These are the pieces:
117
+
118
+ - **The switchboard:** `os/switchboard-worker.js` is a module SharedWorker named `slicc-switchboard` with a fixed URL. It keeps a table of tabs (id, visibility, versions) and the owner. It brokers MessagePorts and never sees their traffic.
119
+ - The frames are JSON with `v: 1`, and other versions are refused: `hello`, `welcome`, `visible`, `own`, `owner`, `replaced`, `want`, `give`, `ping`/`pong`, `stalled` and `unstalled`.
120
+ - A follower's `want` goes to the owner, and the owner's `give` comes back with up to three ports: `agent` (pi-protocol), `kernel` (slicc-kernel's client protocol) and `os` (a small RPC to the owner page).
121
+ - **Liveness:** a MessagePort reports no close, so liveness uses Web Locks.
122
+ - Each tab holds `slicc-tab:<id>`, and the switchboard waits on it. When that lock is granted, the tab is dropped, and if it was the owner, every tab hears `owner { tab: null }`.
123
+ - The switchboard holds `slicc-switchboard`, and every tab waits on it. When a tab gets that lock, the switchboard has died: the tab opens a new one and says `hello` again, and the owner sends `own` again. Ports already brokered keep working.
124
+ - **Stalls:** the switchboard pings the owner every second. After 5 s without a `pong`, followers hear `stalled`, and when the owner answers again, `unstalled`. An owner frozen with `Page.setWebLifecycleState` is reported as stalled after about 5.7 s.
125
+ - **`os/tabs.js`** has the tab side:
126
+ - `claim()`: ownership with `ifAvailable`, and otherwise a queued request that resolves when this tab takes over;
127
+ - `joinSwitchboard()`;
128
+ - `followKernel()`: a kernel facade over the owner's kernel port. Its terminals survive a change of owner: they print `[the tab running SLICC closed; starting a new shell]` and open `bash` again in the new owner's kernel with the same size;
129
+ - `createOs()` and `osClient()` for the `os` port;
130
+ - `skew()`, which compares two tabs' versions;
131
+ - `localBoard()`, the switchboard of a tab alone, and `createTabs()`, the tabs port.
132
+ - **`os/present.js`** attaches a tab to the owner whenever the owner changes. It gives the follower an `os` port and mirrors the owner's updates over it. The owner tab attaches to its own agent the same way.
133
+
134
+ ### Grammars
135
+
136
+ Spectrum bundles 16 syntax languages; the rest come from `@shikijs/langs` and `@shikijs/themes` (725 and 135 files). They aren't part of the first boot. They're a separate pnpm project, [`src/packages/grammars/`](src/packages/grammars/), deployed next to the BIOS's lockfiles:
137
+
138
+ 1. **Until they're installed,** the UI loads grammars from jsDelivr.
139
+ 2. **After every update check,** `os/grammars.js` compares the deployed `packages/grammars/pnpm-lock.yaml` with `var/lib/slicc/grammars/pnpm-lock.yaml`. If they differ, it writes the manifest (merged with the user's packages, see [Your packages](#your-packages)) and lockfile to `/opt/grammars` and runs `pnpm install --frozen-lockfile --trust-lockfile` there, under its own Web Lock, `slicc-grammars`, so a long install never holds up the BIOS in another tab. Only once pnpm succeeds does it record the lockfile.
140
+ 3. **Once they're installed,** `grammarBase` points at `/opt/grammars/node_modules/@shikijs/`. On later loads the page sets it as soon as the recorded lockfile is there, before any network check, so highlighting works offline. If pnpm fails, the page stays on jsDelivr, the **Syntax grammars** row shows the error with **Retry**, and the next check tries again.
141
+
142
+ The Files panel lists `/opt/grammars/node_modules` but doesn't scan it.
143
+
144
+ ### Install / Update
145
+
146
+ `os/updates.js` is spectrum's `UpdatesPort` (`model.updates`, spectrum ≥ 1.27.0), with one row per component: **Agent** (`/opt/agent`), **Kernel** (`slicc-kernel`), **UI** (`slicc-spectrum`), **BIOS packages** (the other root packages) and **Syntax grammars**. Each row shows its version, when it was last checked, pnpm's download or link count while it installs, and pnpm's output under *Install log*. A component that isn't installed yet is *Waiting* (`queued`, spectrum ≥ 1.29.0) until its install starts. A failed row leads with one sentence on what failed and what to do, and keeps pnpm's output in the log. `ready()` is false at every boot until the agent's model is in place, or until the check finds no agent to start. While it is false, spectrum opens the Install / Update panel, so it shows while the agent installs or loads; while an installed agent starts, its row reads *Starting* (`starting`, spectrum ≥ 1.37.0). It closes once the agent is up without failures, and a new failure opens it again. An installed agent that doesn't start fails its row with **Retry**. Background updates show in the header. Each row's actions go back to the installers: **Retry** runs that installer again, **Restart agent** restarts the agent worker and **Reload** reloads through the BIOS.
147
+
148
+ #### Optional packages
149
+
150
+ Below the system rows, Install / Update offers **Optional packages** (spectrum ≥ 1.42.0): tools that seven doesn't preinstall, each at the version the homescoop coordinator certified. The catalog is [`src/packages/optional/`](src/packages/optional/): `package.json` pins each package exactly, `package-lock.json` (made with `npm install --package-lock-only --ignore-scripts`, never installed) holds its integrity, and `catalog.json` gives its id, label, description, commands, tarball size and `requires`. A test keeps the three in step. Today it offers git, OpenSSH, Python, uv, Node.js (slicc-jsh's `node` and `jsh`), Ruby, Perl, esbuild, TypeScript, Biome, the PDF tools (poppler), qpdf, rsync, buf, screen, dig and GnuPG.
151
+
152
+ - **Install** runs `pnpm add -g <package>@<version>` in the page's kernel, as a user would, under the Web Lock `slicc-optional`, so the commands work at once in every open shell. pnpm 12 gives each global add its own project under `$PNPM_HOME/global/v11/`; bios reads `PNPM_HOME` from the kernel's environment. Afterwards `os/packages.js` reads that project's `pnpm-lock.yaml`: every package in it, the one added and each dependency, must be a version in the catalog's lockfile or the base set's, with that integrity. homescoop pins `@ai-ecoverse/*` dependencies exactly (uv → Python, git → bash), so the catalog's lockfile names them all. Anything else, a dependency pnpm resolved to another version included, removes the package again and fails the row with *This version doesn't match the tested build*. An entry's `requires` (uv needs Python) are installed first.
153
+ - **State** comes from pnpm's global projects, not from a list of bios's own, so a package installed by hand shows as installed with its version. One older than the catalog's offers **Update**, which adds the catalog's version; one at or above it offers **Remove**. bios never downgrades.
154
+ - **Remove** runs `pnpm remove -g <package>`. It asks first only when another installed entry needs it (*Remove Python? uv needs it and stops working until Python is back.*, accent); otherwise it doesn't ask, since pnpm's global projects hold no user data and the package can be installed again. A cancelled confirm changes nothing.
155
+ - **Readable by everyone:** after every pnpm install of `/opt/agent`, `/opt/grammars` and an optional project in `/opt/slicc`, bios runs `chmod -R a+rX` on that tree, as root. pnpm writes some files `0600`, such as `node_modules/.pnpm-workspace-state-v1.json`, and with kernel users every user must be able to read seven's installed packages. An install from before this runs the same `chmod` once at its next check, marked by `var/lib/slicc/<project>/readable`.
156
+ - **Failures** lead with one sentence and keep pnpm's output, starting with the package and version, under *Install log*, with **Retry**.
157
+ - **Project entries** (`kind: "project"`): **Python data tools** (numpy, pandas, scipy and matplotlib, about 270 MB on disk) can't be a global add. pnpm 12 gives each global add its own isolated project, so every py-* package would carry its own Python (about 1 GB in total), and Python wouldn't find the packages. So the entry is a pnpm project of its own in [`src/packages/optional/projects/pydata/`](src/packages/optional/projects/pydata/):
158
+ - **Install:** bios writes the project to `/opt/slicc/pydata` and installs it with `pnpm install --frozen-lockfile`, so pnpm checks every integrity against the lockfile bios ships. Its `pnpm-workspace.yaml` overrides `@ai-ecoverse/wasix-python` to nothing, and the entry `requires` the catalog's Python, which runs it.
159
+ - **How Python finds it:** bios writes `/opt/slicc/python/slicc-pydata.pth`, which lists each package's site-packages (from its `slicc.python.sitePackages`), next to a `sitecustomize.py` that adds that folder as a site directory. The page's kernel puts `/opt/slicc/python` on every process's `PYTHONPATH`, so `python3` imports the packages from any shell or agent, as any user. Python's own discovery only looks in its own project, so the `.pth` stays. A `.pth` that an earlier version wrote to `/root/.local` is moved there the next time the optional packages are listed. The kernel's environment reaches every process, whichever client or user starts it, `sudo` included. `python -I` and `python -E` ignore `PYTHONPATH`, so they don't see the data tools; that's a known gap.
160
+ - **State and Remove:** a receipt in `var/lib/slicc/optional/pydata/` records the installed version. **Remove** deletes the project, the `.pth` and the receipt.
161
+ - **Without Python:** if Python is removed while the data tools stay installed, the row warns that it needs Python (`warnings`, spectrum ≥ 1.49.1).
162
+ - **The agent** reads the same catalog: bios writes it to `/etc/slicc/optional.json` at every boot (id, package, version, label, description, commands, requires).
163
+
164
+ Optional packages never count in the header and never open the panel. The terminal's `pnpm add -g` still installs anything, at any version.
165
+
166
+ Since the kernel has users (slicc-kernel ≥ 1.44.0), root's global pnpm lives in `/usr/local/share/pnpm`, and the panel installs system-wide as root into whatever `PNPM_HOME` root's environment names. Packages installed before that are in the old `/home/.local/share/pnpm`, which the kernel no longer looks in. So on the first boot after the upgrade, bios reads the old global projects and installs every catalog package it finds there again, at its certified version, one after the other in the background. It then records what it found in `var/lib/slicc/optional/moved`, so this happens once. Other leftovers stay where they are; the planned one-time notice about fresh homes will list them.
167
+
168
+ A package dropped from the catalog is listed in `src/packages/optional/retired.json` (today `wasi-hf`, and `wasm-tar`, which moved into the base set). If one is still installed, its row says it's no longer offered and offers only **Remove**; otherwise it doesn't show.
169
+ ## Modules
170
+
171
+ #### Your packages
172
+
173
+ `/opt/agent` and `/opt/grammars` are ordinary pnpm projects, and what the user (or the agent) changes in them with `pnpm add`, `pnpm upgrade` or `pnpm remove` survives seven's updates. The deployment owns the **base manifest**, its `package.json`; the user's changes are a **user layer** on top of it, and `os/layer.js` merges the two before it runs pnpm (D15).
174
+
175
+ - **Detection:** at every update check (boot, the tab becoming visible, every 15 minutes, Retry), bios reads the project's `package.json` and, only when it changed since the last check, compares it with the base it last installed (`var/lib/slicc/<project>/package.json`). A name the base doesn't have is an **addition**; a base name with another spec is a **pin**. Nothing runs pnpm when only the user changed the project, since the user's pnpm already installed it. A base package the user removed is put back.
176
+ - **The layer file** is `var/lib/slicc/<project>/user.json`, an interface the agent reads too (it loads extensions from the additions in `/opt/agent`):
177
+
178
+ ```json
179
+ {
180
+ "add": { "<name>": { "spec": "^1.2.0", "field": "dependencies" } },
181
+ "pin": { "<name>": { "spec": "3.30.0", "field": "dependencies", "base": "3.31.0" } }
182
+ }
183
+ ```
184
+
185
+ `field` is `dependencies`, `devDependencies` or `optionalDependencies`, `spec` is what the user's pnpm wrote, and a pin's `base` is the base spec when the pin was made. While bios has work to do it adds `"pending": true`; after a failed install it adds `"error"`.
186
+ - **On an update** (a new deployed lockfile), bios writes the new base merged with the layer and the deployed lockfile to the project. With an empty layer it installs with `--frozen-lockfile`, exactly as shipped. With a layer it runs `pnpm install --no-frozen-lockfile`: pnpm keeps the deployed resolution for every unchanged base package and resolves only the user's entries.
187
+ - **Who wins:** a pin wins over the base until the user unpins it. A pin made *ahead* of the base (an upgrade) drops by itself once the deployment's version reaches or passes it, so the user follows the base again; a pin made *behind* it (a rollback) stays. Range, tag and URL pins never drop by themselves. An addition the base later adopts becomes part of the base.
188
+ - **Certified packages:** a package the base set provides (slicc-kernel, slicc-cdp, wasi-pnpm, spectrum, the `wasm-*` tools) can't be pinned or added in these projects; `/opt/agent`'s kernel has to equal the base set's. bios undoes such a change at the next check and posts a notice naming the base set's version, read from the deployment. After a layered install, every gated package in the project's lockfile (see [Certified packages](#certified-packages)) must be the shipped one or listed in `packages/certs.json` with that integrity.
189
+ - **Failures:** if the layered install fails or fails that check, bios installs the base again, frozen, and keeps the layer with its error. The rows show it with **Retry**; a new deployment tries again too.
190
+ - **The panel** lists the layer under **Your packages** (spectrum ≥ 1.55.0: `origin: 'user'` and `pin: { base, range, project }` on each row, `base` null for an addition), below Optional packages and only when there is one: *Pinned* rows show the user's version next to the one seven ships and offer **Unpin**, *Added* rows offer **Remove**. Both edit `user.json` and the project's `package.json` under the project's Web Lock and install again; unpinning everything restores the deployed lockfile byte for byte. An `/opt/agent` change under a running agent offers **Restart agent**, as any agent update does.
191
+ - **Limits:** only the project's dependencies are layered. A changed lockfile alone (a transitive `pnpm update`) and edits to `pnpm-workspace.yaml` (overrides) are replaced by the deployment's at the next update. The root project and `/opt/tailscale` hold only certified packages and have no layer; `pnpm add -g` is the place for the user's own tools, and bios never rewrites it.
192
+
193
+ Packages are served unbundled, exactly as npm publishes them, so `pnpm upgrade <package>` updates one of them in place. Browsers refuse bare specifiers, and an import map would only cover documents, so the service worker rewrites the imports of every script it serves from OPFS, for pages and workers alike:
194
+
195
+ - **Scanning:** `src/sw/scan.js` finds static imports, re-exports and dynamic imports of string literals while skipping comments, strings, templates and regular expressions. On the 21,000 scripts of slicc-agent's and slicc-spectrum's dependencies it finds exactly what es-module-lexer finds.
196
+ - **Resolving** (`src/sw/resolve.js`): bare specifiers resolve like Node does for a browser bundler, by walking up to the nearest `node_modules/<name>`.
197
+ - `exports` is read with the conditions `browser`, `import`, `module` and `default`, then the `browser` field (including its file mappings), `module` and `main`.
198
+ - A relative import's `browser` file mapping comes from the nearest `package.json` up to the importer's package root (a package directory in a `node_modules`, or `/os`), never from above it and never from `/`. Any process can write `/package.json`, so it must not remap seven's own imports.
199
+ - Extensionless relative imports get `.js`, `.mjs`, `.cjs`, `.json` or `/index.js`.
200
+ - **Node built-ins** (`node:x` and the bare names) become `/__slicc/node/<x>.js`. That's a generated module exporting exactly the names the importer asks for: `crypto.randomUUID`, `getRandomValues`, `webcrypto` and `subtle` are real, and everything else throws when called (not when imported). `node:worker_threads` is a real module instead: its `Worker` starts a module Web Worker, posts `workerData` to it first, and forwards `message` and `error`; inside that worker, `workerData` and a `parentPort` with `postMessage` and `on('message')` work as in Node.
201
+ - **CommonJS** (`src/sw/transform.js`): a script without module syntax that uses `require` or `exports` is wrapped as an ES module. Its `require()` calls become imports, `module.exports` is the default export, and `exports.x =` assignments become named exports. As in Node, the wrapped code also gets the free variables `process` and `global`. `global` is `globalThis`. `process` is the page's own `process` if there is one, and otherwise one shared minimal object (`platform: 'browser'`, `env: {}`, `argv: []`, `cwd()` returning `/`, and `nextTick`), so packages that read `process.platform` at load time (cross-spawn, which, isexe) load.
202
+ - **TypeScript** (`src/sw/strip.js`): `.ts`, `.mts` and `.cts` files are served as `text/javascript` with their types blanked out. Annotations, `interface` and `type` declarations, `import type`, `export type` and type-only specifiers, generics, `as` and `satisfies`, non-null `!`, `declare`, access modifiers, `abstract`, optional `?` and `implements` become spaces, so every line and column stays where it was and no source map is needed. This is what Node's `--experimental-strip-types` does, and for every TypeScript file of the three pi extensions in the test matrix the output is the same as Node's. Syntax with no erasable form (`enum`, a `namespace` with values, parameter properties, `import x = require()`, `export =`, `<T>x` and decorators) makes the module throw a `SyntaxError` with the file, line and column. An import of `./x.js` finds `x.ts` when there is no `x.js` (and `.mjs` finds `.mts`, `.cjs` finds `.cts`), as with TypeScript's `nodenext` and `bundler` resolution. Extensionless imports don't look for `.ts`. A `.cts` file is wrapped as CommonJS after stripping.
203
+ - **Caching:** rewritten scripts are cached per file until the file or `/pnpm-lock.yaml` changes, so an update is picked up without reinstalling the service worker.
204
+
205
+ ## Network
206
+
207
+ Before the kernel starts, `os/transport.js` picks how programs reach the network. The first of these that applies wins:
208
+
209
+ 1. **A local proxy:** [slicc-node](https://github.com/ai-ecoverse/slicc-node) (`npx @ai-ecoverse/slicc-node`) or slicc-swift. The launcher opens `https://seven.sliccy.ai/#proxy=<url>&key=<key>`. The page takes both from the fragment and removes them from the address bar. It accepts only a plain `http://` origin on `127.0.0.1`, `localhost` or `[::1]`, so a link can't send traffic elsewhere. It keeps them in IndexedDB (`slicc-os`, store `transport`), so a reload keeps using the proxy. Each load checks the proxy with `checkLocalProxy`. If the proxy refuses the key (every proxy process mints a new one), isn't a proxy, or doesn't answer although the page may reach loopback, the page forgets it and falls through. It keeps the proxy, and still falls through, when Chrome's Local Network Access permission is denied, or when nothing answered while the permission is undecided (Chrome asks only once it has a connection, so a dismissed prompt and a stopped proxy look the same).
210
+ 2. **[slicc-extension](https://github.com/ai-ecoverse/slicc-extension):** when the extension defines `globalThis.sliccExtension`, the kernel uses `fetchTransport({ fetch: sliccExtension.fetch })`.
211
+ 3. **The page's own `fetch`:** `fetchTransport()`, bound by CORS. A request CORS stops is answered `502` with a hint to run `npx sliccy@next` (the slicc CLI, until `sliccy`'s `latest` is the new CLI) or install slicc-extension, which `curl` prints.
212
+
213
+ A response body that sends nothing for 30 s on the extension or the page fetch fails with `ETIMEDOUT` (the same window as the installer's idle detector), so a stalled download doesn't hang. On the local proxy that window is 5 minutes, the kernel's default, because a server-sent stream there, such as an MCP tool call working without progress, may stay quiet for a long time.
214
+
215
+ The choice is in `document.documentElement.dataset.transport`: `local-proxy`, `extension` or `page`. `os/network.js` is spectrum's `NetworkPort` (`model.network`, spectrum ≥ 1.32.0), behind the header's **Network** indicator and panel:
216
+
217
+ - **Route and health:** `proxy` or `extension` is green (`ok`) and turns red (`failing`) when its last request failed, until one gets through. `page` is yellow (`limited`).
218
+ - **Detail:** one line, next to spectrum's own description of the route. It names the local proxy. When a local proxy was asked for but isn't used, it says why (blocked by the permission, not answering, or refusing the key) and what the page uses instead. When the route stops answering, it says so and what to do. It is empty on the extension and the page fetch while there is nothing to add.
219
+ - **Failures:** the last 20 requests that failed in the transport, newest first, with URL, error and time.
220
+ - **Check again** (`check()`) probes the local proxy again: on the proxy route it updates the health, and otherwise it reloads the page onto the proxy once it answers.
221
+ - **Get the whole web** links slicc-extension in the Chrome Web Store (`extensionUrl`).
222
+
223
+ ## Browser automation
224
+
225
+ Programs in the kernel drive a browser over the Chrome DevTools Protocol: `playwright-cli` and `curlwright` ([slicc-cdp](https://github.com/ai-ecoverse/slicc-cdp), in the base set) or anything else that reads `SLICC_CDP_URL`. The kernel's facade on `127.0.0.1:9222` takes connections only from programs inside the kernel. `playwright-cli route`, `route-list` and `unroute` (slicc-cdp ≥ 1.10.0) fulfil or change a tab's requests through `Fetch` on that tab's session while a command runs. A pattern that names a protected host is refused, and a request to a protected origin is never routed. slicc-cdp ≥ 1.11.2 keeps the protected origins out of reach on its own side too: `tab-list` leaves protected tabs out, a command that names one with `--tab` exits 1, `open` and `goto` refuse protected URLs, and a tab it touches can't load a protected document (a script, link, meta refresh or redirect into one gets Chrome's error page). It hands each one to the `cdp` hook that `os/cdp.js` gives `createKernel`, and the hook connects it to a host:
226
+
227
+ 1. **[slicc-extension](https://github.com/ai-ecoverse/slicc-extension):** when the extension defines `sliccExtension.cdp`, its `chrome.debugger` bridge drives the Chrome that seven runs in. Every program connection shares the page's one extension port. The hook gives each connection only the answers to its own commands and the events of the sessions it attached, and on close it detaches every session the connection attached.
228
+ 2. **The local proxy:** used when it is the transport (see [Network](#network)) and its probe says `cdp: 1`. The hook opens `ws://<proxy>/cdp` and offers the subprotocols `slicc.cdp.v1` and `slicc.key.<key>`, so the key never appears in a URL. It accepts the socket only when the proxy selects `slicc.cdp.v1`. The proxy checks the key and the page's `Origin` before the upgrade. slicc-node and slicc-swift keep one page client per `/cdp`, so every program connection shares one socket: the hook gives each command an id of its own, answers each connection with its own ids, and routes events by the sessions it attached (browser-level events go to every connection). When the last connection closes, so does the socket. When the proxy hands the socket to another page (`4001`) or loses its browser (`4002`), or closes it otherwise, every open connection is closed with a message saying which, and the next one opens a new socket.
229
+
230
+ **Seven's own pages and the vault are out of reach.** Every connection, whether through the extension, the local proxy or a linked browser, goes through a guard (`guardProtected` in `os/guard.js`), so no program in the kernel can drive, read or close a protected tab. What is protected comes from [slicc-browsers](https://github.com/ai-ecoverse/slicc-browsers#protected-origins)' `protectedOrigins` (in the base set): every `sliccy.ai` host, the vault (`vault.sliccy.ai`), `blob:`, `filesystem:` and `view-source:` URLs by the URL inside them, and `devtools:`, plus seven's own origin. A blank popup or frame (`about:blank`, `about:srcdoc`) is protected when its opener is protected or unknown, or when it was protected before. Seven's own pages can show files from OPFS that the user or the agent wrote, and the vault holds the user's secrets.
231
+ - **Hidden targets:** `Target.getTargets` and the target events leave protected targets out.
232
+ - **Refused commands** answer an error: any command whose `targetId` or `frameId` is a protected target (the guard asks for the target's current URL first) (`Target.attachToTarget`, `activateTarget`, `closeTarget`, `getTargetInfo`, `Browser.getWindowForTarget` and the rest, with or without a session); any command whose `url`, origin (`origin`, `securityOrigin`, `ownerOrigin`, `topLevelSite`, `scopeURL` and the like), `storageKey`, `cacheId` or cookie `domain` is protected, at any depth of its parameters, which covers navigation, `IndexedDB.*`, `CacheStorage.*`, `DOMStorage.*`, `Storage.*`, `ServiceWorker.*` and cookie writes; `Network.clearBrowserCookies`, `Storage.clearCookies`, `ServiceWorker.stopAllWorkers`, `BackgroundService.clearEvents`, `Storage.getPrivateVerificationTokens` and `Tracing.start`, which reach every origin; `Network.loadNetworkResource`, which follows redirects the guard can't see; `IO.read` and `IO.close` on a stream the guard didn't hand out in an answer; a `ServiceWorker` command for a registration or version the guard hasn't shown; `Target.detachFromTarget` of a session the connection didn't attach; and every `Fetch.*` command without a tab session, so nothing can pause the whole browser. Free-form values such as `expression` or `headers` aren't searched.
233
+ - **Filtered answers and events:** cookie lists leave out cookies whose domain is protected and partitioned cookies under a protected site. `Storage`, `DOMStorage`, `IndexedDB`, `CacheStorage` and `BackgroundService` events that name a protected origin are dropped, and so are service worker registrations and versions on one.
234
+ - **Checked on attach:** a new session is handed to the program only after the guard has checked its target and read the session's frame tree, and none of them is protected. An auto-attached page or frame without a URL yet is held, with its events dropped and its commands refused, until it has one, and cut if it has none after 10 s.
235
+ - **Cut sessions:** a session whose target becomes protected (`Target.targetInfoChanged`), whose page or any frame in it is about to navigate or navigates to a protected URL (`frameRequestedNavigation`, `frameScheduledNavigation`, `frameStartedNavigating`, `frameNavigated`, `navigatedWithinDocument`, or a `Document` request), or whose context runs on a protected origin is detached at once, without the event, and its target counts as protected from then on. Any other page event that names a protected URL or origin is dropped. Its later events and the answers to its pending commands are dropped, and its commands are refused. The guard watches each session's `Page` events itself, and passes them on only when the program asked for them.
236
+ - **Requests from protected documents:** a request whose URL, document, frame, initiator, `Referer` or `Origin` is protected never reaches the program. The guard continues its `Fetch` pause itself, drops its `Network` events, and refuses its bodies (`Network.getResponseBody`, `getRequestPostData`, `Fetch.getResponseBody` and the like). Bodies, `replayXHR` and `Fetch` replies for a request the guard hasn't seen are refused too. Extra-info events that come before their request wait up to 2 s for it, then are dropped.
237
+ - **Flat sessions only:** `Target.attachToTarget` and `Target.setAutoAttach` need `flatten: true`. `Target.sendMessageToTarget`, `Target.attachToBrowserTarget` and `Target.exposeDevToolsProtocol` are refused, so no command can be tunnelled past the guard.
238
+
239
+ A program picks a host by adding `runtime=extension` or `runtime=proxy` to the query of the URL it opens (`playwright-cli --runtime proxy`). With no runtime, the extension goes first. The hook never derives a URL from the request: it reaches only the extension's page global and the proxy the page already checked.
240
+
241
+ **No consent dialog.** A connection goes straight through once the hook has found a host; seven doesn't ask first. The once-per-session confirm from #138 was removed by Lars's decision W10. The Network panel shows the host in its browser automation line (`browser` on the network port, from `browserVia`): through the extension, through slicc-node, or not available.
242
+
243
+ When no host can drive a browser, `/json/list` and the WebSocket handshake answer `502` with `CDP host: no browser to drive: install slicc-extension, or run npx @ai-ecoverse/slicc-node`. A runtime that isn't `extension` or `proxy` gets `CDP host: unknown runtime "<name>" (extension, proxy)`. `/json/version` answers either way, since a hook is always offered.
244
+
245
+ ### Tailscale (proof of concept)
246
+
247
+ seven can also join a tailnet, with a patched build of Tailscale's wasm client ([`@tailscale/connect`](https://www.npmjs.com/package/@tailscale/connect)) that adds a full HTTP `fetch`, raw `dial` (TCP and UDP, available to the page as `sliccTailscale.dial`), exit-node selection and status. It runs in its own module worker, `os/tailscale-worker.js`. No CORS applies, because Go makes the requests itself over the tailnet, and its relays (DERP) carry the traffic over WebSocket.
248
+
249
+ - **Turning it on:** the Network panel's **Use Tailscale** switch (spectrum ≥ 1.38.0) starts it at once and remembers the choice. Opening seven with `#tailscale=on` or `#tailscale=off` does the same. The setting, the exit node and the node's state are kept in IndexedDB (`slicc-os`, store `transport`), so the node keeps its identity across reloads.
250
+ - **The Network panel** (`os/network.js` passes `os/tailscale.js`'s panel through as `NetworkStatus.tailnet` and the port's `setTailnet`, `setExitNode`, `submitAuthKey` and `logoutTailnet`) shows:
251
+ - the node's name and 100.x address and how many other devices are on the tailnet;
252
+ - the exit node picker: None, Automatic (`auto:any`, the default), or one of the tailnet's exit nodes;
253
+ - whether shields are up;
254
+ - **Sign out**;
255
+ - when it fails, why, with Retry (`check()` starts it again).
256
+
257
+ `document.documentElement.dataset.tailscale` shows the backend state, or `off`. While an exit node is in use, the panel's Connection block reports `route: 'tailnet'` (spectrum ≥ 1.41.0), with health `ok` and a note that this computer's own services still go through the base route. Without one, it reports the base route.
258
+ - **Joining:** **Sign in to Tailscale** opens Tailscale's login in a new tab. Or paste an auth key into the panel's field. A key is held in memory for that one login and is never stored, put in a URL or logged. An auth key in the fragment is dropped with a warning. The integration test hands its key to the page in `globalThis.sliccTailscaleAuthKey` through an init script, and the page takes it once and deletes it. The tailnet object is exposed as `globalThis.sliccTailscale` only when an init script sets `globalThis.sliccTailscaleDebug`, as the integration test does.
259
+ - **Nothing inbound:** the node runs with shields up, so tailnet peers can't open connections to it. The build has no peerapi server, and the page listens on nothing. Peers can't reach the kernel's services, `*.kernel.localhost` or the CDP facade.
260
+ - **Where the wasm comes from:** shown as **Tailscale (/opt/tailscale)** in [Install / Update](#install--update). [`@ai-ecoverse/wasm-tailscale`](https://www.npmjs.com/package/@ai-ecoverse/wasm-tailscale) (built in [ai-ecoverse/wasm-tailscale](https://github.com/ai-ecoverse/wasm-tailscale), certified, 1.104.1-8) is a pnpm project of its own, [`src/packages/tailscale/`](src/packages/tailscale/), installed like the grammars, never at boot. The first time Tailscale is turned on, `os/tailscale.js` writes the deployed manifest and lockfile to `/opt/tailscale` and runs `pnpm install --frozen-lockfile --trust-lockfile` there under the Web Lock `slicc-tailscale`. The panel shows the download progress. The lockfile is recorded in `var/lib/slicc/tailscale/pnpm-lock.yaml`, so later starts skip the install, and a new deployed lockfile installs the new version on the next start. The page then reads `main.wasm` and `wasm_exec.js` through the kernel. A host folder mounted at `/mnt/tailscale`, or a folder named in the stored setting's `from`, (`npx @ai-ecoverse/slicc-node --mount <dist>:tailscale:ro`, then `mount -t hostfs tailscale /mnt/tailscale`) wins over the package, for trying a local build.
261
+ - **Routing:** the kernel keeps the transport [picked above](#network), and Tailscale is layered over it. While an exit node is in use, every request goes through the tailnet, except what [never goes out](#network) (loopback keeps `host.slicc.internal` on the local proxy). Without one, only tailnet addresses (`100.64.0.0/10`, `fd7a:115c:a1e0::/48`) and MagicDNS names go there.
262
+ - **Exits:** `os/exits.js` routes traffic over any number of exits: Tailscale, and linked devices.
263
+ - **Linked devices:** `os/link.js` links a `slicc` CLI to the page over WebRTC, as one exit per link. It speaks the [slicc link protocol](https://github.com/ai-ecoverse/slicc-cli): slicc-node's tunnel frames over data channels, with one channel per stream and a 256 KiB credit window per stream and direction.
264
+ - **Setup:** a CLI that holds CDP to this browser calls `globalThis.sliccLinkReceive` with a `hello` and its nonce, and the page answers through the CLI's `sliccLinkSend` binding: the offer, then its ICE candidates. The CLI checks that the call came from seven's main frame. CDP carries only the setup; the data goes over WebRTC. Every `hello` names its CLI process (`node`): a new `hello` from the same process replaces its link (after a reload, say), and one from another process (`slicc prompt` next to `slicc`) adds a second link.
265
+ - **Half-open links:** a link that hasn't said HELLO within 30 s of its offer (a follower that gave up on a bootstrap, say) is closed, and its peer connection with it. A device's row follows its link that said HELLO, never a newer half-open one; with only a half-open link left, a known device shows as away. Unlinking a device drops every link it has and tells the ones that said HELLO.
266
+ - **Names:** each link gets an address from `198.18.57.0/24` (the first is `.11`) and the name `<host>.slicc.internal`, from the CLI's hostname, made DNS-safe and unique (`lars-mbp-2`). A link keeps its name when it reconnects, also after a reload and in any order, while the name is free: the page remembers each CLI process's name (by device certificate and process id) in `localStorage` (`slicc-os.link-labels`). A CLI that restarts is a new process, so it may come back as `lars-mbp-2` while its old link is still closing; once that link is gone, the new one takes the name back (and its SSH host key is written under it). Port 22 there is the CLI's built-in SSH server, and any other port is the CLI machine's loopback, under the CLI's policy.
267
+ - **Into the page (`kernel-in`):** the page's HELLO offers `kernel-in`, so a local CLI serves `http://<port>.kernel.localhost` on its own loopback (port 80, or `--kernel-port`) and carries each connection into the page as `OPEN { kind: 'kernel', port }`. The page answers with `kernel.dial({ port })` and pipes both ways with half-close under the same credit window. It refuses port 9222, the CDP facade, with `EACCES`, and anything but a port 1–65535 with `EINVAL`. A port nothing listens on gets `ECONNREFUSED` (the CLI answers 502). Every other node-opened kind is `EOPNOTSUPP`, and an OPEN without a JSON `kind` is `EPROTO`. This replaces slicc-node's kernel tunnel for a linked CLI.
268
+ - **Browsers (`cdp`):** a CLI with `cdp` lists its browsers in HELLO, and later in `OPEN { kind: 'browsers' }` updates (the page offers cap `browsers`, answers OPENED and END, and replaces the list). Each browser is a runtime of the 9222 facade named `<device>/<browser>`, so `playwright-cli --runtime lars-mbp/chrome …`, or `/json/list?runtime=lars-mbp/chrome`, drives it. Each facade connection opens its own `OPEN { kind: 'cdp', browser }` stream, framed as a `u32` length plus one JSON message (at most 64 MiB, otherwise the stream is reset with `EMSGSIZE`). Without a runtime, the facade still prefers slicc-extension, then slicc-node, then a linked browser that hosts this seven (`seven: true`), then the first linked one. The Network panel lists each device's browsers with their runtimes (`devices[].browsers`), and browser automation reads `via: 'link'` when linked browsers are what it drives.
269
+ - **Sync:** the Network panel's Sync section (spectrum's `model.tray`) shows this tray as `os/tray.js` sees it:
270
+ - **Name and role:** seven's name, `leader` while it leads the tray (`none` when disconnected), and `hosted`.
271
+ - **Connection:** Live, Connecting, Reconnecting, Error or Offline.
272
+ - **Join link**, the same one Linked devices offers.
273
+ - **Followers:** the connected remote devices, sevens as `browser` and CLIs as `cli`, named as Linked devices names them (a leading `slicc` reads `SLICC`). While Sync is Offline the list is empty.
274
+ - **Disconnect and Reconnect:** they stop and restart the tray (`stopLinks`, `retryLinks`). Links that are already up stay up.
275
+ - **Other tabs** show the same, through the owner tab's network port.
276
+ - **The agent's tray:** when the agent is running, its tray port (the Adobe budget, spend and rate) is joined with this one, so both show.
277
+ - **Webhooks:** the tray also has a webhook URL, `https://www.sliccy.ai/webhook/<token>`, shown as `status().links.webhookUrl`. A tray kept from before seven knew webhook URLs is replaced once: seven makes a new one and asks the hub to point the old join URL at the new one (`POST /api/tray/<trayId>/supersede`), so its followers move over. Until the hub takes it (any answer below 500), seven asks again on every reconnect. If the new tray is later gone and replaced, the old join URL is pointed at the replacement. Rotating the join URL drops this, since rotation is meant to revoke the old URL. A `POST` to `<webhook URL>/<name>` reaches seven's leader socket as `webhook.event`. `os/tray.js` hands it to the agent as a delivery for `~/.slicc/webhooks/<name>.json` (slicc-agent's licks), through `connection.webhook(name, { id, headers, body })`.
278
+ - **Names:** a name is 1–64 of `a-z 0-9 . _ -`, starting with a letter or digit; anything else is dropped.
279
+ - **Headers:** `authorization`, `proxy-authorization` and `cookie` are dropped, and the rest are passed on.
280
+ - **Body:** it is passed on as text: JSON as the hub parsed it, or the original text when it wasn't JSON. A `{"raw": …}` body from a JSON request stays JSON.
281
+ - **Retries:** an `idempotency-key` or `x-github-delivery` header becomes the delivery id, so a retry isn't delivered twice.
282
+ - **No agent:** with no agent running, or a slicc-agent without page-side webhooks, the delivery is dropped with a warning. The hub has already answered the sender `202`.
283
+ - **Remote control (`agent`):** a linked CLI can prompt, watch and stop seven's agent (`slicc prompt`, `slicc watch`, `slicc abort`). The page offers cap `agent` to a CLI on this computer always, and to a remote device only after **Control** is turned on for it in Linked devices. That setting is kept per device certificate and cleared by unlinking it. Any other link's agent OPEN gets `EACCES`.
284
+ - Each `OPEN { kind: 'agent', op }` goes to the agent of the tab that runs the kernel, on its active cone, through slicc-agent's `remoteControl`. `prompt` answers OPENED with how the prompt was delivered (or `RESET EBUSY` for `whenBusy: 'reject'` while busy), then streams NDJSON events from the prompt's own `user` event to its `end` line. A prompt over about 60 KiB comes as `length` bytes after an empty OPENED, and the first line then says how it was delivered. `watch` answers with the transcript `head` (and `gap` when `since` is gone), then streams events. `abort` answers `{ aborted }`.
285
+ - A CLI that goes away ends its stream but never aborts the run; aborting is explicit.
286
+ - Linked devices lists every device that has linked, offline ones too, with its control setting, and shows **Prompting** or **Watching** while a CLI does, also in the header.
287
+ - A device that reconnects replaces only its own process's link, so `slicc <join> prompt` doesn't drop that machine's `follow`. Control-only links (`caps: []`) get no `.slicc.internal` name and no exit.
288
+ - **Folders (`fs`):** a CLI started with `--mount <path>[:<name>][:ro]` lists its folders in HELLO, and later in `OPEN { kind: 'mounts' }` updates; the page offers cap `mounts`. Each is a `hostfs` source named `<device>/<name>`, so `mount -t hostfs lars-mbp/proj /mnt/proj` mounts it, and nothing is mounted unless someone asks. A bare source with no `<device>/`, as slicc-node named its folders (`mount -t hostfs project /mnt/p`, or one remembered from then), is the folder of that name on the CLI linked on this computer, when exactly one local CLI shares one. The device's row in Linked devices lists its folders (`folders`, with a Read-only badge; spectrum ≥ 1.58.0): **Mount** (`mountFolder(source)`) mounts one at `/mnt/<device>/<name>` (a folder named `.` or `..` is never listed), and **Eject** (`ejectFolder(source)`) unmounts it. The Files panel's Mounted strip shows each `hostfs` mount's source (`mountInfo()`), so `/mnt/proj · lars-mbp/proj` reads as another computer's folder. A mounted linked folder stays with the CLI process it was first granted from (`slicc-os.link-sources`), even if that process comes back under another name. If that process isn't linked, the folder goes to whichever process has the name now, but only when no other process with the same host name shares a folder of that name; otherwise it waits. So a remembered `lars-mbp/proj` never lands on another process's `proj`. A remembered linked folder that is mounted again before its CLI is back stays `nomedium`, and fills in when the CLI links. The kernel's `hostfs` hook answers such a source with a `MessagePort` (slicc-kernel ≥ 1.54.9), and the page serves it with `serveHostfsPort` over `OPEN { kind: 'fs', mount, op, … }` streams: `list` collects its NDJSON, a read over 64 KiB and every write get their own channel, and `watch` streams the CLI's change events. A read-only kernel mount, or a read-only export, sends `readonly: true` on every OPEN. A link that drops makes the mount `nomedium` until the CLI is back; files that were open reopen by path and continue if the host didn't change them.
289
+ - **SSH host key:** the CLI's HELLO carries its host key, and the page writes `<host>.slicc.internal,<address> <key>` into the system-wide `/etc/ssh/ssh_known_hosts`, replacing older entries for that name or address, so `ssh` never trusts a key on first sight, whichever user runs it. Seven manages these keys, not the user, and OpenSSH looks up a user's own file under the passwd home, which isn't always `$HOME`.
290
+ - **Tests:** `test/integration/remote.test.mjs` runs the real `slicc watch`, `slicc prompt` and `slicc abort` with `--signal stdio` against seven's real agent and a fake Bedrock. It also leads a fake tray hub and sends a `webhook.event`, which reaches the agent as a webhook lick (without its `authorization` header) and starts a run. `test/integration/tray.test.mjs` leads a fake tray hub and links a follower in the page through it, then rotates and unlinks. `test/integration/link.test.mjs` links a fake node in the page over a real `RTCPeerConnection`, and, with `SLICC_CLI` set, the real `slicc attach --signal stdio`, relaying its NDJSON through the CDP binding: raw TCP and the http kind; kernel `ssh` with strict host-key checking against `--runner sh -c`, after `pnpm add -g @ai-ecoverse/wasix-openssh`; and, with the link as the exit, a name from the CLI's `--hosts` file, then refused under `--deny`; and `<port>.kernel.localhost` through the CLI's listener into a kernel server (`httptest`), with 502 for a closed port and the facade refused; a folder shared with `--mount`, mounted as `ref-node/proj`, read, written, seen changing on the host, and refused for writes when mounted `ro`; a second headless Chromium shared with `--cdp-extra`, listed and driven by `playwright-cli --runtime ref-node/<id>`; and a program WebSocket from kernel `bash` through the proxy to a `ws` echo server on the CLI's loopback: text, binary, the subprotocol, a close from each end, and `EACCES` under `--deny`. CI installs the CLI at a pinned commit with `go install`.
291
+ - **As the exit:** a CLI linked on this computer (`npx sliccy@next`) is the exit by itself until the user chooses one, so the kernel's HTTP and raw TCP get past CORS at once, as slicc-node's proxy did; a Tailscale exit node that is on comes first. A remote device that offers to be a default exit carries other traffic only once it's chosen. The Network panel's **Internet traffic** picker (`setExit`) is one choice across Directly, Tailscale exit nodes and linked devices: choosing a device turns off Tailscale's exit node, and choosing Tailscale clears the device. The choice of a device is kept in `localStorage` (`slicc-os.exit`), and while that device isn't connected, after a reload or while it reconnects, internet traffic **waits**: raw connections get `ENETUNREACH` and HTTP fails, rather than going out directly. Choosing Directly sends it directly again and is kept (`direct`), so a local CLI doesn't take it over, while a Tailscale exit node turned on later still does; unlinking the chosen exit device goes back to the automatic choice.
292
+ - **What the agent sees:** the transport's `traits` name the default exit while there is one, so the agent can say how its fetches go. `traits.exit` is `{ kind, caps }`:
293
+ - **`kind`:** `local` (a CLI on this computer), `remote` (a chosen linked device) or `tailnet` (a Tailscale exit node);
294
+ - **`caps`:** what it carries, such as `['net', 'http', 'ws']`;
295
+ - **Absent:** while traffic goes directly, or waits for an exit that's away.
296
+
297
+ The Network panel's status says the same: while a linked device carries the traffic, `route` is `'link'`, next to `exit: { kind: 'link', id }`, and that device's `mode` tells local from remote. A Tailscale exit node reads `'tailnet'`, as before.
298
+
299
+ `traits` is read fresh each time, and every change to the exits (a link coming up or going, the tray, Tailscale, a new choice) calls `kernel.updateTransportTraits()` (slicc-kernel ≥ 1.80.0), so connected clients such as the agent get the new value through `onTraits`. Routing never depends on it: every request still goes through the router. With `globalThis.sliccKernelDebug` set by an init script, `globalThis.sliccKernelClient()` attaches a kernel client, as the integration test does.
300
+ - **The host machine:** the kernel sends `host.slicc.internal:<port>` to the transport as `127.0.0.1:<port>`, and that goes to the CLI linked on this computer (its `http` streams, under its policy), so a dev server on the host is reachable without CORS or Chrome's Local Network Access prompt. This holds even under Directly, since it isn't internet traffic. A remote device's loopback is never used for it, and without a local CLI it stays on the page's transport.
301
+ - **WebSockets (`ws`):** a program's WebSocket (the kernel proxy's `Upgrade: websocket`, or `ctx.websocket`) goes to the exit that would carry that host's HTTP, and a linked CLI with cap `ws` carries it as `OPEN { kind: 'ws', url, protocols, headers }` (`os/link-ws.js`).
302
+ - **The handshake:** the CLI dials it under its policy, with the system's TLS roots. OPENED answers `{ protocol }` with the subprotocol the far end chose, and a refusal is a RESET, which the program sees as `502`.
303
+ - **Messages:** both ways, each one is a record: a type byte (`1` text, `2` binary, `8` close), a big-endian `u32` length, then the payload. A close carries its code (`u16`) and reason. Messages are at most 64 MiB either way (a larger one closes with `1009`; a program's `send` of one also fails with `EMSGSIZE`), and at most 16 MiB wait unread before the socket closes with `1009` and drops what's queued. A close reason is cut to 123 bytes at a character boundary. Pings stay between the CLI and the far end.
304
+ - **When nothing carries it:** an exit without WebSockets (a Tailscale exit node, or a CLI without cap `ws`) answers `502`. A held exit waits as HTTP does.
305
+ - **No secrets yet:** a handshake whose URL or headers carry a vault placeholder (`fnox_proxy_…`) is refused (`EACCES`, "secrets in WebSocket handshakes aren't supported yet"), so the placeholder never leaves seven.
306
+ - **Never through the page:** seven never opens a browser WebSocket itself. Unlike `fetch`, a browser WebSocket can't leave out the user's cookies for the site it reaches, so a program could act as the user there. With **Directly**, or no exit, a WebSocket is `501` with a hint to link a slicc CLI.
307
+ - **Network panel:** `status().links.devices` lists the linked devices (name, `<host>.slicc.internal`, address, mode, what they offer, the CLI's policy summary, whether they can be the exit, and their state), `status().exit` is the current choice, and `unlink(id)` drops a device. On `*.sliccy.ai` the panel also has the join URL, `rotateJoinUrl` and `retryLinks`.
308
+ - **Remote devices (the tray hub):** on `*.sliccy.ai`, `os/tray.js` makes seven a **tray leader** on SLICC's tray hub (`slicc-tray-hub`), through the sliccy-ai worker's same-origin proxy for `POST /tray`, `/controller/<token>` and the leader WebSocket, which drops cookies. The hub mints the join URL on `www.sliccy.ai`, and the panel shows `npx sliccy@next <join-url> follow` (`@next` until `sliccy`'s `latest` is the new CLI; `latest` is still six's). Followers then link the same way as a local CLI: seven offers, and the offer, answer and candidates go over the hub's bootstrap messages. TURN comes from the hub.
309
+ - **Who may link:** only followers with runtime `slicc-link/1` and full trust. A device **is its DTLS certificate**: its id is the SHA-256 fingerprint in its answer. The CLI keeps one certificate per hub and mints a new `controllerId` for every connection, which is only a routing id. A device that reconnects replaces its old link and gets its address back, and a new certificate is a new device. Known devices (name, label, address, what they offer) are kept in `localStorage` (`slicc-os.devices`), keyed by fingerprint, so a device that's away shows as reconnecting. The exit choice is keyed the same way.
310
+ - **Unlink:** a connected device is told `OPEN { kind: 'unlink' }` and forgets its join URL and identity. Its fingerprint is refused from then on: an answer that carries it fails the bootstrap with `UNLINKED`. Running the join command again mints a new certificate, so it links as a new device.
311
+ - **New join URL:** seven leads a new tray, tells every connected device that supports it the new URL (`OPEN { kind: 'join', url }`), and only then stops leading the old one, so a leaked URL stops working. A device that was away has to be linked again.
312
+ - **Followers (seven on another device):** Link a device also offers **Open on another device**, `https://<this origin>/#follow=<join-url>` as a link and a QR code (spectrum ≥ 1.52.0, `links.followUrl`). A seven opened that way is a **follower**: it runs neither a kernel nor an agent of its own and shows the owner's chat and terminals over the link instead.
313
+ - **The page:** `os/start.js` picks `os/follower.js` over `os/os.js` when there is a follow URL. The page reads `#follow` and removes it from the address bar. It accepts only a join URL on the tray hub, `www.sliccy.ai` (or the hub `slicc-os.tray-hub` names, or loopback for tests), and it never follows a tray that moves to another hub. A new link asks first: *Follow a SLICC on another computer?* says that every SLICC tab in this browser then shows the other SLICC until **Leave**. **Cancel** goes back to this browser's own SLICC. Only **Follow** keeps the join URL in `localStorage` (`slicc-os.follow`), so the page reconnects after a reload until Leave. It offers the Agents, Chat, Terminal and Settings panels only, and `model.follow` (spectrum's follow port) says whom it follows (`owner`), the link (`connecting`, `connected`, `reconnecting`, `unlinked`, `failed`) and what it may do (`access`).
314
+ - **Joining:** the follower attaches to the join URL as runtime `slicc-seven/1` through the sliccy-ai worker's same-origin `/join/<token>` proxy, polls the bootstrap, answers seven's offer and trickles its candidates. It keeps polling until HELLO, so an `UNLINKED` refusal still reaches it, and follows a moved tray (in the body, `Link` or `x-tray-location`) at most five times per dial. Each dial has a fresh `controllerId`, and a dropped link dials again after 1, 2, 5, 10, then every 30 seconds.
315
+ - **Identity:** one persistent DTLS certificate per hub origin (`RTCPeerConnection.generateCertificate`, ECDSA P-256 for a year, in the IndexedDB database `slicc-follow`), so the device and its access survive reloads. Unlinking deletes it.
316
+ - **The link:** the follower answers as the link's node: its HELLO says `role: 'node'`, `kind: 'seven'`, caps `join` and `permissions`, and its tab as `node`, and it opens even stream ids. The owner treats a link as a follower because the hub said `slicc-seven/1`, and closes it if HELLO disagrees. A follower gets no `.slicc.internal` name, no exit, and none of the CLI kinds: the owner refuses `kernel`, `agent` and `browsers` from it with `EOPNOTSUPP`.
317
+ - **View, Control and Terminal:** each follower certificate has three switches in Linked devices, off by default and kept with the device in `slicc-os.devices` (`setAccess` on the network port). Control and Terminal need View, so turning either on turns View on, and turning View off clears both. Without View a follower links but gets nothing: it shows as **Asks to follow**, with Allow, and its page says it waits for the owner. The owner's HELLO carries the current switches, and every change goes to the follower as `OPEN { kind: 'permissions', view, control, terminal }` on the control channel. Losing View resets the follower's open streams with `EACCES`, and so does losing Terminal for its terminals. For a CLI, the old **Allow remote control** is the same Control switch.
318
+ - **Chat (`pi`):** with View, the follower opens `OPEN { kind: 'pi' }` and gets the owner's agent, the same replicated chat a follower tab has. The owner bridges the stream to `owner.port({ readOnly, pages: false })` with slicc-agent's `bridgePort` (agent ≥ 3.32.0), read-only unless the device has Control, and without `pages`, so a follower's agent can't drive the owner's panels. The follower builds its `AgentConnection` with `linkEndpoint` and `connectAgent`, and spectrum's agent model on top. The stream carries slicc-agent's frames: a `u32` length, a `u8` tag (data, hello or close) and the payload. Without Control, a send, steer or stop is refused by the owner's agent with `EACCES`, and the chat shows that it watches only. Changing Control resets the stream, so the follower reconnects with the new access, and losing View resets it with `EACCES`. The follower installs `/opt/agent` itself, for `page.js` and the agent model, in a kernel it starts only for that install. Linked devices shows *Watching* while a follower's chat is open.
319
+ - **Terminals (`terminal`):** with View and Terminal, the follower's Terminal panel opens shells in the owner's kernel. Each one is `OPEN { kind: 'terminal', cols, rows, cwd? }`, and the owner runs `bash -i` there with its own terminals' prompt, as the same user its own terminals use (root today, so Terminal is a root shell on the owner's SLICC), and answers OPENED `{ pid }`. Both directions then carry records, each a `u32` length, a `u8` type and a payload: `0` terminal bytes, `1` a resize as `{ cols, rows }` JSON, `2` a signal name (`INT`, `TSTP`, `QUIT`, `HUP`), and, from the owner only, `3` the shell's exit status as a decimal string, just before END. A bad record or size resets the stream with `EPROTO` or `EINVAL`. The follower's END closes the shell, and the owner ends the stream when the shell exits. Linked devices shows *Terminal open* while one runs (`terminals`), and turning Terminal or View off resets every open shell with `EACCES`.
320
+ - **Names:** both sides call themselves `SLICC on <platform>` (`navigator.userAgentData.platform`). When two show the same name, the first four hex digits of the certificate fingerprint are added.
321
+ - **The tray:** its capabilities are kept in `localStorage` (`slicc-os.tray`), so the join URL survives reloads. A hub that forgot the tray gets a new one. The leader socket pings every 30 s and reconnects with backoff. Elsewhere (`localhost`, tests) the tray starts only when `localStorage['slicc-os.tray-hub']` names a hub.
322
+ - **Kernel side:** the kernel gets one uplink and one route table, the union of what the active exits offer, pushed with `kernel.setRoutes`.
323
+ - **Choosing an exit:** for each destination, an exit that claims it (a tailnet address or MagicDNS name) wins. Otherwise the default exit takes it: the one that offers a default route, for example Tailscale with an exit node. Otherwise HTTP stays on the picked transport, and raw connections are `ENETUNREACH`.
324
+ - **What never goes out:** loopback, `10.0.2.2` and reserved addresses (IPv4-mapped IPv6 forms included), the kernel's own names, and single-label names no exit claims. This holds for raw connections and for HTTP alike: HTTP to them stays on the picked transport, even while a default exit is in use.
325
+ - **Raw connections (stage B, slicc-kernel ≥ 1.28.0):** the kernel also gets `os/tailscale.js`'s uplink, so a program's `connect()` to a tailnet address, or to any address while an exit node is in use, goes through the tailnet (`tailnet.dial`). This needs no proxy, for example `curl --noproxy '*' http://100.x.y.z:8080/`.
326
+ - **Routes:** the page pushes the route table with `kernel.setRoutes` whenever it changes: `100.64.0.0/10` and `fd7a:115c:a1e0::/48`, plus everything while an exit node is in use.
327
+ - **Names:** MagicDNS names of the other nodes resolve from the netmap. The page never answers or forwards the kernel's own node name (`uname -n`, and `emscripten`, `slicc` and `wasmer.sh`), nor a single-label name the netmap doesn't know: the kernel answers its own hostname locally (slicc-kernel#181), and the uplink says "not mine". While an exit node is in use, other names resolve over DNS-over-HTTPS (`1.1.1.1`) through it.
328
+ - **What stays out:** the kernel keeps loopback (the CDP facade included) and `host.slicc.internal` off the uplink, and the page also refuses to dial loopback, `10.0.2.2` and reserved addresses.
329
+ - **Who can use it:** every process in the kernel, since joining is per machine.
330
+ - **Nothing comes in:** the node keeps shields up, and nothing listens on a tailnet address.
331
+
332
+ ## Kernel servers
333
+
334
+ A server running inside the kernel (vite, `python -m http.server`, impeccable `live`) listens on the kernel's own loopback, not the machine's. Pages reach it as **`http://<port>.kernel.localhost/`**:
335
+
336
+ - **Testing on a tailnet of its own:** `test/integration/tailnet.mjs` starts one on this machine with no account, sign-in or key from anyone:
337
+ - [headscale](https://github.com/juanfont/headscale) with its embedded DERP over plain HTTP, behind a small CORS proxy for the browser's control requests;
338
+ - two userspace `tailscaled` nodes, `peer` (a web page and a half-close echo) and `exit` (an approved exit node).
339
+
340
+ The stage B integration test then joins seven with a headscale pre-auth key (`hskey-…`, handed over in `globalThis.sliccTailscaleAuthKey`). The stored setting `derpOverHttp` makes the wasm speak DERP over `ws://`, and is only for this. The test checks:
341
+ - curl over raw TCP to the peer, by address and by MagicDNS name;
342
+ - `git clone` from the peer by name (wasm-git 2.55.0-11, installed with `pnpm add -g` in the test; `TS_GIT=0` skips it);
343
+ - half-close;
344
+ - `10.0.2.2` refused;
345
+ - no inbound connections on 80, 5710 or 9222;
346
+ - public addresses unreachable without an exit node, and reachable by address and by name with one.
347
+
348
+ To run it: `HEADSCALE=… TAILSCALED=… TAILSCALE=… TAILSCALE_E2E=1 npm test -- --test-name-pattern 'stage B'` (`TS_SHOTS=<dir>` also saves screenshots of the Network panel and the Install / Update row).
349
+ - **Plain `localhost` and `127.0.0.1` stay the real machine.** The service worker passes them through untouched. Inside the kernel it's the other way round: `localhost` is the kernel loopback, and `host.slicc.internal` reaches the machine. The kernel exports the page-visible name as `SLICC_PAGE_LOOPBACK`, so tools can print the right URL.
350
+ - **Why the port goes in the name.** `kernel.localhost:8400` would resolve to the machine's real port 8400 whenever the service worker doesn't answer, as for a navigation or a WebSocket, and reach the wrong server. `8400.kernel.localhost` lands on port 80, which is closed or answered by [slicc-node](https://github.com/ai-ecoverse/slicc-node)'s kernel tunnel, and it gives every kernel port its own origin. The service worker routes by the name alone and ignores the URL's port.
351
+ - **How a request gets there.** A seven page's request to `*.kernel.localhost`, with any method, reaches the service worker. `*.localhost` is potentially trustworthy, so an `https` page loads it without a mixed-content block. The service worker answers it itself, so nothing goes to the network, no Local Network Access prompt is shown, and there's no CORS preflight. It hands the request to the `/os/` tab: the requesting one, or else the most recently focused. That tab calls `kernel.loopbackFetch(request, { port })` and transfers the streamed response body back.
352
+ - **The response.** Status, headers and body pass through. Bodies stream, so `EventSource` and long-poll work, and closing the `EventSource` closes the kernel socket. seven's pages are cross-origin isolated, so where the server sent none, the service worker adds `access-control-allow-origin: <seven's origin>` with `access-control-allow-credentials: true`, and `cross-origin-resource-policy: cross-origin`. That lets a plain `<script src>` or `fetch` load from a server that knows nothing about CORS.
353
+ - **When it fails.**
354
+ - Nothing listens on the port: `502` `sw: nothing listening on kernel port <port>`.
355
+ - No `/os/` tab is open: `502` `sw: no kernel to reach <port>.kernel.localhost`.
356
+ - Any other kernel error: `502` that names the port.
357
+ - The headers take longer than 30 s: `504` `sw: no answer from kernel port <port> for <path> in 30 s`.
358
+ - After the headers, the 30 s applies to each chunk, not to the whole response. A stream that stays silent for 30 s is ended and its socket closed. `EventSource` reconnects by itself, so a server should send a comment line as a heartbeat. Chrome may also stop the service worker during a long stream, and `EventSource` reconnects then too.
359
+
360
+ What doesn't work through the service worker:
361
+
362
+ - **WebSockets.** A service worker never sees them, so `ws://<port>.kernel.localhost/` goes to the machine's port 80. Vite's HMR can't connect: the page loads and runs, but needs reloading by hand.
363
+ - **Opening a kernel server as a page.** A navigation to `http://<port>.kernel.localhost/` is another origin, which seven's service worker doesn't control.
364
+ - **Cookies.** The responses are built in the page, so `Set-Cookie` is dropped.
365
+
366
+ **slicc-node's kernel tunnel** carries what the service worker can't. slicc-node listens on `127.0.0.1:80`, or on the port `--kernel-port` gives it, and the URLs then carry that port: `http://8400.kernel.localhost:8080/`. It hands every connection to `*.kernel.localhost` to the page through a WebSocket, as raw bytes, so navigations, keep-alive and WebSocket upgrades (vite HMR) work. A page opened this way is its own origin, isolated from seven's.
367
+
368
+ - **Opening the tunnel.** When the local proxy's probe reports `kernelTunnel`, `os/tunnel.js` opens `ws://<proxy>/api/kernel-tunnel`, with the proxy key as a WebSocket subprotocol.
369
+ - **Serving streams.** For every stream slicc-node opens, the page calls `kernel.dial({ port })` and pipes the bytes both ways. Each direction may have at most 256 KiB in flight before the other side credits it, so a slow stream doesn't hold up the others.
370
+ - **A dial that fails** is reset with its error code. slicc-node then answers `502 nothing listening on kernel port <port>`.
371
+ - **Reconnecting.** A dropped tunnel reconnects after 1, 2, 5, 10, then every 30 seconds.
372
+ - **Several tabs.** slicc-node gives new streams to the most recently connected tunnel. A seven tab reconnects when it gets focus. When another seven tab's tunnel opens, for example after slicc-node restarts, a tab that has focus reconnects too, over the BroadcastChannel `slicc-kernel-tunnel`. So the focused tab's kernel takes new streams, and an old tunnel closes once its open streams have ended.
373
+
374
+ The contract is in slicc-node's README, under [Kernel services](https://github.com/ai-ecoverse/slicc-node#kernel-services).
375
+
376
+ ## Updates
377
+
378
+ A running install updates itself from the deployment it was booted from, package by package, without a reload:
379
+
380
+ 1. **Notice.** `os/update.js` fetches `packages/pnpm-lock.yaml` from the deployment when the UI starts, whenever the tab becomes visible again, and every 15 minutes. If it differs from `var/lib/slicc/pnpm-lock.yaml` in OPFS, there is an update.
381
+ 2. **Install.** It writes the deployment's `package.json` and `pnpm-lock.yaml` to the OPFS root and runs `pnpm install --frozen-lockfile --trust-lockfile` there in the page's kernel. [`@ai-ecoverse/wasi-pnpm`](https://www.npmjs.com/package/@ai-ecoverse/wasi-pnpm) is one of the bootstrap packages, so pnpm is always installed. pnpm's hoisted, copying layout keeps `node_modules/` exactly where the BIOS puts it, and it only rewrites packages whose locked version changed. Its store lives in OPFS under `/home/.local/share/pnpm/`. Only when pnpm succeeds does the updater write the lockfile to `var/lib/slicc/pnpm-lock.yaml`, so an update that fails or is cut short by closing the tab is tried again by the next check.
382
+ 3. **Switch over.** Packages are updated in place. A program reads its wasm and glue when it starts, so running processes, the interactive `bash` included, keep the code they started with, and every process started afterwards gets the new version. The page itself keeps the kernel and terminals it loaded. The changed rows of [Install / Update](#install--update) say *Ready to apply*, list what changed and offer **Reload**, which navigates to the BIOS with a view transition. The BIOS then picks up a new `sw.js` and seed, finds every package current (its receipt, or pnpm's `.modules.yaml` and the installed version, match), and goes back to `os/`. Files in OPFS, `/home` included, are not touched.
383
+
384
+ Both the BIOS installer and the updater take the Web Lock `slicc-packages`, so tabs never install at the same time.
385
+
386
+ Decisions, from [#11](https://github.com/ai-ecoverse/slicc-bios/issues/11):
387
+
388
+ - **The newest lockfile comes from the BIOS deployment, not from npm.** Every deploy already publishes `src/packages/` next to the BIOS, `sw.js` and the seed, and CI tested that exact combination. A branch host updates from its own branch, the host's `cache-control: no-cache` and ETag keep polling cheap, and nothing extra has to be published. Taking the lockfile from npm would need a release package that sits outside this repo's CI, and the deployed BIOS and seed could drift away from the packages it pins.
389
+ - **Two lockfiles from one `package.json`.** `package-lock.json` stays for the bootstrap: it carries each tarball URL and its hoisted path, so the installer needs neither a YAML parser nor a hoisting algorithm. `pnpm-lock.yaml` drives every update after that, and a test keeps both pinned to the same versions and integrity. After pnpm has run, the installer fetches the changed packages' tarballs once more, checks them against `package-lock.json` and records their hashes, so a reload doesn't download them again and the service worker can check what pnpm wrote.
390
+ - **In place, not versioned directories.** OPFS has no hard links, so a versioned directory would copy every file of every package for every update, which is the slow part (the kernel's per-file OPFS writes). In place, pnpm copies only the packages that changed, and the kernel re-reads its command catalog on every run.
391
+
392
+ Limits:
393
+
394
+ - The first update after a bootstrap has no pnpm state yet, so pnpm downloads every package once into its store and copies all of them. Later updates touch only what changed.
395
+ - Between an in-place update of `slicc-kernel` and the reload, the old kernel worker starts new processes with the new `process-worker.js`. The two are built together, so this works as long as their protocol doesn't change; a kernel that loads its process worker once would remove the gap.
396
+ - Packages added to `/package.json` by hand are removed by the next update. Projects of your own belong in `/home`.
397
+ - Emscripten programs see only the directories at `/`, so `ls /` and `cat /pnpm-lock.yaml` don't find the two files; WASI programs such as `pnpm` (`cd / && pnpm install`) do.
398
+ - An update of the seed (`os/`) or `sw.js` alone has no lockfile change, so it arrives with the next boot through the BIOS.
399
+
400
+ ## Certified packages
401
+
402
+ [`src/packages/certs.json`](src/packages/certs.json) lists every certified version of the gated packages: slicc-kernel, slicc-cdp, and the `wasi-*`, `wasix-*`, `wasm-*` and `py-*` packages. Each entry has its npm integrity, and its tarball sha256 where the cert gave one. `test/integration/pins.test.mjs` fails when any lockfile seven ships (the base set, `/opt/agent`, the optional catalog with its nested dependencies, and Tailscale) pins a gated package that isn't listed there with the same integrity. It also fails when a pinned package's `engines.slicc-kernel` or kernel peer range isn't met by the pinned kernel. An uncertified bump, from Renovate or anyone, turns CI red. When homescoop (or the CDP coordinator, for slicc-cdp) certifies a version, add it here in the same PR that pins it.
403
+
404
+ ### Canary
405
+
406
+ [`.github/workflows/canary.yml`](.github/workflows/canary.yml) answers "is bios main green with these candidate versions?" before anyone asks for a pin. It never pushes, pins or opens a PR: it reports.
407
+
408
+ - **Triggers:** `workflow_dispatch` and `workflow_call` with `overrides`, a JSON list of `name@version` (`["@ai-ecoverse/slicc-kernel@1.81.0"]`), and optionally `runner`, `shards` (names from [`scripts/canary/shards.json`](scripts/canary/shards.json); ci.yml's `follow-chat`, `remote-pydata`, `users-agent-ui`, `packages-update-cdp` and `rest` by default, or `sweep` for just the base-set sweep and the certs check), `expect` and `bios-ref` (any branch or sha of slicc-bios: the shard jobs check it out and lay this ref's `scripts/canary`, and `sweep.test.mjs` if it has none, over it; a ref whose `pins.test.mjs` has no canary mode gets the candidates in its working copy of `certs.json` instead, and the report still lists them as candidates). A proof in `proofs.json` can name its own `ref` the same way. An hourly `schedule` checks npm's `latest` of every kernel, jsh, cdp, agent, `wasm-*`, `wasi-*`, `wasix-*` and `py-*` package that a `src/packages/**/package.json` pins, and runs once with every newer one as the overrides, only when that list differs from the last scheduled canary's (its inputs are kept in the Actions cache). A `pull_request` trigger, filtered to the canary's own files, runs the proof in [`scripts/canary/proofs.json`](scripts/canary/proofs.json) from one plan job, cancelling a superseded run: kernel 1.78.0 on the `sweep` shard must regress grep, sed, jq, xz, findutils, diffutils and patch with exit 70 (kernel #483).
409
+ - **Jobs:** one plan job, one job per shard of each run, and one report job, because the queue for a runner costs more than the work.
410
+ - **Runner:** fork code never runs on the org's Cloud Run pools. A same-repo `pull_request` (the proofs from our own branches), or a `schedule`, `workflow_dispatch`, `workflow_run` or `release` run on `main` (any `bios-ref`), puts the shard jobs on the `runner` input, else the `CERT_RUNNER` repository variable (`cloud-run-bench`), and the light plan and report jobs on `CERT_LIGHT_RUNNER` (`cloud-run`). Everything else runs on `ubuntu-latest`, and the `runner` input is ignored: fork pull requests, and dispatches from any other branch. Under `workflow_call` the same check reads the caller's event. The pools run with "no new privileges", so `sudo` can't become root there: the shard job installs Chromium's system libraries only when `sudo -n true` works, and otherwise relies on the image's.
411
+ - **Pins:** [`scripts/canary/override-pins.mjs`](scripts/canary/override-pins.mjs) `name@version …` fetches each tarball through the harness download cache (so the tests find it there), checks it against npm's sha512 and records its sha256. It sets the version in every `package.json` under `src/packages` that pins the package and lets npm (`--package-lock-only`) and pnpm 12 (`--lockfile-only`) rewrite that directory's lockfiles, so a dependency that moves with the package (an agent release taking a newer slicc-browsers) moves in the lock too. A package that is only a dependency in a pnpm lock is forced with an `overrides` entry in that directory's `pnpm-workspace.yaml`. One that is only a dependency in an npm lock (wasm-git's own wasm-bash in the optional catalog) is left alone and reported as held. It writes `node_modules/.cache/slicc-canary/pins.json`, whose `candidates` are the gated packages the locks now pin that `certs.json` doesn't certify.
412
+ - **Certs check:** with `SLICC_CANARY` set to that file, `pins.test.mjs` reports those candidates as "candidate, not certified" instead of failing on them; anything else uncertified still fails. Without it (normal CI) the check is unchanged.
413
+ - **Tests:** the same integration shards as `ci.yml`, run by [`scripts/canary/shard.mjs`](scripts/canary/shard.mjs) with the overrides. The test files with a failure run again on main's pins, and the ones that passed there run once more with the overrides, so each failure is a regression (passes on main's pins, fails with the overrides), a failure on both, or flaky. Tests that assert a pinned version by value (`jsh.test.mjs` for slicc-jsh, `update.test.mjs` and `reuse.test.mjs` for wasm-bash) regress whenever that package is overridden.
414
+ - **Output:** a `canary` artifact with `canary.json` (`<proof>/canary.json` for the proofs), `{ biosRef, overrides: [{ name, version, sha256 }], candidates, held, shards: [{ shard, wallMs, phases }], results: [{ test, status, baseline, retry, verdict, log }] }`, and a job summary with the overrides, the wall time of each shard and the failures split into regressions, failures on both, and flaky ones. The report job fails on a failure without overrides, on a missing shard, on a shard that exited nonzero with no failing test (a crash or a throwing teardown), when an `expect` entry (a `{ test, log }` pair of substrings) matches no regression, or on a regression no `expect` entry matches, unless the run sets `strict: false`. `test/integration/canary.test.mjs` checks these rules.
415
+
416
+ `test/integration/sweep.test.mjs`, the base-set sweep, runs in both workflows. It boots the BIOS, checks that each base-set package in OPFS has the version `package-lock.json` pins, and runs every command its `slicc.commands` lists with `--version` or a trivial call under `timeout 60`, then real grep, egrep, sed -i, xz, jq, find, xargs, diff, cmp, patch, awk, gzip, bzip2 and tar work. Each command is its own subtest named `<package> · <call>`. The clipboard commands run `--help` (`xclip -help`). Known exits: `false` 1, `bunzip2`/`bzcat --version` 2 (upstream), `i --version` 2 (argument parsing; 126 is #495), and `chcon`/`runcon` 1 (listed by coreutils, not built: "unknown program"). `[` runs as bash's builtin, since `timeout` can't exec it.
417
+
418
+ ### The npm package
419
+
420
+ `@ai-ecoverse/slicc-bios` on npm carries only the manifests a consumer needs to test against what seven ships: `src/packages/package.json`, `package-lock.json`, `pnpm-lock.yaml` and `certs.json`, `src/packages/agent/package.json` and `pnpm-lock.yaml`, and `src/packages/optional/package.json`, `package-lock.json` and `catalog.json`. It has no runtime code and no dependencies, and those paths stay where they are. [`.github/workflows/release.yml`](.github/workflows/release.yml) releases it from `main` only: semantic-release reads the conventional commits, tags `v<version>` and writes the GitHub release, then `npm publish --provenance` publishes the tag at HEAD through npm's trusted publishing on a GitHub-hosted runner, retrying, and skipping a version npm already has. So re-running the workflow on main publishes a tag whose publish failed. The deploy to seven (`deploy.yml`) is separate and doesn't wait for it.
421
+
422
+ ## Develop
423
+
424
+ ```sh
425
+ npm install
426
+ npm start
427
+ npm test
428
+ npm run lint
429
+ ```
430
+
431
+ `npm start` serves `src/` on <http://127.0.0.1:8080/>. There is no build step. To change what the BIOS installs, edit `src/packages/package.json` and refresh both lockfiles in that directory with `npm install --package-lock-only` and `npx pnpm@12 install --lockfile-only`; npm and pnpm resolve the dependency tree, the BIOS and the updater only replay it. A test fails when the two lockfiles pin different packages. `npm install` also installs the [lefthook](https://lefthook.dev) pre-commit hook, which runs Biome, the no-comment check, the no-unit-tests-in-git guard, and the diff-coverage check described below.
432
+
433
+ `npm test` runs the integration tests with `node --test` against Chromium (fetched by `playwright-core`), driven directly over the DevTools protocol by the [harness from slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web#integration-test-harness); `test/integration/chrome.mjs` only says what to serve and which CDN requests to intercept. Every run leaves behind:
434
+
435
+ - `coverage/` – V8 coverage of the page, the shared worker, and the service worker as `lcov.info` plus an HTML report.
436
+ - `artifacts/<suite>/<test>/` – a screenshot of each tab at the end of the test, a `console.log` of every page and worker, and the minidump of any renderer or browser crash.
437
+ - `artifacts/hotspots.md` – added to the CI job summary. It says that profiling is off unless you turned it on.
438
+
439
+ CPU profiling is off by default, in CI too: V8's sampler interrupts every page and worker thread every 100 µs with a signal, and that is where the renderer crashes in [#114](https://github.com/ai-ecoverse/slicc-bios/issues/114) faulted. Run `SLICC_PROFILE=1 npm test` to get a `.cpuprofile` per page and worker for every document and test, the kernel's workers included (open them in the DevTools Performance panel or [speedscope](https://www.speedscope.app)). `hotspots.md` then lists the functions in `src/` with the most self time across all profiles. The profiles are recorded with coverage switched on, so absolute timings run high. Use them to compare functions with each other rather than as real-world numbers.
440
+
441
+ No test calls a real model. Every test serves an empty `packages/agent/` project (`emptyAgent` in `test/integration/chrome.mjs`), so only the chat test installs the agent in the background. In it, `test/integration/fake-proxy.mjs` plays a local proxy and answers Bedrock's `converse-stream` with a scripted event stream, so the turn runs through the real agent worker, pi-ai's Bedrock client and the kernel's transport. The test also signs in to Adobe against a fake IMS (`--host-resolver-rules` maps `ims-na1.adobelogin.com` to a local HTTPS server) and the fake proxy's `oauth-state`/`oauth-result` endpoints, cancelling once first.
442
+
443
+ ## Hosting
444
+
445
+ `main` is live at <https://seven.sliccy.ai/>, and every other branch at `https://<branch>.sliccy.ai/`, where the branch name is lowercased and runs of anything but letters and digits become `-` (`feat/edge-hosting` → <https://feat-edge-hosting.sliccy.ai/>). Each host is its own origin, so a branch gets its own OPFS and service worker and can't disturb `seven`.
446
+
447
+ The hosts are served by the [sliccy-ai](https://github.com/ai-ecoverse/sliccy-ai) worker from the R2 bucket `slicc-bios`. This repo only publishes into that bucket:
448
+
449
+ - [`edge/publish.mjs`](edge/publish.mjs) `<branch>` uploads `src/` to `seven/` for `main` and to `branches/<label>/` for every other branch, and deletes what the previous upload had and this one doesn't. A manifest next to it (`seven.json`, `branches/<label>.json`) records which branch owns the label, so two branches that map to the same label can't overwrite each other.
450
+ - The bucket expires everything under `branches/` 30 days after upload. Every push uploads all files again, so a branch stays up while it's active and disappears a month after its last push. Nothing needs to be cleaned up when a branch is deleted.
451
+ - [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) publishes on every push.
452
+ - **`main`** publishes `seven/` with `CLOUDFLARE_API_TOKEN`, an account token with Workers R2 Storage Write. That token could overwrite `seven/`, so it lives only in the `production` environment, whose deployment branch policy allows `main` alone. A branch that changes the workflow can't read it.
453
+ - **Every other branch** holds no secret. It runs in the `preview` environment, with no approval step and `id-token: write`. `edge/publish.mjs` asks GitHub for the job's OIDC token (audience `https://sliccy.ai`) and publishes through the sliccy-ai worker's `/api/publish/*` route. The route checks the token's signature, issuer, audience, expiry, repository and branch, then derives `branches/<label>/` from the branch itself. A branch can only ever write its own prefix, and never `seven/`. Files go up eight at a time, then deletions, then the manifest.
454
+
455
+ ## Rules
456
+
457
+ The Biome, lefthook, Renovate and CI configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web), which also provides the `slicc-*` commands below.
458
+
459
+ - **No comments.** `slicc-lint-comments` runs the [slicc no-comment checker](https://github.com/ai-ecoverse/slicc/tree/main/packages/dev-tools/no-comment) over the tree.
460
+ - **Unit tests stay out of git.** Write them in `test/unit/`, which is gitignored, and run them with `npm run test:unit`. `slicc-no-unit-tests` fails if a unit test gets committed anyway.
461
+ - **Changed code must be covered.** When a commit touches JavaScript in `src/`, lefthook runs the local unit tests with coverage and [`diff-cover`](https://github.com/Bachmann1234/diff_cover) requires every staged line to be executed. Files that no unit test loads count as 0%. This needs [uv](https://docs.astral.sh/uv/) for `uvx`.
462
+ - **Integration tests run in CI.** `test/integration/` is committed and runs on every push and pull request; run it locally with `npm test`. Their coverage report, screenshots and crash dumps are uploaded as CI artifacts for inspection, not used as a gate.