/:section`, exactly as the core screens do
-(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
+(`/offscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.
diff --git a/db/schema.ts b/db/schema.ts
index fd60a35..a24aee9 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -8,7 +8,7 @@ import { users } from 'officerdb/auth/schema';
//
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
-// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
+// db/queries.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
// email/integrations tables are debt to avoid copying, not a precedent to follow.
//
diff --git a/manifest.ts b/manifest.ts
index b80a94f..2e62590 100644
--- a/manifest.ts
+++ b/manifest.ts
@@ -33,6 +33,22 @@ export const manifest: PluginManifest = {
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
// full control of the tailnet, which is the owner's decision to make deliberately.
+ // The Console shells into a machine by spawning `ssh` (sidecar/ssh.ts), and so does the reachability
+ // probe behind /ssh-test. This manifest said "Offscale needed nothing" until 2026-08-16, which meant a
+ // host without an ssh client passed the install checks and then failed at runtime with a spawn error —
+ // the one failure mode `osDependencies` exists to convert into a refusal at install time. PLUGIN.md had
+ // listed it as a host dependency all along; the manifest was the file that was wrong.
+ //
+ // Present on effectively every Linux install, which is exactly why it was missed: the declaration costs
+ // nothing on the machines that have it and is the whole difference on the machines that do not.
+ osDependencies: [
+ {
+ binary: 'ssh',
+ reason: 'Opens the Console shell to a machine, and probes reachability before you try.',
+ packages: { apt: 'openssh-client', pacman: 'openssh', dnf: 'openssh-clients', brew: 'openssh' },
+ },
+ ],
+
permissions: [
{
key: 'offscale',
diff --git a/web/Cards.tsx b/web/Cards.tsx
index a9f9fe5..bf6b331 100644
--- a/web/Cards.tsx
+++ b/web/Cards.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
-// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
+// Shared visual language for the /offscale panels, matching the /soulseek grouped views: almost-black
// cards on hairline white borders. Kept local to the app so the look changes in one place.
export const Card = ({ children }: { children: ReactNode }) => (
diff --git a/web/ConsoleView.tsx b/web/ConsoleView.tsx
index 333612a..487fcfe 100644
--- a/web/ConsoleView.tsx
+++ b/web/ConsoleView.tsx
@@ -19,7 +19,11 @@ import { Button } from './Cards';
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
// which is what stops a re-attach from typing a second `ssh` inside the first.
-const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
+// Renamed from `headscale-console-…` on 2026-08-16. This is a pty session KEY, not a label, so the rename
+// has a real one-time cost: a console open at the moment of the change keeps running in the sidecar under
+// the old key with nothing pointing at it, until that sidecar restarts. Paid once, deliberately, rather
+// than carrying the pre-extraction name in persisted state indefinitely.
+const consoleSessionId = (serverId: number) => `offscale-console-${serverId}`;
const Centred = ({ children }: { children: React.ReactNode }) => (
diff --git a/web/OffscaleNav.tsx b/web/OffscaleNav.tsx
index 9a29d22..de1fb29 100644
--- a/web/OffscaleNav.tsx
+++ b/web/OffscaleNav.tsx
@@ -4,10 +4,10 @@ import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, Ter
import { OFFSCALE_SECTIONS, offscaleSectionPath, type OffscaleSectionId } from './shared';
import { useOffscaleServers } from './useOffscaleServers';
-// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
+// Lower-left panel of the /offscale workspace: the section list. Which server it all acts on is the panel
// above (OffscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
//
-// Sections are real links to /headscale/, not channel writes — so they cmd-click into a new tab,
+// Sections are real links to /offscale/, not channel writes — so they cmd-click into a new tab,
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
// being derived in JS, per the navigation audit's Phase 4.
diff --git a/web/OffscaleServerPicker.tsx b/web/OffscaleServerPicker.tsx
index b66436d..9f430d0 100644
--- a/web/OffscaleServerPicker.tsx
+++ b/web/OffscaleServerPicker.tsx
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
import { offscaleSectionPath } from './shared';
import { useOffscaleServers } from './useOffscaleServers';
-// Top-left panel of the /headscale workspace: which server everything else acts on.
+// Top-left panel of the /offscale workspace: which server everything else acts on.
//
// It is its own panel rather than a block inside OffscaleNav because the two answer different questions —
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
@@ -23,7 +23,7 @@ export const OffscaleServerPicker = () => {
-
Headscale
+
Offscale
{active ? active.name : 'no server'}
diff --git a/web/OffscaleView.tsx b/web/OffscaleView.tsx
index d1a38ee..065d547 100644
--- a/web/OffscaleView.tsx
+++ b/web/OffscaleView.tsx
@@ -8,7 +8,7 @@ import { PolicyView } from './PolicyView';
import { DiagnosticsView } from './DiagnosticsView';
import { ConsoleView } from './ConsoleView';
-// Right panel of the /headscale workspace — renders the section named by the URL.
+// Right panel of the /offscale workspace — renders the section named by the URL.
//
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
diff --git a/web/panels.ts b/web/panels.ts
index afcb8fd..d81884a 100644
--- a/web/panels.ts
+++ b/web/panels.ts
@@ -12,15 +12,15 @@ import { OffscaleViewHeader } from './OffscaleViewHeader';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'offscale-servers',
- name: 'Headscale servers',
+ name: 'Offscale servers',
icon: Network,
component: OffscaleServerPicker,
availableOnPanel: false,
},
- { key: 'offscale-nav', name: 'Headscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
+ { key: 'offscale-nav', name: 'Offscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
{
key: 'offscale-view',
- name: 'Headscale',
+ name: 'Offscale',
icon: LayoutGrid,
component: OffscaleView,
header: OffscaleViewHeader,
diff --git a/web/shared.ts b/web/shared.ts
index e70d47f..00b4c49 100644
--- a/web/shared.ts
+++ b/web/shared.ts
@@ -1,4 +1,4 @@
-// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
+// Shared types/constants for the /offscale workspace panels. Everything here mirrors the wire shapes the
// officer-offscale sidecar returns under /api/offscale/_officer/* — deliberately NOT Headscale's own API
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
@@ -17,7 +17,7 @@ export const OFFSCALE_SECTIONS = [
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
-/** Where /headscale lands, and where an unrecognised section redirects to. */
+/** Where /offscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_OFFSCALE_SECTION: OffscaleSectionId = 'servers';
export const isOffscaleSection = (value: string | undefined): value is OffscaleSectionId =>
diff --git a/web/useOffscaleServers.ts b/web/useOffscaleServers.ts
index b9d56a4..2ef7138 100644
--- a/web/useOffscaleServers.ts
+++ b/web/useOffscaleServers.ts
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { OffscaleServer, OffscaleHealth, OffscaleSshTest } from './shared';
-// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
+// The registered-servers cache. Every panel in the /offscale workspace reads this one query, so switching
// the active server anywhere updates the whole screen at once.
//
// Registration is validated server-side before anything is saved (reachable, >=0.29, key accepted), which