@ai-ecoverse/slicc-spectrum 1.51.0 → 1.53.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
@@ -119,7 +119,7 @@ Things found while building this against wterm 0.5.4, recorded here, not pushed
119
119
 
120
120
  ### Layouts and rails
121
121
 
122
- The layout follows the width of `<slicc-app>`, which it reflects as its `screen` attribute: `phone` (under 640 px) has one column, `tablet` (under 1200 px) two, `desktop` three, like SLICC v6. Each screen class starts with few panels open and saves its own layout (`slicc-ui.layout.v2.<screen>`). Every surface has a home side, left, center or right. Closed panels wait as icons in the left and right rails (on a phone, in one bar at the bottom); one click puts a panel back on its side, next to its neighbours. Each thread, a cone's or a scoop's conversation, opens in its own chat panel (`chat:<agent id>`), as a tab in the same group as the other chats. Selecting a thread opens or reveals its tab, activating a chat tab selects its thread, and a thread's chat closes when the thread goes away (frozen or deleted).
122
+ The layout follows the width of `<slicc-app>`, which it reflects as its `screen` attribute: `phone` (under 640 px) has one column, `tablet` (under 1200 px) two, `desktop` three, like SLICC v6. Each screen class starts with few panels open and saves its own layout (`slicc-ui.layout.v2.<screen>`). Every surface has a home side, left, center or right. Every panel has an icon in the left or right rail (on a phone, in one bar at the bottom, with the rest in its More menu). The icon stays when its panel is open; while the panel is visible it shows selected (`.selected`, `data-visible`, labelled "{Title}, shown"). One click puts a closed panel back on its side, next to its neighbours, or brings an open one to the front. Each thread, a cone's or a scoop's conversation, opens in its own chat panel (`chat:<agent id>`), as a tab in the same group as the other chats. Selecting a thread opens or reveals its tab, activating a chat tab selects its thread, and a thread's chat closes when the thread goes away (frozen or deleted).
123
123
 
124
124
  `surfaces` on `<slicc-app>` (default: every surface, exported as `surfaces`) is what the app offers: the panels it can open, the View menu, the rails and `Alt+1…9`. A saved layout with a panel it doesn't offer is dropped for the default. An embedder can pass a subset with its own `open` per screen class. The agent picker and the tray show only when `chat` is offered, the update status only with `updates`, the network indicator only with `network` and a `network` port, and the settings button only with `settings`. Elements with `slot="status"` show in the header as compact single-line items, before the update status, and are cut with an ellipsis when long; a `hidden` one takes no space. There are no strips under the header. On a phone the status items are hidden.
125
125
 
@@ -131,13 +131,14 @@ Text is Adobe Clean Spectrum VF, falling back to Adobe Clean, and code is Spectr
131
131
 
132
132
  | Element | |
133
133
  | :--- | :--- |
134
- | `<slicc-app>` | The shell: header with the agent picker, status items, the update status, the network indicator, the tray, the View menu and the theme switch, rails, and a `<slicc-dock>`. Saves the dock layout per screen class to `storage` (default `localStorage`) under `layoutKey`. While connected it sets `overscroll-behavior` to `none !important` on the page's `html` and `body`, so only scroll areas inside panels rubber-band. When the last app on the page is removed, the page's own inline values come back. |
134
+ | `<slicc-app>` | The shell: header with the agent picker, status items, the update status, the network indicator, the tray, the View menu and the theme switch, rails, and a `<slicc-dock>`. Saves the dock layout per screen class to `storage` (default `localStorage`) under `layoutKey`. While connected it sets `overscroll-behavior` to `none !important` on the page's `html` and `body`, so only scroll areas inside panels rubber-band. When the last app on the page is removed, the page's own inline values come back. With a `follow` port, the header shows a `<slicc-follow>` chip, and while the follower connects, waits to be allowed, is unlinked or fails, a `<slicc-follow page>` (`.alone[data-follow]` with the page kind) takes the place of the panels; without Terminal access, the terminal surface and New terminal aren't offered, and open terminals close when it's taken away. |
135
135
  | `<slicc-dock>` | Generic. [dockview-core](https://dockview.dev/) 8 with a theme mapped to Spectrum tokens, in its own shadow root. Panels come from `factories` (`name → (params) => HTMLElement`). `open`, `close`, `has`, `content`, `focusPanel`, `cycleGroup`, `cycleTab`, `toJSON`, `restore`, `clear`, and the full `api`. Tabs have a context menu (close, close others, float, maximize). Events: `layout-change`, `active-panel-change`, `panel-close`. |
136
136
  | `<slicc-agents>` | Cones with their scoops, status, context fill and unread count. A cone's row has a second line with its conversation title, as does its item in the header's cone picker: `Agent.title`, or else the first line of the first user message, or "No messages yet". When cones share a name, their accessible names and the Delete cone, Freezer and confirmation labels add the title, as in "Delete cone sliccy (Hello from Bedrock)". Selecting one makes it the active agent. |
137
- | `<slicc-chat>` | One thread's conversation (its `agent`, or the active agent if unset), by day: user messages (with attachments, tagged as steered or follow-up by `delivered`, or delegated from another cone), assistant messages (Markdown with tables, task lists, quotes, code and images; thinking; tool calls, grouped into a collapsible cluster from three in a row, each with its input (a shell command after `$`), an optional `meta` such as a timeout, an edit's `diff` shown as a diff view, and an `image`, such as a screenshot, shown as a thumbnail under the closed card and in full when open; media galleries with images, video and audio; action cards for tools, pull requests and status; plans and checklists; inline diffs; question prompts; delegations; link previews; errors with a fix-it action; a content-filter stop offers **Drop the last turn**, which rewinds that turn and puts its prompt back into the composer; when the turns right before it failed too, the button reads **Drop N failed turns** and rewinds to before the first of them, putting the latest prompt back), standalone tool results, system messages (compaction markers, error cards, notices) and licks, SLICC's events into a cone, on every channel; a lick's `severity` (`warn` or `error`) turns its channel badge into a notice or negative one with an alert icon. Permission requests are allowed or denied in place. The header shows the status, a model picker that names each model's provider and leaves out classifiers and models with `tools: false`, the thinking picker, the context meter and **New conversation**, which calls `clear` and puts focus back in the composer; on a cone, when the port can freeze, its tooltip says the old conversation stays in the Freezer. Between the header and the transcript, a `<slicc-notices>` shows the model's notices. |
137
+ | `<slicc-chat>` | One thread's conversation (its `agent`, or the active agent if unset), by day: user messages (with attachments, tagged as steered or follow-up by `delivered`, or delegated from another cone), assistant messages (Markdown with tables, task lists, quotes, code and images; thinking; tool calls, grouped into a collapsible cluster from three in a row, each with its input (a shell command after `$`), an optional `meta` such as a timeout, an edit's `diff` shown as a diff view, and an `image`, such as a screenshot, shown as a thumbnail under the closed card and in full when open; media galleries with images, video and audio; action cards for tools, pull requests and status; plans and checklists; inline diffs; question prompts; delegations; link previews; errors with a fix-it action; a content-filter stop offers **Drop the last turn**, which rewinds that turn and puts its prompt back into the composer; when the turns right before it failed too, the button reads **Drop N failed turns** and rewinds to before the first of them, putting the latest prompt back), standalone tool results, system messages (compaction markers, error cards, notices) and licks, SLICC's events into a cone, on every channel; a lick's `severity` (`warn` or `error`) turns its channel badge into a notice or negative one with an alert icon. Permission requests are allowed or denied in place. The header shows the status, a model picker that names each model's provider and leaves out classifiers and models with `tools: false`, the thinking picker, the context meter and **New conversation**, which calls `clear` and puts focus back in the composer; on a cone, when the port can freeze, its tooltip says the old conversation stays in the Freezer. Between the header and the transcript, a `<slicc-notices>` shows the model's notices. On a follower without Control, a note (`p[data-follow="watch"]`) takes the composer's place, the header shows the model's name instead of the pickers and New conversation, and there's no suggested follow-up. |
138
138
  | `<slicc-notices>` | The model's `notices` as in-line alerts with their actions and a close button, after a banner from the `tabs` port when another tab is paused or runs a different SLICC, and a warning for each waiting approval when the `approvals` port has no `decide` (`[data-approval-notice]`); renders nothing without any. |
139
139
  | `<slicc-approvals>` | The model's waiting `approvals` as a modal alert dialog, one at a time, oldest first; `<slicc-app>` mounts it. Renders nothing without `approvals.decide`. |
140
- | `<slicc-composer>` | The message box. <kbd>Enter</kbd> sends, or steers a running reply: the message joins after the current tool round, without interrupting; <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>Enter</kbd> queues a follow-up that runs after the reply and can be withdrawn; <kbd>Shift</kbd>+<kbd>Enter</kbd> adds a line; <kbd>Tab</kbd> takes the suggested follow-up; <kbd>↑</kbd>/<kbd>↓</kbd> at the start or end walk this agent's history; <kbd>Esc</kbd> stops. `/` completes commands (`/clear`, `/compact`, `/model`, `/thinking`, `/scoop`, `/stop`, `/freeze` when the port can freeze, `/theme`) and their arguments, `@` mentions agents and files. Attach from the computer, from SLICC's files, a browser screenshot or a shared secret, or paste and drop files (25 MB each). Queued messages wait above it, to send now or drop. Model and thinking pickers, and dictation where the browser has speech recognition. Drafts are kept per agent. |
140
+ | `<slicc-follow>` | The model's `follow` state. By default a header chip (`#chip[data-follow]` with the link state: **Connecting to**, **Following**, **Reconnecting to**, **Unlinked from** or **Can’t reach** the owner; `compact` keeps only a follow icon and the light) whose popover says what this device can do (`[data-can]`), that the conversation may be out of date while reconnecting, and with Control that sending waits (`[data-reconnecting]`), and has **Leave** (`data-action="leave"`, behind a confirm unless unlinked). With `page`, the full page for `connecting`, `waiting` (connected without View: "Waiting for {owner} to allow this device"), `unlinked` and `failed` (`[data-follow-page]`), with **Try again** (`data-action="retry"`) when failed and the port has `retry`, and **Leave**. A rejected call shows its message (`[data-error]`). Renders nothing without the port. |
141
+ | `<slicc-composer>` | The message box. <kbd>Enter</kbd> sends, or steers a running reply: the message joins after the current tool round, without interrupting; <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>Enter</kbd> queues a follow-up that runs after the reply and can be withdrawn; <kbd>Shift</kbd>+<kbd>Enter</kbd> adds a line; <kbd>Tab</kbd> takes the suggested follow-up; <kbd>↑</kbd>/<kbd>↓</kbd> at the start or end walk this agent's history; <kbd>Esc</kbd> stops. `/` completes commands (`/clear`, `/compact`, `/model`, `/thinking`, `/scoop`, `/stop`, `/freeze` when the port can freeze, `/theme`) and their arguments, `@` mentions agents and files. Attach from the computer, from SLICC's files, a browser screenshot or a shared secret, or paste and drop files (25 MB each). Queued messages wait above it, to send now or drop. Model and thinking pickers, and dictation where the browser has speech recognition. Drafts are kept per agent. On a follower while the link reconnects, the field is disabled and says so; the draft stays. |
141
142
  | `<slicc-files>` | The file system as a `<slicc-file-tree>`, with pending changes as git status. Opening a file opens it in a tab. With `files.mountFolder`, **Mount a folder…** (`data-action="mount-folder"`) mounts a local folder, then selects and expands it in the tree; with `files.mounts`, a **Mounted** strip lists the mount points, each with **Eject** (`data-action="eject"`, `data-path` the mount point) when the port has `eject`. With `files.needsFolder`, a **Needs a folder** section lists the mount points waiting for a folder, each with **Insert folder…** (`data-action="insert-folder"`, `data-id` the mount point), which calls `mountFolder(path)`. A failed mount, insert or eject shows its message under the bar. |
142
143
  | `<slicc-file-view>` | One file (`path`), highlighted, with its pending change and a link to the diff. |
143
144
  | `<slicc-changes>` | Pending changes with their author. Opens the diff, accepts or reverts one change or all of them. Changes with a `repo` show their folder relative to it, and no author when `agentId` is `null`; they group under a header per repository (its path and count), even when there is only one, in the order the port returns them. In a diff panel narrower than 480 px, Accept and Revert (`data-action="accept"`, `data-action="revert"`) show only their icons. Revert and Revert all ask with a destructive confirm that says the uncommitted (or, without a repo, pending) changes can't be undone; a failed revert shows its message under the bar. When the port's `unavailable()` returns a string, the panel shows it instead of the list, with each `backtick` span as inline code in the text's color and, below, each command once with a **Copy** button (`data-command`, `data-action="copy-command"`). |
@@ -150,8 +151,8 @@ Text is Adobe Clean Spectrum VF, falling back to Adobe Clean, and code is Spectr
150
151
  | `<slicc-monitor>` | The live monitor: vitals with sparklines (active agents, spend, budget, fullest context), alerts, and the topology (cones and scoops, terminals, tabs, pending changes, sprinkles, tray followers). |
151
152
  | `<slicc-freezer>` | Every conversation in the session, from `frozen()`: cones, scoops and agent runs, each with a kind badge, live ones first with a Live light, then the newest first. A live row offers **Open**, which selects that agent; a stopped one offers **Thaw** and **Remove**, which deletes its working folders after a confirmation. A thawed scoop or agent run keeps its row, which says what it was thawed as. **Delete cone**, in the agents list (or <kbd>Delete</kbd> on a cone's row) and the header's cone picker, stops a cone after a confirmation; `/freeze` does it at once. Either way its conversation stays here, and a row without a `kind` counts as a cone. |
152
153
  | `<slicc-sprinkle>` | A SLICC sprinkle (`.shtml`), as a panel or, with `inline`, as a dip in the chat that grows to fit. A dip has a tab-like handle: drag it into the dock (or onto a rail, or use its open-as-panel button) and it lives on as a sprinkle panel, with a note in the chat where it was; which dips are out is saved in `slicc-ui.dips`. It runs in a sandboxed frame (`allow-scripts` only) with SLICC's sprinkle theme (the `--s2-*` tokens and `.sprinkle-*` classes), the app's fonts, and Lucide icons (`<i data-lucide>`, `LucideIcons.render()`). It talks back through `slicc.lick({ action, data })`, which reaches the owning cone as a `sprinkle` lick, and, when the port has `call`, reads files and keeps state (see [Sprinkle bridge](#sprinkle-bridge)). Panel sprinkles sit at the top of the right rail with their Lucide icon. |
153
- | `<slicc-network>` | Network: the route and its health in plain words with the bios `detail`, a line on browser automation (`data-browser`: through the SLICC extension, through slicc-node, through a linked device's browser, that slicc-node has no browser attached when `route` is `proxy` and `via` is `null`, or not available with how to get it) when the status has `browser`, **Check again** (`data-action="check"`) when the port has `check`, an **Internet traffic** picker (`data-action="exit"`, in `[data-exit-picker]` with the exit's kind) when the port has `setExit`, the route line saying where traffic goes, and under the picker a warning that traffic waits while the chosen device isn't connected (`data-exit="device"`, `data-held`, with **Send directly**, `data-action="exit-direct"`, which calls `setExit(null)`) or, when `route` isn't `tailnet`, the Tailscale exit node in use (`data-exit="node"`), a **Tailscale** section (`data-tailnet` with the state) when the status has `tailnet`, a **Linked devices** section (`data-links`: `linked`, `connecting`, `reconnecting`, `offline`, `none` or `permission`; each device row has a **Prompting** or **Watching** badge, `data-controlling`, while it controls the agent, and **Allow remote control**, `sp-switch[data-action="remote-control"]`, behind a confirm when turned on, or `data-control="fixed"` for a device on this computer) when the status has `links`, **Get the whole web** with the Chrome extension link (`data-action="install-extension"`, when `extensionUrl` is set) and `npx @ai-ecoverse/slicc-node` with a copy button (`data-action="copy-command"`), folded away when the route already reaches the whole web (`proxy`, `extension` or `tailnet`, whose line names `tailnet.exitNode`) or a linked device is the exit (`exit.kind` `link`: the route line names the device and its `policy`, or says it's waiting for it, and `data-route` is `link`, whatever `route` says), and recent failures with host, error and time. The header's **Network** indicator (`data-network`, `data-health`) opens it: a status light, green for `ok`, yellow for `limited` and red for `failing`, with the state in its text and label and `detail` as its tooltip, which adds "Tailscale is connected." while the tailnet runs; on a phone, a cloud icon for the state. While a linked device controls the agent, an indicator (`data-remote`, `prompt` or `watch`) sits before it, **Controlled by** or **Watched by** the device's name (or the number of devices; prompting wins), says who does what in its tooltip and opens the panel too. Offered only when the model has a `network` port. |
154
- | `<slicc-tray>` | The tray indicator in the header: connection, the float's name, followers and budget; its panel adds role, runtime, spend, the follower list, the join link, and disconnect or reconnect. |
154
+ | `<slicc-network>` | Network: the route and its health in plain words with the bios `detail`, a line on browser automation (`data-browser`: through the SLICC extension, through slicc-node, through a linked device's browser, that slicc-node has no browser attached when `route` is `proxy` and `via` is `null`, or not available with how to get it) when the status has `browser`, **Check again** (`data-action="check"`) when the port has `check`, an **Internet traffic** picker (`data-action="exit"`, in `[data-exit-picker]` with the exit's kind) when the port has `setExit`, the route line saying where traffic goes, and under the picker a warning that traffic waits while the chosen device isn't connected (`data-exit="device"`, `data-held`, with **Send directly**, `data-action="exit-direct"`, which calls `setExit(null)`) or, when `route` isn't `tailnet`, the Tailscale exit node in use (`data-exit="node"`), a **Tailscale** section (`data-tailnet` with the state) when the status has `tailnet`, a **Linked devices** section (`data-links`: `linked`, `connecting`, `reconnecting`, `offline`, `none` or `permission`; each device row has a **Prompting** or **Watching** badge, `data-controlling`, while it controls the agent, and **Allow remote control**, `sp-switch[data-action="remote-control"]`, behind a confirm when turned on, or `data-control="fixed"` for a device on this computer; with `access`, an **Allow** group of **View**, **Control** and **Terminal** switches, `data-action="access-view"`, `access-control` and `access-terminal`, each confirmed when turned on, Terminal's saying it gives a root shell, and Control disabled while View is off; a device that asks to follow shows **Asks to follow** and **Allow**, `data-action="allow-follow"`; a **Terminal open** badge, `data-terminals`, counts its terminals; with `followUrl`, the join details add **Open on another device** with a QR code and the link, `data-join="follow"` and `data-copy="follow-url"`) when the status has `links`, **Get the whole web** with the Chrome extension link (`data-action="install-extension"`, when `extensionUrl` is set) and `npx @ai-ecoverse/slicc-node` with a copy button (`data-action="copy-command"`), folded away when the route already reaches the whole web (`proxy`, `extension` or `tailnet`, whose line names `tailnet.exitNode`) or a linked device is the exit (`exit.kind` `link`: the route line names the device and its `policy`, or says it's waiting for it, and `data-route` is `link`, whatever `route` says), and recent failures with host, error and time. When the model has a `tray` port, a **Sync** section (`data-sync` with the connection) follows the connection: the tray's state, the float's name, role and runtime, the join link with **Copy** (`data-action="copy-tray-join"`), the followers (`li[data-follower]`), and **Disconnect** (`data-action="tray-disconnect"`, confirmed) or **Reconnect** (`data-action="tray-reconnect"`). The header's **Network** indicator (`data-network`, `data-health`) opens it: a status light, green for `ok`, yellow for `limited` and red for `failing`, with the state in its text and label and `detail` as its tooltip, which adds "Tailscale is connected." while the tailnet runs; on a phone, a cloud icon for the state. While a linked device asks to follow, controls the agent, watches it or has a terminal open, an indicator (`data-remote`, `ask`, `prompt`, `terminal` or `watch`) sits before it, **{name} asks to follow**, **Controlled by**, **Terminal open from** or **Watched by** the device's name (or the number of devices; in that order of precedence), says who does what in its tooltip and opens the panel too. Offered only when the model has a `network` port. |
155
+ | `<slicc-tray>` | The tray items in the header, each opening a panel when that panel is in `panels`: the connection (`#chip`, a status light with the float's name and followers, `data-surface="network"`) opens Network at its **Sync** section, and the budget (`#usage`, `data-surface="monitor"`) opens the Monitor. Without the panel an item is a plain status with its label. |
155
156
  | `<slicc-confirm>` | The confirmation dialog `confirm()` opens. Not for direct use. |
156
157
  | `<slicc-prompt>` | The name dialog `prompt()` opens. Not for direct use. |
157
158
  | `<slicc-file-tree>` | Generic. [`@pierre/trees`](https://www.npmjs.com/package/@pierre/trees) through its `web-components` entry: `paths` (folders end in `/`), `gitStatus`, `expanded`, `reveal(path, focus)`. Fires `file-open` on click and on <kbd>Enter</kbd>. |
@@ -208,16 +209,19 @@ Paths are the agent's: `/shared/…` and `/home/…`, nothing else. A refusal or
208
209
  | `sprinkles` | `@ai-ecoverse/slicc-agent` | Sprinkles (`inline` ones show as dips in the chat, not in the rail) and `send`, which turns a sprinkle's lick into a lick on its cone. The optional `call(id, method, args)` serves the [sprinkle bridge](#sprinkle-bridge) for sprinkle `id`: `readFile` and `exists` (`[path]`) resolve with a string and a boolean, `getState` (`[]`) with the saved value or `null`, and `setState` (`[value]`) once it is saved. The adapter owns path policy and keeps state per sprinkle; `<slicc-sprinkle>` awaits `getState` before it builds a frame, mirrors what the frame saves, and forwards only these four methods from its own frame. The dummy keeps state in memory and maps `/shared/x` to `/home/x` in its files, refusing anything outside `/home`. |
209
210
  | `tray` | the tray protocol | Connection, role, float kind, followers, spend and budget; `reconnect` and `disconnect`. |
210
211
  | `updates` (optional) | BIOS installers | `list()` returns `UpdateItem` rows for BIOS, kernel, agent, grammars, global pnpm packages, skills and UI. `ready()` explicitly reports agent readiness, independently of agent activity. Emit `items` whenever rows or readiness change. Use `queued` for a row that waits for another component before it starts; it reads "Waiting" and doesn't count as updating. Use `starting` for an installed row that is only starting up: it reads "Starting" without a checked line, and the header shows a neutral "Starting" only when nothing is failed, updating or ready. A `failed` row with an installed `from` version reads "Update failed". `act(id, action)` resolves when dispatched or rejects with a displayed error; actions are `retry`, `update-now`, `restart-agent` and `reload`. Missing ports preserve existing adapters and report disconnected information in the panel. Optional `packages()` returns `PackageItem` rows for optional tools, each with a short catalog `id`, its npm `package` (shown in the name's tooltip and at the top of its Install log), `label`, a one-sentence `description`, the `commands` it provides, `requires` (other package ids, read as "Uses Python" while it's installed, or "Installs Python too" on a row that isn't installed yet; an installed row leaves out a requirement that isn't installed, since its warning says so), optional `notes` (short plain-text lines shown muted under the row, such as "1 untested dependency: …"; leave it out when there's nothing to say), optional `warnings` (lines with an alert icon, `data-warnings`, such as "Needs Python, which isn't installed. Install Python again to use it."; the dummy adds that one when a required package is removed), `state` (`available`, `queued`, `installing`, `installed`, `outdated`, `updating`, `removing` or `failed`), the installed `version`, the `offered` version and its `size` in bytes, `progress` counted in packages (or `null` for an indeterminate bar), `error`, `actions` (`install`, `update`, `remove`, `retry`) and an optional `log`. Emit `packages` whenever they change. `actPackage(id, action)` keeps the row's button pending until it settles; it shows any confirm itself, resolves without a change when that confirm is cancelled, and rejects with a displayed error on a real failure. Hooks: `section[data-section="packages"]`, `li[data-id][data-state]`, buttons with `data-action`, `[data-requires]`, `[data-notes]` and `[data-error]`. Real installer wiring belongs to ai-ecoverse/slicc-bios#70. |
211
- | `network` (optional) | BIOS | `status()` returns the `NetworkStatus`: `route` (`proxy` for slicc-node, `extension` for the Chrome extension relay, `tailnet` while a Tailscale exit node carries the traffic, `page` for the page's own fetch, limited by CORS, or `null`), `health` (`ok`, `limited` or `failing`, decided by the port), a one-line `detail`, recent `failures` (`{ url, error, at }`, newest first), an optional `extensionUrl` and an optional `browser` (`{ via, detail? }`, `via` being `extension`, `proxy`, `link` when linked devices' browsers carry it (the line names the browser and its device, or counts them when there are several, counting connected devices only), or `null` when browser automation isn't available; a legacy `declined` flag is ignored, since there is no consent prompt any more). Emit `network` when it changes. The optional `check()` tests the route again. An optional `tailnet` (`TailnetStatus`) shows the Tailscale section: `state` (`off`, `loading`, `needs-login`, `starting`, `running` or `failed`, a status light), a plain-language `detail` (progress, or the failure reason in an error box with **Retry** through `check()`), a `loginUrl` (**Sign in to Tailscale**, a new tab), the `node` (`{ name, addresses }`; the name and the first 100.x address, each with a Copy button), `exitNode` (the name in use or `null`), `exitNodes` (`{ id, name, online }`), `autoExitNode`, `shieldsUp` (a muted line when `true`, a warning when `false`) and `peers` (a count). The optional `setTailnet(on)` adds the **Use Tailscale** switch (`data-action="tailnet"`); `submitAuthKey(key)` adds an auth-key field to `needs-login` (a password field without a `name`, cleared on submit, never kept in component state); `setExitNode(id | 'auto' | null)` adds the **Exit node** picker (None, Automatic, and each node, offline ones disabled) to `running`, with a line saying where traffic goes; `logoutTailnet()` adds **Sign out**, behind a confirm. A rejected call shows its message in the section. Peers are never named beyond the exit-node list. An optional `links` (`NetworkLinks`) shows **Linked devices**, slicc CLIs linked over WebRTC: `joinUrl` and `joinCommand` (each with Copy, `data-copy="join-url"` and `data-copy="join-command"`; both `null` shows "Preparing a join URL…"), `devices` and `permission`. A `LinkedDevice` has an `id`, a display `name`, the kernel `host` and `address`, a `mode` (`local` for a CLI on this computer attached over CDP, or `remote`), `offers` (`net`, `http`, `ssh`; `ssh` adds a copyable `ssh <host>`), a one-line `policy`, `exit` (whether it can carry internet traffic), a `state` (`connecting`, `connected`, `reconnecting` or `offline`, for a device that linked before and isn't there now; every device that ever linked is listed, so one that was refused can be allowed), optional `control` (`{ allowed, fixed? }`; `fixed` for one on this computer, always allowed), optional `controlling` (`prompt` or `watch` while a CLI prompts or watches the agent) and optional `browsers` (`{ runtime, name }`, listed in the row as `data-browsers`, with the runtime such as `pier-nuc/chrome` that the 9222 facade drives). Each device is an `li[data-device]` with `data-state` and `data-mode`; once there are devices, the join details fold into **Link another device**. `permission` is `prompt` (an informative line asking to choose Allow in Chrome's Local Network Access prompt) or `denied` (a warning, with **Try again**, `data-action="retry-links"`, through `retryLinks()`). An optional `exit` (`NetworkExit`: `null`, `{ kind: 'tailnet', node }` with a Tailscale node id or `'auto'`, or `{ kind: 'link', id }`) is the default exit. The optional `setExit(exit)` replaces the Tailscale **Exit node** picker with one **Internet traffic** picker in Connection: Directly, then Tailscale (Automatic and its nodes) and Linked devices (those with `exit: true`), offline ones disabled unless chosen; a chosen device that isn't connected shows that traffic waits, since the exit fails closed. Without `setRemoteControl(id, allowed)`, `control` is a line saying whether it's allowed; with it, **Allow remote control** is a switch. `unlink(id)` adds **Unlink** (`data-action="unlink"`) to each device and `rotateJoinUrl()` adds **New join URL** (`data-action="rotate-join-url"`), both behind a confirm. Their failures show in the section. Without the port, the indicator and the Network panel don't show. |
212
+ | `network` (optional) | BIOS | `status()` returns the `NetworkStatus`: `route` (`proxy` for slicc-node, `extension` for the Chrome extension relay, `tailnet` while a Tailscale exit node carries the traffic, `page` for the page's own fetch, limited by CORS, or `null`), `health` (`ok`, `limited` or `failing`, decided by the port), a one-line `detail`, recent `failures` (`{ url, error, at }`, newest first), an optional `extensionUrl` and an optional `browser` (`{ via, detail? }`, `via` being `extension`, `proxy`, `link` when linked devices' browsers carry it (the line names the browser and its device, or counts them when there are several, counting connected devices only), or `null` when browser automation isn't available; a legacy `declined` flag is ignored, since there is no consent prompt any more). Emit `network` when it changes. The optional `check()` tests the route again. An optional `tailnet` (`TailnetStatus`) shows the Tailscale section: `state` (`off`, `loading`, `needs-login`, `starting`, `running` or `failed`, a status light), a plain-language `detail` (progress, or the failure reason in an error box with **Retry** through `check()`), a `loginUrl` (**Sign in to Tailscale**, a new tab), the `node` (`{ name, addresses }`; the name and the first 100.x address, each with a Copy button), `exitNode` (the name in use or `null`), `exitNodes` (`{ id, name, online }`), `autoExitNode`, `shieldsUp` (a muted line when `true`, a warning when `false`) and `peers` (a count). The optional `setTailnet(on)` adds the **Use Tailscale** switch (`data-action="tailnet"`); `submitAuthKey(key)` adds an auth-key field to `needs-login` (a password field without a `name`, cleared on submit, never kept in component state); `setExitNode(id | 'auto' | null)` adds the **Exit node** picker (None, Automatic, and each node, offline ones disabled) to `running`, with a line saying where traffic goes; `logoutTailnet()` adds **Sign out**, behind a confirm. A rejected call shows its message in the section. Peers are never named beyond the exit-node list. An optional `links` (`NetworkLinks`) shows **Linked devices**, slicc CLIs linked over WebRTC: `joinUrl` and `joinCommand` (each with Copy, `data-copy="join-url"` and `data-copy="join-command"`; both `null` shows "Preparing a join URL…"), an optional `followUrl` (the page URL with `#follow=` and the join URL, shown with a QR code), `devices` and `permission`. A `LinkedDevice` has an `id`, a display `name` (a leading `slicc` is shown as `SLICC`), the kernel `host` and `address`, a `mode` (`local` for a CLI on this computer attached over CDP, or `remote`), `offers` (`net`, `http`, `ssh`; `ssh` adds a copyable `ssh <host>`), a one-line `policy`, `exit` (whether it can carry internet traffic), a `state` (`connecting`, `connected`, `reconnecting` or `offline`, for a device that linked before and isn't there now; every device that ever linked is listed, so one that was refused can be allowed), optional `control` (`{ allowed, fixed? }`; `fixed` for one on this computer, always allowed), optional `controlling` (`prompt` or `watch` while a CLI prompts or watches the agent) optional `browsers` (`{ runtime, name }`, listed in the row as `data-browsers`, with the runtime such as `pier-nuc/chrome` that the 9222 facade drives), optional `kind` (`cli`, or `seven` for another SLICC that follows this one, "Follower" in the row), optional `access` (`{ view?, control?, terminal?, fixed? }`: only the keys present get a switch, so a CLI sends `{ control }`; `fixed` lists them as always allowed from this computer; it replaces `control`), optional `asking` (a follower that waits to be allowed) and optional `terminals` (how many terminals it has open here). Each device is an `li[data-device]` with `data-state`, `data-mode` and `data-kind`, and `data-asking` while it asks; once there are devices, the join details fold into **Link another device**. `permission` is `prompt` (an informative line asking to choose Allow in Chrome's Local Network Access prompt) or `denied` (a warning, with **Try again**, `data-action="retry-links"`, through `retryLinks()`). An optional `exit` (`NetworkExit`: `null`, `{ kind: 'tailnet', node }` with a Tailscale node id or `'auto'`, or `{ kind: 'link', id }`) is the default exit. The optional `setExit(exit)` replaces the Tailscale **Exit node** picker with one **Internet traffic** picker in Connection: Directly, then Tailscale (Automatic and its nodes) and Linked devices (those with `exit: true`), offline ones disabled unless chosen; a chosen device that isn't connected shows that traffic waits, since the exit fails closed. Without `setRemoteControl(id, allowed)`, `control` is a line saying whether it's allowed; with it, **Allow remote control** is a switch. Without `setAccess(id, 'view' \| 'control' \| 'terminal', allowed)`, `access` is a line saying what's allowed; with it, the switches call it, and **Allow** on a device that asks calls `setAccess(id, 'view', true)`. Turning View off should turn Control off too. `unlink(id)` adds **Unlink** (`data-action="unlink"`) to each device and `rotateJoinUrl()` adds **New join URL** (`data-action="rotate-join-url"`), both behind a confirm. Their failures show in the section. Without the port, the indicator and the Network panel don't show. |
212
213
  | `notices` (optional) | BIOS | `list()` returns `Notice`s for quiet, dismissible in-line notices at the top of every chat panel, above the transcript, in list order: `id`, `tone` (`info` reads as informative, `warning` as a notice), `title`, a plain-text `body` and `actions` (`{ id, label }`). Title, body and labels are text only. Notices use `role="status"` and never take focus. An action is a secondary button that stays pending until `act(id, action)` settles; a rejection shows its message under the body. The close button, labelled "Dismiss: <title>", hides the notice at once and calls `dismiss(id)` synchronously; it shows again only if the port drops the notice from `list()` and brings it back later. Emit `notices` whenever they change. Hooks: `[data-notice="<id>"]`, buttons with `data-action`, `[data-dismiss]` and `[data-error]`. |
213
214
  | `tabs` (optional) | BIOS | `state()` returns this tab's `TabsState`: `role` (`owner` runs SLICC; `follower` shows the owner tab's SLICC; `connecting` while the owner boots or hands over; `alone` without SharedWorker), `stalled` (the owner stopped answering) and `skew` (`newer` or `older` when the owner runs another SLICC version, else `null`). Emit `tabs` when it changes. An owner shows nothing. A follower gets a quiet header light, "Another tab" (`[data-tabs][data-role]`, with `data-stalled` and `data-skew`; a dot on phones), info while connecting, and a notice while stalled or skewed, with its explanation as the tooltip. While connecting, an empty chat says "Connecting to the tab running SLICC…" (`[data-connecting]`). Skew, or else a stall, shows a warning banner at the top of every chat panel (`[data-tabs-notice="skew"\|"stalled"]`, `data-skew`): "Another tab runs a newer SLICC. Reload this tab." with **Reload** (`data-action="reload"`, calls `reload()`), "Another tab runs an older SLICC. Reload the other SLICC tab.", or "Chrome paused the tab running SLICC. Switch to it to continue.". An `alone` tab shows only the brand and "SLICC is open in another tab." (`[data-tabs="alone"]`). |
214
215
  | `approvals` (optional) | BIOS | `list()` returns waiting `Approval`s; emit `approvals` when the list changes. A `sudo` approval has `id`, `requester` (`kind` `agent` or `user`, `name`, optional `agentId` and `where`, such as a terminal's title), `command` (without `sudo`; bios joins the kernel's argv shell-quoted), optional `target` (the user to run as; shown as `sudo -u {target}` unless `root`), optional `cwd`, `createdAt`, `expiresAt` (epoch ms) and `always` (offer Always allow, which covers the requester's user and this exact command). With `decide(id, 'once' \| 'always' \| 'deny')`, which resolves once the decision is recorded and then drops the request from `list()`, the tab shows a warning alert dialog (`dialog[data-approval][data-kind][data-requester]`): "Allow sudo?", "{name} wants to run this with sudo[ in {where}]:" for an agent or "You ran this with sudo[ in {where}] (as {name}):" for a user, the command, "In {cwd}", what Always allow does, "Denied automatically in m:ss." and how many more wait, with **Deny** (`data-action="deny"`, focused; Escape also denies), **Always allow** (`always`) and **Allow once** (`once`). For half a second after a request shows, only Deny works, so a double-click can't approve it. A rejected `decide` shows its message in the dialog. Pass `decide` in every tab: the first decision wins. When a request ends without a decision in this tab, emit `ended` with `{ id, reason }` and drop it from `list()`: `answered` (another tab decided) shows "Answered in another tab", `expired` shows "Sudo request timed out", each with the command and **Close** (`data-action="close"`, `dialog[data-gone]`), and `withdrawn` (the requester went away) closes the dialog silently. Without `ended`, a request that leaves the list reads as expired past `expiresAt` and answered before it. Without `decide`, each request is a warning notice in the chat panels: "Waiting for sudo approval", who asks and the command, and "Answer it in the tab running SLICC. Denied automatically at {time}." |
216
+ | `follow` (optional) | BIOS | Only on a follower page, a SLICC that shows another SLICC's agent over a link. `state()` returns the `FollowState`: `owner` (its display name), `link` (`connecting` for the first connection, `connected`, `reconnecting` after a connection dropped, `unlinked` when the owner unlinked this device, or `failed`), `access` (`{ view, control, terminal }`, what the owner allows this device), an optional `name` (this device's name on the owner, for the waiting page) and an optional `error` (shown when `failed`). Emit `follow` when it changes. Connected without `view` means waiting to be allowed. `leave()` forgets the link and the optional `retry()` tries again from `failed`. Pass only the surfaces a follower has to `<slicc-app>` (agents, chat, terminal and settings) and leave out `network`. |
215
217
 
216
- The dummy supports `createDummyModel({ updates: 'boot' | 'starting' | 'current' | 'available' | 'restart' | 'failure' })`, also selectable with `?updates=` on `/ui/`. Fixtures stay stable for inspection; `model.updates.setScenario('current')` completes boot. Dummy Update now and Retry simulate download and link progress; Restart agent and Reload only change fixture state, never restart a real process or reload the browser. `createDummyModel({ packages: 'mixed' | 'empty' | 'fail' | 'off' })` (or `?packages=` on `/ui/`, off by default) adds invented optional packages in every state: Install and Retry finish after a short wait and install missing requirements too, `fail` makes them fail, and removing Python while uv is installed asks through the `confirm` option (the `/ui/` page passes spectrum's `confirm`). `updateFixtures`, `packageFixtures` and `DummyUpdates` are exported from `/dummy`. `createDummyModel({ mounts: 'mount' | 'cancel' | 'fail' | 'needs' })` (or `?mounts=` on `/ui/`) turns on the optional mount members, which are off by default: `mountFolder` mounts invented files at `/mnt/photos`, resolves with `null`, or rejects, as `model.files.mount.scenario` says; `needs` also lists `/mnt/photos` in `needsFolder()` until a folder is inserted there. `createDummyModel({ network: 'ok' | 'limited' | 'failing' | 'off' })` (or `?network=` on `/ui/`, default `limited`) sets the dummy network: slicc-node (with browser automation through it), the page fetch with CORS failures, or no route, all invented; `off` leaves out the port. `model.network.setScenario()` switches it and Check again re-emits after a short wait. `createDummyModel({ tailnet: 'off' | 'needs-login' | 'running' | 'failed' })` (or `?tailnet=` on `/ui/`, off by default) adds an invented tailnet: an auth key starting with `tskey-` signs in, `mast-pi` and `keel-router` are online exit nodes and `buoy-laptop` is offline, and Retry on `failed` connects. While the dummy tailnet runs with an exit node, the route is `tailnet` and the health `ok`. `createDummyModel({ links: 'none' | 'preparing' | 'linked' | 'reconnecting' | 'exit' | 'prompt' | 'denied' | 'remote' })` (or `?links=` on `/ui/`, off by default) adds invented linked devices and the exit members: `linked` has quay-studio (on this computer) and pier-nuc, which can be exits and have a Chrome each, and dock-builder, which can't; with no other browser automation, the dummy reports `via: 'link'`; `exit` sends traffic through quay-studio; `reconnecting` holds traffic for pier-nuc (health `failing`, and `ok` while the exit device is connected, both without a `detail`, as bios reports them); `remote` has pier-nuc allowed and prompting, quay-studio watching, and an offline reef-laptop that only ever connected for control; `prompt` and `denied` wait for Local Network Access, and Try again links quay-studio. New join URL changes the URL, and `model.network.setLinks()` switches scenarios. `networkFixtures`, `tailnetFixtures`, `linksFixtures`, `joinUrl` and `DummyNetwork` are exported from `/dummy`. `createDummyModel({ changes: 'files' | 'git' | 'nogit' })` (or `?changes=` on `/ui/`, default `files`) adds a git `changes` port: `git` lists the pending changes under `/workspace/harbor` and `/workspace/skills` with their `repo` and no author, and `nogit` reports git as unavailable. `DummyChanges` is exported from `/dummy`. `createDummyModel({ notices: 'storage' | 'mixed' | 'fail' | 'off' })` (or `?notices=` on `/ui/`, off by default) adds an invented storage warning with Try again, which clears it after a short wait (`mixed` adds an informative notice without actions, and `fail` makes Try again fail). `noticeFixtures` and `DummyNotices` are exported from `/dummy`. `createDummyModel({ browser: 'idle' | 'driving' | 'off' })` (or `?browser=` on `/ui/`, default `idle`) sets the dummy browser: `idle` has a history of invented actions and no driven tab, `driving` has harbor and quiet-otter driving two tabs, with harbor's last click still running and frames every 500 ms while watched, and `off` has no tabs. Stopping an agent releases its tabs. `DummyBrowser`, `browserHistory` and `page` are exported from `/dummy`. `createDummyModel({ tabs: 'owner' | 'follower' | 'connecting' | 'stalled' | 'newer' | 'older' | 'alone' | 'off' })` (or `?tabs=` on `/ui/`, off by default) adds a tabs port in that state; `model.tabs.setScenario()` switches it, and Reload connects again and becomes a follower after a short wait. `tabsFixtures` and `DummyTabs` are exported from `/dummy`.
218
+ The dummy supports `createDummyModel({ updates: 'boot' | 'starting' | 'current' | 'available' | 'restart' | 'failure' })`, also selectable with `?updates=` on `/ui/`. Fixtures stay stable for inspection; `model.updates.setScenario('current')` completes boot. Dummy Update now and Retry simulate download and link progress; Restart agent and Reload only change fixture state, never restart a real process or reload the browser. `createDummyModel({ packages: 'mixed' | 'empty' | 'fail' | 'off' })` (or `?packages=` on `/ui/`, off by default) adds invented optional packages in every state: Install and Retry finish after a short wait and install missing requirements too, `fail` makes them fail, and removing Python while uv is installed asks through the `confirm` option (the `/ui/` page passes spectrum's `confirm`). `updateFixtures`, `packageFixtures` and `DummyUpdates` are exported from `/dummy`. `createDummyModel({ mounts: 'mount' | 'cancel' | 'fail' | 'needs' })` (or `?mounts=` on `/ui/`) turns on the optional mount members, which are off by default: `mountFolder` mounts invented files at `/mnt/photos`, resolves with `null`, or rejects, as `model.files.mount.scenario` says; `needs` also lists `/mnt/photos` in `needsFolder()` until a folder is inserted there. `createDummyModel({ network: 'ok' | 'limited' | 'failing' | 'off' })` (or `?network=` on `/ui/`, default `limited`) sets the dummy network: slicc-node (with browser automation through it), the page fetch with CORS failures, or no route, all invented; `off` leaves out the port. `model.network.setScenario()` switches it and Check again re-emits after a short wait. `createDummyModel({ tailnet: 'off' | 'needs-login' | 'running' | 'failed' })` (or `?tailnet=` on `/ui/`, off by default) adds an invented tailnet: an auth key starting with `tskey-` signs in, `mast-pi` and `keel-router` are online exit nodes and `buoy-laptop` is offline, and Retry on `failed` connects. While the dummy tailnet runs with an exit node, the route is `tailnet` and the health `ok`. `createDummyModel({ links: 'none' | 'preparing' | 'linked' | 'reconnecting' | 'exit' | 'prompt' | 'denied' | 'remote' | 'followers' })` (or `?links=` on `/ui/`, off by default) adds invented linked devices and the exit members: `linked` has quay-studio (on this computer) and pier-nuc, which can be exits and have a Chrome each, and dock-builder, which can't; with no other browser automation, the dummy reports `via: 'link'`; `exit` sends traffic through quay-studio; `reconnecting` holds traffic for pier-nuc (health `failing`, and `ok` while the exit device is connected, both without a `detail`, as bios reports them); `remote` has pier-nuc allowed and prompting, quay-studio watching, and an offline reef-laptop that only ever connected for control; `prompt` and `denied` wait for Local Network Access, and Try again links quay-studio. New join URL changes the URL, and `model.network.setLinks()` switches scenarios. `networkFixtures`, `tailnetFixtures`, `linksFixtures`, `joinUrl` and `DummyNetwork` are exported from `/dummy`. `createDummyModel({ changes: 'files' | 'git' | 'nogit' })` (or `?changes=` on `/ui/`, default `files`) adds a git `changes` port: `git` lists the pending changes under `/workspace/harbor` and `/workspace/skills` with their `repo` and no author, and `nogit` reports git as unavailable. `DummyChanges` is exported from `/dummy`. `createDummyModel({ notices: 'storage' | 'mixed' | 'fail' | 'off' })` (or `?notices=` on `/ui/`, off by default) adds an invented storage warning with Try again, which clears it after a short wait (`mixed` adds an informative notice without actions, and `fail` makes Try again fail). `noticeFixtures` and `DummyNotices` are exported from `/dummy`. `createDummyModel({ browser: 'idle' | 'driving' | 'off' })` (or `?browser=` on `/ui/`, default `idle`) sets the dummy browser: `idle` has a history of invented actions and no driven tab, `driving` has harbor and quiet-otter driving two tabs, with harbor's last click still running and frames every 500 ms while watched, and `off` has no tabs. Stopping an agent releases its tabs. `DummyBrowser`, `browserHistory` and `page` are exported from `/dummy`. `createDummyModel({ tabs: 'owner' | 'follower' | 'connecting' | 'stalled' | 'newer' | 'older' | 'alone' | 'off' })` (or `?tabs=` on `/ui/`, off by default) adds a tabs port in that state; `model.tabs.setScenario()` switches it, and Reload connects again and becomes a follower after a short wait. `tabsFixtures` and `DummyTabs` are exported from `/dummy`.
219
+
220
+ `createDummyModel({ follow: 'connecting' | 'waiting' | 'watch' | 'control' | 'full' | 'reconnecting' | 'unlinked' | 'failed' })` (`?follow=` on `/ui/`, which then leaves out the network port and offers only agents, chat, terminal and settings) makes the page a follower of SLICC on quay-studio: connecting, waiting to be allowed, View only, View and Control, everything, reconnecting, unlinked, or failed, where Try again connects with everything allowed. Leave counts in `model.follow.left` (the `/ui/` page then drops `?follow=`), and `model.follow.setScenario()` switches it. `followFixtures` and `DummyFollow` are exported from `/dummy`. `?links=followers` lists quay-studio (a CLI on this computer, Control always allowed), pier-nuc (a remote CLI, prompting), bay-laptop (a follower with everything allowed, watching, a terminal open), reef-phone (View only, watching) and tide-tablet (asking to follow); every links scenario has a follow link.
217
221
 
218
222
  `createDummyModel({ approvals: 'agent' | 'user' | 'many' | 'follower' | 'fail' | 'soon' | 'elsewhere' | 'withdrawn' })` (`?approvals=` on `/ui/`) adds waiting sudo requests: one from an agent, one from a terminal user, three in a row (the last as `www-data`), one without `decide`, one whose `decide` fails, one that expires in 24 seconds, or two whose first is answered in another tab or withdrawn after a few seconds. Decisions are recorded in `model.approvals.decisions`, and `model.approvals.end(id, reason)` ends a request.
219
223
 
220
- On phones the update status and the tray show only their status light and the network indicator a cloud icon, with the text in their labels and tooltips; the agent picker keeps room for the cone's name, cut with an ellipsis when long, and the bottom rail scrolls when needed. The update status comes first so progress and failures remain reachable on narrow screens.
224
+ On phones the update status and the tray connection show only their status light (the tray's budget is hidden) and the network indicator a cloud icon, with the text in their labels and tooltips; the agent picker keeps room for the cone's name, cut with an ellipsis when long, and the bottom rail scrolls when needed. The update status comes first so progress and failures remain reachable on narrow screens.
221
225
 
222
226
  ### Kernel model
223
227
 
@@ -324,6 +324,56 @@ var DummyChanges = class {
324
324
  }
325
325
  };
326
326
 
327
+ // src/dummy/follow.ts
328
+ var owner = "SLICC on quay-studio";
329
+ var name = "SLICC on reef-phone";
330
+ function followFixtures(scenario) {
331
+ const none = { view: false, control: false, terminal: false };
332
+ const full = { view: true, control: true, terminal: true };
333
+ const base = { owner, name };
334
+ if (scenario === "connecting") return { ...base, link: "connecting", access: none };
335
+ if (scenario === "waiting") return { ...base, link: "connected", access: none };
336
+ if (scenario === "watch") return { ...base, link: "connected", access: { ...none, view: true } };
337
+ if (scenario === "control")
338
+ return { ...base, link: "connected", access: { ...full, terminal: false } };
339
+ if (scenario === "reconnecting") return { ...base, link: "reconnecting", access: full };
340
+ if (scenario === "unlinked") return { ...base, link: "unlinked", access: none };
341
+ if (scenario === "failed")
342
+ return {
343
+ ...base,
344
+ link: "failed",
345
+ access: none,
346
+ error: `Couldn\u2019t reach ${owner}. It may be closed or offline.`
347
+ };
348
+ return { ...base, link: "connected", access: full };
349
+ }
350
+ var DummyFollow = class extends Emitter {
351
+ #state;
352
+ #time;
353
+ left = 0;
354
+ constructor(scenario, time) {
355
+ super();
356
+ this.#state = followFixtures(scenario);
357
+ this.#time = time;
358
+ }
359
+ state() {
360
+ return this.#state;
361
+ }
362
+ setScenario(scenario) {
363
+ this.#state = followFixtures(scenario);
364
+ this.emit("follow", this.#state);
365
+ }
366
+ async leave() {
367
+ await this.#time.sleep(4);
368
+ this.left++;
369
+ }
370
+ async retry() {
371
+ this.setScenario("connecting");
372
+ await this.#time.sleep(20);
373
+ this.setScenario("full");
374
+ }
375
+ };
376
+
327
377
  // src/dummy/script.ts
328
378
  var units = "/workspace/harbor/src/lib/units.ts";
329
379
  var kelvin = `
@@ -899,11 +949,11 @@ var DummyAgent = class extends Emitter {
899
949
  }
900
950
  async ready() {
901
951
  }
902
- createScoop(parentId, name) {
952
+ createScoop(parentId, name2) {
903
953
  const parent = this.#agent(parentId);
904
954
  const agent = {
905
955
  id: this.#id("scoop"),
906
- name,
956
+ name: name2,
907
957
  kind: "scoop",
908
958
  parentId: parent?.kind === "cone" ? parent.id : parent?.parentId ?? null,
909
959
  status: "idle",
@@ -918,7 +968,7 @@ var DummyAgent = class extends Emitter {
918
968
  id: this.#id("m"),
919
969
  role: "lick",
920
970
  channel: "scoop-idle",
921
- title: name,
971
+ title: name2,
922
972
  text: "New scoop, waiting for its first task",
923
973
  createdAt: Date.now()
924
974
  });
@@ -937,8 +987,8 @@ var DummyAgent = class extends Emitter {
937
987
  if (this.#active === agentId) this.select(agent.parentId ?? this.#agents[0].id);
938
988
  this.#changed();
939
989
  }
940
- async createCone(name) {
941
- const trimmed = name.trim();
990
+ async createCone(name2) {
991
+ const trimmed = name2.trim();
942
992
  if (!trimmed) throw new Error("A cone needs a name");
943
993
  if (this.#agents.some((agent2) => agent2.kind === "cone" && agent2.name === trimmed)) {
944
994
  throw new Error(`A cone named ${trimmed} already exists`);
@@ -2419,8 +2469,8 @@ function tone(seconds = 0.6, frequency = 440, rate = 8e3) {
2419
2469
  for (const byte of bytes) binary += String.fromCharCode(byte);
2420
2470
  return `data:audio/wav;base64,${btoa(binary)}`;
2421
2471
  }
2422
- function tool(id, name, title, input, output, extra = {}) {
2423
- return { id, name, title, input, output, status: "done", paths: [], ...extra };
2472
+ function tool(id, name2, title, input, output, extra = {}) {
2473
+ return { id, name: name2, title, input, output, status: "done", paths: [], ...extra };
2424
2474
  }
2425
2475
  var markdown = [
2426
2476
  "## The forecast API in one page",
@@ -4129,12 +4179,100 @@ var linkedDevices = [
4129
4179
  function joinUrl(rotation = 0) {
4130
4180
  return `https://join.slicc.example/l/${rotation ? `r${rotation}-` : ""}7Hq2kd9FXw3m`;
4131
4181
  }
4182
+ function followUrl(rotation = 0) {
4183
+ return `https://seven.slicc.example/#follow=${joinUrl(rotation)}`;
4184
+ }
4185
+ var followers = [
4186
+ {
4187
+ id: "link-quay",
4188
+ name: "slicc on quay-studio",
4189
+ host: "quay-studio.slicc.internal",
4190
+ address: "198.18.57.11",
4191
+ mode: "local",
4192
+ offers: ["net", "http", "ssh"],
4193
+ exit: true,
4194
+ state: "connected",
4195
+ kind: "cli",
4196
+ access: { control: true, fixed: true }
4197
+ },
4198
+ {
4199
+ id: "link-pier",
4200
+ name: "slicc on pier-nuc",
4201
+ host: "pier-nuc.slicc.internal",
4202
+ address: "198.18.57.12",
4203
+ mode: "remote",
4204
+ offers: ["net", "ssh"],
4205
+ exit: true,
4206
+ state: "connected",
4207
+ kind: "cli",
4208
+ access: { control: true },
4209
+ controlling: "prompt"
4210
+ },
4211
+ {
4212
+ id: "link-bay",
4213
+ name: "SLICC on bay-laptop",
4214
+ host: "",
4215
+ address: "",
4216
+ mode: "remote",
4217
+ offers: [],
4218
+ exit: false,
4219
+ state: "connected",
4220
+ kind: "seven",
4221
+ access: { view: true, control: true, terminal: true },
4222
+ controlling: "watch",
4223
+ terminals: 1
4224
+ },
4225
+ {
4226
+ id: "link-reef",
4227
+ name: "SLICC on reef-phone",
4228
+ host: "",
4229
+ address: "",
4230
+ mode: "remote",
4231
+ offers: [],
4232
+ exit: false,
4233
+ state: "connected",
4234
+ kind: "seven",
4235
+ access: { view: true, control: false, terminal: false },
4236
+ controlling: "watch"
4237
+ },
4238
+ {
4239
+ id: "link-tide",
4240
+ name: "SLICC on tide-tablet",
4241
+ host: "",
4242
+ address: "",
4243
+ mode: "remote",
4244
+ offers: [],
4245
+ exit: false,
4246
+ state: "connected",
4247
+ kind: "seven",
4248
+ asking: true,
4249
+ access: { view: false, control: false, terminal: false }
4250
+ }
4251
+ ];
4252
+ function grant(device, key, allowed) {
4253
+ const access = { ...device.access, [key]: allowed };
4254
+ if (key === "view" && !allowed && "control" in access) access.control = false;
4255
+ const next = { ...device, access, asking: device.asking && !access.view };
4256
+ if (!access.view && !access.control) next.controlling = void 0;
4257
+ else if (!access.control && next.controlling === "prompt") next.controlling = "watch";
4258
+ if (!access.terminal) next.terminals = void 0;
4259
+ return next;
4260
+ }
4132
4261
  function linksFixtures(scenario, rotation = 0) {
4133
4262
  const url = joinUrl(rotation);
4134
- const base = { joinUrl: url, joinCommand: `npx sliccy ${url} follow`, permission: null };
4263
+ const base = {
4264
+ joinUrl: url,
4265
+ joinCommand: `npx sliccy ${url} follow`,
4266
+ followUrl: followUrl(rotation),
4267
+ permission: null
4268
+ };
4135
4269
  if (scenario === "preparing") {
4136
- return { links: { ...base, joinUrl: null, joinCommand: null, devices: [] }, exit: null };
4270
+ return {
4271
+ links: { ...base, joinUrl: null, joinCommand: null, followUrl: null, devices: [] },
4272
+ exit: null
4273
+ };
4137
4274
  }
4275
+ if (scenario === "followers") return { links: { ...base, devices: followers }, exit: null };
4138
4276
  if (scenario === "none") return { links: { ...base, devices: [] }, exit: null };
4139
4277
  if (scenario === "prompt") {
4140
4278
  return { links: { ...base, devices: [], permission: "prompt" }, exit: null };
@@ -4185,6 +4323,7 @@ var DummyNetwork = class extends Emitter {
4185
4323
  setExit;
4186
4324
  unlink;
4187
4325
  setRemoteControl;
4326
+ setAccess;
4188
4327
  rotateJoinUrl;
4189
4328
  retryLinks;
4190
4329
  constructor(scenario, clock2, tailnet, links) {
@@ -4197,6 +4336,7 @@ var DummyNetwork = class extends Emitter {
4197
4336
  this.setExit = (exit) => this.#setExit(exit);
4198
4337
  this.unlink = (id) => this.#unlink(id);
4199
4338
  this.setRemoteControl = (id, allowed) => this.#control(id, allowed);
4339
+ this.setAccess = (id, key, allowed) => this.#access(id, key, allowed);
4200
4340
  this.rotateJoinUrl = () => this.#rotate();
4201
4341
  this.retryLinks = () => this.#retry();
4202
4342
  }
@@ -4268,6 +4408,15 @@ var DummyNetwork = class extends Emitter {
4268
4408
  this.#links = { ...links, devices };
4269
4409
  this.#emit();
4270
4410
  }
4411
+ async #access(id, key, allowed) {
4412
+ await this.#clock.sleep(4);
4413
+ const links = this.#links;
4414
+ const devices = links.devices.map(
4415
+ (device) => device.id === id ? grant(device, key, allowed) : device
4416
+ );
4417
+ this.#links = { ...links, devices };
4418
+ this.#emit();
4419
+ }
4271
4420
  async #unlink(id) {
4272
4421
  await this.#clock.sleep(4);
4273
4422
  const links = this.#links;
@@ -4282,7 +4431,8 @@ var DummyNetwork = class extends Emitter {
4282
4431
  this.#links = {
4283
4432
  ...this.#links,
4284
4433
  joinUrl: url,
4285
- joinCommand: `npx sliccy ${url} follow`
4434
+ joinCommand: `npx sliccy ${url} follow`,
4435
+ followUrl: followUrl(this.#rotation)
4286
4436
  };
4287
4437
  this.#emit();
4288
4438
  }
@@ -4546,8 +4696,8 @@ var FakeShell = class {
4546
4696
  const names = [...entries].filter(
4547
4697
  ([candidate]) => candidate.startsWith(prefix) && !candidate.slice(prefix.length).includes("/")
4548
4698
  ).map(([candidate, kind]) => {
4549
- const name = candidate.slice(prefix.length);
4550
- return kind === "directory" ? `\x1B[1;34m${name}\x1B[0m` : name;
4699
+ const name2 = candidate.slice(prefix.length);
4700
+ return kind === "directory" ? `\x1B[1;34m${name2}\x1B[0m` : name2;
4551
4701
  });
4552
4702
  if (path !== "/" && !entries.has(path))
4553
4703
  this.#print(`ls: cannot access '${target}': No such file or directory`);
@@ -4600,12 +4750,12 @@ var FakeShell = class {
4600
4750
  }
4601
4751
  }
4602
4752
  async #run(line) {
4603
- const [name, ...args] = line.trim().split(/\s+/);
4604
- if (!name) return;
4753
+ const [name2, ...args] = line.trim().split(/\s+/);
4754
+ if (!name2) return;
4605
4755
  this.#history.push(line);
4606
- const command = this.#commands[name];
4756
+ const command = this.#commands[name2];
4607
4757
  if (command) await command(args);
4608
- else this.#print(`bash: ${name}: command not found`);
4758
+ else this.#print(`bash: ${name2}: command not found`);
4609
4759
  }
4610
4760
  #key(char) {
4611
4761
  if (this.#escape) {
@@ -4652,8 +4802,8 @@ var FakeShell = class {
4652
4802
  }
4653
4803
  resize() {
4654
4804
  }
4655
- signal(name) {
4656
- if (name === "SIGHUP") {
4805
+ signal(name2) {
4806
+ if (name2 === "SIGHUP") {
4657
4807
  this.#exit();
4658
4808
  return;
4659
4809
  }
@@ -5067,6 +5217,7 @@ function createDummyModel({
5067
5217
  notices = "off",
5068
5218
  tabs: tabs2 = "off",
5069
5219
  approvals = "off",
5220
+ follow = "off",
5070
5221
  browser: scenario = "idle"
5071
5222
  } = {}) {
5072
5223
  const time = clock(delay);
@@ -5118,7 +5269,8 @@ function createDummyModel({
5118
5269
  ...changes === "files" ? {} : { changes: new DummyChanges(files3, changes === "git") },
5119
5270
  ...notices === "off" ? {} : { notices: new DummyNotices(notices, time) },
5120
5271
  ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) },
5121
- ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time) }
5272
+ ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time) },
5273
+ ...follow === "off" ? {} : { follow: new DummyFollow(follow, time) }
5122
5274
  };
5123
5275
  return { ...base, monitor: new DummyMonitor(base) };
5124
5276
  }
@@ -5126,6 +5278,7 @@ export {
5126
5278
  DummyApprovals,
5127
5279
  DummyBrowser,
5128
5280
  DummyChanges,
5281
+ DummyFollow,
5129
5282
  DummyNetwork,
5130
5283
  DummyNotices,
5131
5284
  DummyTabs,
@@ -5133,6 +5286,7 @@ export {
5133
5286
  approvalFixtures,
5134
5287
  browserHistory,
5135
5288
  createDummyModel,
5289
+ followFixtures,
5136
5290
  joinUrl,
5137
5291
  linksFixtures,
5138
5292
  networkFixtures,