@ai-ecoverse/slicc-spectrum 1.50.0 → 1.52.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
@@ -131,12 +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. |
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; renders nothing without either. |
139
- | `<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. |
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
+ | `<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
+ | `<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-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. |
140
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. |
141
143
  | `<slicc-file-view>` | One file (`path`), highlighted, with its pending change and a link to the diff. |
142
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"`). |
@@ -149,7 +151,7 @@ Text is Adobe Clean Spectrum VF, falling back to Adobe Clean, and code is Spectr
149
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). |
150
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. |
151
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. |
152
- | `<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-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. 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. |
153
155
  | `<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
156
  | `<slicc-confirm>` | The confirmation dialog `confirm()` opens. Not for direct use. |
155
157
  | `<slicc-prompt>` | The name dialog `prompt()` opens. Not for direct use. |
@@ -207,11 +209,17 @@ Paths are the agent's: `/shared/…` and `/home/…`, nothing else. A refusal or
207
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`. |
208
210
  | `tray` | the tray protocol | Connection, role, float kind, followers, spend and budget; `reconnect` and `disconnect`. |
209
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. |
210
- | `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. |
211
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]`. |
212
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"]`). |
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`. |
213
217
 
214
- 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.
221
+
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.
215
223
 
216
224
  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.
217
225
 
@@ -7,6 +7,74 @@ import {
7
7
  } from "./chunk-T5NHW4KC.js";
8
8
  import "./chunk-AXQFWXYZ.js";
9
9
 
10
+ // src/dummy/approvals.ts
11
+ var minutes = 6e4;
12
+ function approvalFixtures(scenario, now2 = Date.now()) {
13
+ const agent = {
14
+ id: "sudo-jq",
15
+ kind: "sudo",
16
+ requester: { kind: "agent", name: "docs-pass", agentId: "scoop-docs" },
17
+ command: "apt install jq",
18
+ cwd: "/home/cone/harbor",
19
+ createdAt: now2,
20
+ expiresAt: now2 + (scenario === "soon" ? 0.4 * minutes : 5 * minutes),
21
+ always: true
22
+ };
23
+ const user = {
24
+ id: "sudo-chown",
25
+ kind: "sudo",
26
+ requester: { kind: "user", name: "cone", where: "Terminal 2" },
27
+ command: "chown -R cone /home/cone/shared",
28
+ createdAt: now2 + 1,
29
+ expiresAt: now2 + 5 * minutes,
30
+ always: true
31
+ };
32
+ const cache = {
33
+ id: "sudo-cache",
34
+ kind: "sudo",
35
+ requester: { kind: "agent", name: "quiet-otter", agentId: "scoop-otter" },
36
+ command: "rm -rf /var/cache/harbor",
37
+ target: "www-data",
38
+ createdAt: now2 + 2,
39
+ expiresAt: now2 + 5 * minutes,
40
+ always: false
41
+ };
42
+ if (scenario === "user") return [user];
43
+ if (scenario === "many") return [agent, user, cache];
44
+ if (scenario === "elsewhere" || scenario === "withdrawn") return [agent, user];
45
+ return [agent];
46
+ }
47
+ var DummyApprovals = class extends Emitter {
48
+ #approvals;
49
+ #time;
50
+ #fail;
51
+ decisions = [];
52
+ decide;
53
+ constructor(scenario, time) {
54
+ super();
55
+ this.#approvals = approvalFixtures(scenario);
56
+ this.#time = time;
57
+ this.#fail = scenario === "fail";
58
+ if (scenario !== "follower") this.decide = (id, decision) => this.#decide(id, decision);
59
+ if (scenario === "elsewhere" || scenario === "withdrawn")
60
+ void time.sleep(100).then(() => this.end("sudo-jq", scenario === "elsewhere" ? "answered" : "withdrawn"));
61
+ }
62
+ end(id, reason) {
63
+ this.emit("ended", { id, reason });
64
+ this.#approvals = this.#approvals.filter((approval) => approval.id !== id);
65
+ this.emit("approvals", this.list());
66
+ }
67
+ list() {
68
+ return [...this.#approvals];
69
+ }
70
+ async #decide(id, decision) {
71
+ await this.#time.sleep(10);
72
+ if (this.#fail) throw new Error("The kernel stopped waiting. Run the command again.");
73
+ this.decisions.push([id, decision]);
74
+ this.end(id, "answered");
75
+ }
76
+ };
77
+
10
78
  // src/dummy/browser.ts
11
79
  function escape(text) {
12
80
  return text.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -256,6 +324,56 @@ var DummyChanges = class {
256
324
  }
257
325
  };
258
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
+
259
377
  // src/dummy/script.ts
260
378
  var units = "/workspace/harbor/src/lib/units.ts";
261
379
  var kelvin = `
@@ -831,11 +949,11 @@ var DummyAgent = class extends Emitter {
831
949
  }
832
950
  async ready() {
833
951
  }
834
- createScoop(parentId, name) {
952
+ createScoop(parentId, name2) {
835
953
  const parent = this.#agent(parentId);
836
954
  const agent = {
837
955
  id: this.#id("scoop"),
838
- name,
956
+ name: name2,
839
957
  kind: "scoop",
840
958
  parentId: parent?.kind === "cone" ? parent.id : parent?.parentId ?? null,
841
959
  status: "idle",
@@ -850,7 +968,7 @@ var DummyAgent = class extends Emitter {
850
968
  id: this.#id("m"),
851
969
  role: "lick",
852
970
  channel: "scoop-idle",
853
- title: name,
971
+ title: name2,
854
972
  text: "New scoop, waiting for its first task",
855
973
  createdAt: Date.now()
856
974
  });
@@ -869,8 +987,8 @@ var DummyAgent = class extends Emitter {
869
987
  if (this.#active === agentId) this.select(agent.parentId ?? this.#agents[0].id);
870
988
  this.#changed();
871
989
  }
872
- async createCone(name) {
873
- const trimmed = name.trim();
990
+ async createCone(name2) {
991
+ const trimmed = name2.trim();
874
992
  if (!trimmed) throw new Error("A cone needs a name");
875
993
  if (this.#agents.some((agent2) => agent2.kind === "cone" && agent2.name === trimmed)) {
876
994
  throw new Error(`A cone named ${trimmed} already exists`);
@@ -2319,7 +2437,7 @@ var DummyFiles = class extends Emitter {
2319
2437
 
2320
2438
  // src/dummy/showcase.ts
2321
2439
  var start = Date.UTC(2026, 9, 4, 16, 0);
2322
- var at = (minutes) => start + minutes * 6e4;
2440
+ var at = (minutes2) => start + minutes2 * 6e4;
2323
2441
  function picture(label, from, to, width = 640, height = 400) {
2324
2442
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="${width}" height="${height}" fill="url(#g)"/><text x="50%" y="50%" font-family="sans-serif" font-size="${Math.round(height / 9)}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
2325
2443
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
@@ -2351,8 +2469,8 @@ function tone(seconds = 0.6, frequency = 440, rate = 8e3) {
2351
2469
  for (const byte of bytes) binary += String.fromCharCode(byte);
2352
2470
  return `data:audio/wav;base64,${btoa(binary)}`;
2353
2471
  }
2354
- function tool(id, name, title, input, output, extra = {}) {
2355
- 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 };
2356
2474
  }
2357
2475
  var markdown = [
2358
2476
  "## The forecast API in one page",
@@ -3415,7 +3533,7 @@ var agents = [
3415
3533
  }
3416
3534
  ];
3417
3535
  var start2 = Date.UTC(2026, 9, 5, 8, 30);
3418
- var at2 = (minutes) => start2 + minutes * 6e4;
3536
+ var at2 = (minutes2) => start2 + minutes2 * 6e4;
3419
3537
  var queues = { "cone-kitchen": showcaseQueue() };
3420
3538
  var conversations = {
3421
3539
  "cone-kitchen": showcase(),
@@ -4061,12 +4179,100 @@ var linkedDevices = [
4061
4179
  function joinUrl(rotation = 0) {
4062
4180
  return `https://join.slicc.example/l/${rotation ? `r${rotation}-` : ""}7Hq2kd9FXw3m`;
4063
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
+ }
4064
4261
  function linksFixtures(scenario, rotation = 0) {
4065
4262
  const url = joinUrl(rotation);
4066
- 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
+ };
4067
4269
  if (scenario === "preparing") {
4068
- 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
+ };
4069
4274
  }
4275
+ if (scenario === "followers") return { links: { ...base, devices: followers }, exit: null };
4070
4276
  if (scenario === "none") return { links: { ...base, devices: [] }, exit: null };
4071
4277
  if (scenario === "prompt") {
4072
4278
  return { links: { ...base, devices: [], permission: "prompt" }, exit: null };
@@ -4117,6 +4323,7 @@ var DummyNetwork = class extends Emitter {
4117
4323
  setExit;
4118
4324
  unlink;
4119
4325
  setRemoteControl;
4326
+ setAccess;
4120
4327
  rotateJoinUrl;
4121
4328
  retryLinks;
4122
4329
  constructor(scenario, clock2, tailnet, links) {
@@ -4129,6 +4336,7 @@ var DummyNetwork = class extends Emitter {
4129
4336
  this.setExit = (exit) => this.#setExit(exit);
4130
4337
  this.unlink = (id) => this.#unlink(id);
4131
4338
  this.setRemoteControl = (id, allowed) => this.#control(id, allowed);
4339
+ this.setAccess = (id, key, allowed) => this.#access(id, key, allowed);
4132
4340
  this.rotateJoinUrl = () => this.#rotate();
4133
4341
  this.retryLinks = () => this.#retry();
4134
4342
  }
@@ -4200,6 +4408,15 @@ var DummyNetwork = class extends Emitter {
4200
4408
  this.#links = { ...links, devices };
4201
4409
  this.#emit();
4202
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
+ }
4203
4420
  async #unlink(id) {
4204
4421
  await this.#clock.sleep(4);
4205
4422
  const links = this.#links;
@@ -4214,7 +4431,8 @@ var DummyNetwork = class extends Emitter {
4214
4431
  this.#links = {
4215
4432
  ...this.#links,
4216
4433
  joinUrl: url,
4217
- joinCommand: `npx sliccy ${url} follow`
4434
+ joinCommand: `npx sliccy ${url} follow`,
4435
+ followUrl: followUrl(this.#rotation)
4218
4436
  };
4219
4437
  this.#emit();
4220
4438
  }
@@ -4478,8 +4696,8 @@ var FakeShell = class {
4478
4696
  const names = [...entries].filter(
4479
4697
  ([candidate]) => candidate.startsWith(prefix) && !candidate.slice(prefix.length).includes("/")
4480
4698
  ).map(([candidate, kind]) => {
4481
- const name = candidate.slice(prefix.length);
4482
- 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;
4483
4701
  });
4484
4702
  if (path !== "/" && !entries.has(path))
4485
4703
  this.#print(`ls: cannot access '${target}': No such file or directory`);
@@ -4532,12 +4750,12 @@ var FakeShell = class {
4532
4750
  }
4533
4751
  }
4534
4752
  async #run(line) {
4535
- const [name, ...args] = line.trim().split(/\s+/);
4536
- if (!name) return;
4753
+ const [name2, ...args] = line.trim().split(/\s+/);
4754
+ if (!name2) return;
4537
4755
  this.#history.push(line);
4538
- const command = this.#commands[name];
4756
+ const command = this.#commands[name2];
4539
4757
  if (command) await command(args);
4540
- else this.#print(`bash: ${name}: command not found`);
4758
+ else this.#print(`bash: ${name2}: command not found`);
4541
4759
  }
4542
4760
  #key(char) {
4543
4761
  if (this.#escape) {
@@ -4584,8 +4802,8 @@ var FakeShell = class {
4584
4802
  }
4585
4803
  resize() {
4586
4804
  }
4587
- signal(name) {
4588
- if (name === "SIGHUP") {
4805
+ signal(name2) {
4806
+ if (name2 === "SIGHUP") {
4589
4807
  this.#exit();
4590
4808
  return;
4591
4809
  }
@@ -4998,6 +5216,8 @@ function createDummyModel({
4998
5216
  changes = "files",
4999
5217
  notices = "off",
5000
5218
  tabs: tabs2 = "off",
5219
+ approvals = "off",
5220
+ follow = "off",
5001
5221
  browser: scenario = "idle"
5002
5222
  } = {}) {
5003
5223
  const time = clock(delay);
@@ -5048,19 +5268,25 @@ function createDummyModel({
5048
5268
  ...network === "off" ? {} : { network: new DummyNetwork(network, time, tailnet, links) },
5049
5269
  ...changes === "files" ? {} : { changes: new DummyChanges(files3, changes === "git") },
5050
5270
  ...notices === "off" ? {} : { notices: new DummyNotices(notices, time) },
5051
- ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) }
5271
+ ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) },
5272
+ ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time) },
5273
+ ...follow === "off" ? {} : { follow: new DummyFollow(follow, time) }
5052
5274
  };
5053
5275
  return { ...base, monitor: new DummyMonitor(base) };
5054
5276
  }
5055
5277
  export {
5278
+ DummyApprovals,
5056
5279
  DummyBrowser,
5057
5280
  DummyChanges,
5281
+ DummyFollow,
5058
5282
  DummyNetwork,
5059
5283
  DummyNotices,
5060
5284
  DummyTabs,
5061
5285
  DummyUpdates,
5286
+ approvalFixtures,
5062
5287
  browserHistory,
5063
5288
  createDummyModel,
5289
+ followFixtures,
5064
5290
  joinUrl,
5065
5291
  linksFixtures,
5066
5292
  networkFixtures,