@ai-ecoverse/slicc-spectrum 1.46.1 → 1.47.1

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
@@ -149,7 +149,7 @@ Text is Adobe Clean Spectrum VF, falling back to Adobe Clean, and code is Spectr
149
149
  | `<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
150
  | `<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
151
  | `<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, or not available with how to get it) when the status has `browser`, **Check again** (`data-action="check"`) when the port has `check`, a **Tailscale** section (`data-tailnet` with the state) when the status has `tailnet`, **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`), 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. Offered only when the model has a `network` port. |
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, 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`, with the line saying where traffic goes (`data-exit`: `node` or `device`, `data-held` while the chosen device isn't connected), a **Tailscale** section (`data-tailnet` with the state) when the status has `tailnet`, a **Linked devices** section (`data-links`: `linked`, `connecting`, `reconnecting`, `none` or `permission`) 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. Offered only when the model has a `network` port. |
153
153
  | `<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
154
  | `<slicc-confirm>` | The confirmation dialog `confirm()` opens. Not for direct use. |
155
155
  | `<slicc-prompt>` | The name dialog `prompt()` opens. Not for direct use. |
@@ -207,11 +207,11 @@ Paths are the agent's: `/shared/…` and `/home/…`, nothing else. A refusal or
207
207
  | `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
208
  | `tray` | the tray protocol | Connection, role, float kind, followers, spend and budget; `reconnect` and `disconnect`. |
209
209
  | `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 "Needs Python", "Installs Python too", or "Needs Python (not installed)" on an installed row), 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), `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` 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. Without the port, the indicator and the Network panel don't show. |
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` 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) and a `state` (`connecting`, `connected` or `reconnecting`). 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. `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
211
  | `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
212
  | `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"]`). |
213
213
 
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`. `networkFixtures`, `tailnetFixtures` 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`.
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' })` (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 dock-builder, which can't; `exit` sends traffic through quay-studio; `reconnecting` holds traffic for pier-nuc; `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`.
215
215
 
216
216
  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
217
 
@@ -3993,21 +3993,105 @@ function tailnetFixtures(scenario) {
3993
3993
  }
3994
3994
  return { ...base, state: "off" };
3995
3995
  }
3996
+ var linkedDevices = [
3997
+ {
3998
+ id: "link-quay",
3999
+ name: "slicc on quay-studio",
4000
+ host: "quay-studio.slicc.internal",
4001
+ address: "198.18.57.11",
4002
+ mode: "local",
4003
+ offers: ["net", "http", "ssh"],
4004
+ policy: "every public address, this machine, and the local network",
4005
+ exit: true,
4006
+ state: "connected"
4007
+ },
4008
+ {
4009
+ id: "link-pier",
4010
+ name: "slicc on pier-nuc",
4011
+ host: "pier-nuc.slicc.internal",
4012
+ address: "198.18.57.12",
4013
+ mode: "remote",
4014
+ offers: ["net", "ssh"],
4015
+ policy: "every public address and the local network",
4016
+ exit: true,
4017
+ state: "connected"
4018
+ },
4019
+ {
4020
+ id: "link-dock",
4021
+ name: "slicc on dock-builder",
4022
+ host: "dock-builder.slicc.internal",
4023
+ address: "198.18.57.13",
4024
+ mode: "remote",
4025
+ offers: ["http"],
4026
+ policy: "this machine",
4027
+ exit: false,
4028
+ state: "connected"
4029
+ }
4030
+ ];
4031
+ function joinUrl(rotation = 0) {
4032
+ return `https://join.slicc.example/l/${rotation ? `r${rotation}-` : ""}7Hq2kd9FXw3m`;
4033
+ }
4034
+ function linksFixtures(scenario, rotation = 0) {
4035
+ const url = joinUrl(rotation);
4036
+ const base = { joinUrl: url, joinCommand: `npx sliccy ${url} follow`, permission: null };
4037
+ if (scenario === "preparing") {
4038
+ return { links: { ...base, joinUrl: null, joinCommand: null, devices: [] }, exit: null };
4039
+ }
4040
+ if (scenario === "none") return { links: { ...base, devices: [] }, exit: null };
4041
+ if (scenario === "prompt") {
4042
+ return { links: { ...base, devices: [], permission: "prompt" }, exit: null };
4043
+ }
4044
+ if (scenario === "denied") {
4045
+ return { links: { ...base, devices: [], permission: "denied" }, exit: null };
4046
+ }
4047
+ if (scenario === "reconnecting") {
4048
+ const devices = linkedDevices.map(
4049
+ (device) => device.id === "link-pier" ? { ...device, state: "reconnecting" } : device
4050
+ );
4051
+ return { links: { ...base, devices }, exit: { kind: "link", id: "link-pier" } };
4052
+ }
4053
+ return {
4054
+ links: { ...base, devices: linkedDevices },
4055
+ exit: scenario === "exit" ? { kind: "link", id: "link-quay" } : null
4056
+ };
4057
+ }
3996
4058
  var DummyNetwork = class extends Emitter {
3997
4059
  scenario;
3998
4060
  #base;
3999
4061
  #status;
4000
4062
  #clock;
4001
4063
  #tailnet;
4002
- constructor(scenario, clock2, tailnet) {
4064
+ #links;
4065
+ #exit = null;
4066
+ #rotation = 0;
4067
+ setExit;
4068
+ unlink;
4069
+ rotateJoinUrl;
4070
+ retryLinks;
4071
+ constructor(scenario, clock2, tailnet, links) {
4003
4072
  super();
4004
4073
  this.scenario = scenario;
4005
4074
  this.#clock = clock2;
4006
4075
  this.#tailnet = tailnet ? tailnetFixtures(tailnet) : void 0;
4076
+ if (links) {
4077
+ ({ links: this.#links, exit: this.#exit } = linksFixtures(links));
4078
+ this.setExit = (exit) => this.#setExit(exit);
4079
+ this.unlink = (id) => this.#unlink(id);
4080
+ this.rotateJoinUrl = () => this.#rotate();
4081
+ this.retryLinks = () => this.#retry();
4082
+ }
4007
4083
  this.#base = networkFixtures(scenario);
4008
4084
  this.#status = this.#with(this.#base);
4009
4085
  }
4010
4086
  #with(status) {
4087
+ const linked = this.#links ? { ...status, links: this.#links, exit: this.#exit } : status;
4088
+ const exit = this.#exit;
4089
+ const device = exit?.kind === "link" ? this.#links?.devices.find((item) => item.id === exit.id) : void 0;
4090
+ const routed = this.#withTailnet(linked);
4091
+ if (device?.state !== "connected") return routed;
4092
+ return { ...routed, health: "ok", detail: `${device.name} carries the internet traffic.` };
4093
+ }
4094
+ #withTailnet(status) {
4011
4095
  const tailnet = this.#tailnet;
4012
4096
  if (!tailnet) return status;
4013
4097
  const exit = tailnet.state === "running" ? tailnet.exitNode : null;
@@ -4022,9 +4106,57 @@ var DummyNetwork = class extends Emitter {
4022
4106
  }
4023
4107
  #set(tailnet) {
4024
4108
  this.#tailnet = tailnet;
4109
+ this.#emit();
4110
+ }
4111
+ #emit() {
4025
4112
  this.#status = this.#with(this.#base);
4026
4113
  this.emit("network", this.#status);
4027
4114
  }
4115
+ setLinks(scenario) {
4116
+ ({ links: this.#links, exit: this.#exit } = linksFixtures(scenario, this.#rotation));
4117
+ this.#emit();
4118
+ }
4119
+ async #setExit(exit) {
4120
+ await this.#clock.sleep(4);
4121
+ const links = this.#links;
4122
+ if (exit?.kind === "link") {
4123
+ const device = links.devices.find((item) => item.id === exit.id);
4124
+ if (!device?.exit || device.state !== "connected") {
4125
+ throw new Error("That device can\u2019t carry internet traffic right now.");
4126
+ }
4127
+ }
4128
+ if (this.#tailnet) {
4129
+ const node = exit?.kind === "tailnet" ? exit.node : null;
4130
+ await this.setExitNode(node);
4131
+ }
4132
+ this.#exit = exit;
4133
+ this.#emit();
4134
+ }
4135
+ async #unlink(id) {
4136
+ await this.#clock.sleep(4);
4137
+ const links = this.#links;
4138
+ this.#links = { ...links, devices: links.devices.filter((device) => device.id !== id) };
4139
+ if (this.#exit?.kind === "link" && this.#exit.id === id) this.#exit = null;
4140
+ this.#emit();
4141
+ }
4142
+ async #rotate() {
4143
+ await this.#clock.sleep(4);
4144
+ this.#rotation += 1;
4145
+ const url = joinUrl(this.#rotation);
4146
+ this.#links = {
4147
+ ...this.#links,
4148
+ joinUrl: url,
4149
+ joinCommand: `npx sliccy ${url} follow`
4150
+ };
4151
+ this.#emit();
4152
+ }
4153
+ async #retry() {
4154
+ await this.#clock.sleep(4);
4155
+ const links = this.#links;
4156
+ const devices = links.devices.length ? links.devices : linkedDevices.slice(0, 1);
4157
+ this.#links = { ...links, permission: null, devices };
4158
+ this.#emit();
4159
+ }
4028
4160
  status() {
4029
4161
  return this.#status;
4030
4162
  }
@@ -4776,6 +4908,7 @@ function createDummyModel({
4776
4908
  mounts = "off",
4777
4909
  network = "limited",
4778
4910
  tailnet,
4911
+ links,
4779
4912
  changes = "files",
4780
4913
  notices = "off",
4781
4914
  tabs: tabs2 = "off",
@@ -4826,7 +4959,7 @@ function createDummyModel({
4826
4959
  ...packages === "off" ? {} : { packages },
4827
4960
  confirm
4828
4961
  }),
4829
- ...network === "off" ? {} : { network: new DummyNetwork(network, time, tailnet) },
4962
+ ...network === "off" ? {} : { network: new DummyNetwork(network, time, tailnet, links) },
4830
4963
  ...changes === "files" ? {} : { changes: new DummyChanges(files3, changes === "git") },
4831
4964
  ...notices === "off" ? {} : { notices: new DummyNotices(notices, time) },
4832
4965
  ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) }
@@ -4842,6 +4975,8 @@ export {
4842
4975
  DummyUpdates,
4843
4976
  browserHistory,
4844
4977
  createDummyModel,
4978
+ joinUrl,
4979
+ linksFixtures,
4845
4980
  networkFixtures,
4846
4981
  noticeFixtures,
4847
4982
  packageFixtures,