the platform stops knowing what VNC is

Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and
apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem.

sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants
and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four
functions and a dozen message types in the platform, describing a feature that is not the platform's —
the rule CLAUDE.md states most plainly, and its clearest breach.

What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a
command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever
it likes through them and nothing here has to be edited when it adds a message.

The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind
a plugin permission cannot have and never will, because a permission nobody can delegate is a
contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied
to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not
exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the
key from its list.

Also gone: the app store's `vnc` entry, which would have offered an older second way to install the
same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13.

The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request".
It now uses the generic door, which is the better instrument — that file is about the blast radius of
a disconnect and never had anything to do with VNC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 00:42:19 +00:00
co-authored by Claude Opus 5
parent b1a2d67b76
commit fcdbf02649
20 changed files with 67 additions and 929 deletions
+37 -26
View File
@@ -9,7 +9,6 @@ import type {
LiveClaudeSession,
LiveOpenCodeSession,
OpenCodeRunParams,
VncStartParams,
} from './sidecar/protocol';
import type { SidecarRegistration } from './sidecar/registration-protocol';
import type { TurnMessage } from './api/chat/types';
@@ -170,10 +169,43 @@ export function on(eventType: string, handler: EventHandler): () => void {
}
// ── Command sending ──
//
// ── The generic door, for plugins ──
//
// `sendCommand` below is private and its `SidecarCommand` union names every message the PLATFORM knows
// about. That was fine while every sidecar was the platform's. A plugin's sidecar speaks a vocabulary
// this file must not learn — the desktop extraction is what made that concrete: `startVnc`,
// `ensureVncPassword` and a dozen `vnc:*` variants lived here, in the platform, describing a plugin.
//
// So a plugin drives its own sidecar through `sendSidecarCommand`, which knows only that a command has a
// type and an id and that a reply carries the same id. The vocabulary stays in the plugin, and nothing
// here has to be edited when a plugin adds a message.
const DEFAULT_TIMEOUT_MS = 30_000;
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
/** A message to a sidecar. `id` is what the reply is matched on; everything else is the plugin's. */
export type PluginSidecarCommand = { type: string; id: string } & Record<string, unknown>;
/**
* Send a command to whichever sidecar handles `kind`, and wait for the reply with the same id.
*
* The plugin-facing half of `sendCommand`. Rejects when no sidecar handles that kind — which for a
* plugin means its own process is not running, and is a better error than a timeout.
*/
export function sendSidecarCommand<T = unknown>(
kind: string,
cmd: PluginSidecarCommand,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<T> {
return sendCommand(kind, cmd as unknown as SidecarCommand, timeoutMs) as Promise<T>;
}
/** Is a sidecar handling `kind` connected right now? */
export function isSidecarConnected(kind: string): boolean {
return !!findSidecarHandling(kind);
}
function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
return new Promise((resolve, reject) => {
const sc = findSidecarHandling(kind);
@@ -482,28 +514,7 @@ export function onOpenCodeSession(handler: (sessionKey: string, sessionId: strin
// Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and
// `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar.
// ── VNC ──
export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> {
const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params });
if (res.type === 'vnc:started') return { port: res.port, display: res.display };
if (res.type === 'vnc:error') throw new Error(res.error);
throw new Error('Unexpected response');
}
// Provision the VNC password without starting a server. The desktop UI needs it before it can open
// the WebSocket that would start one, so asking vnc:start here would be circular.
export async function ensureVncPassword(email: string): Promise<string> {
const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email });
if (res.type === 'vnc:password') return res.password;
if (res.type === 'vnc:error') throw new Error(res.error);
throw new Error('Unexpected response from VNC sidecar');
}
export function stopVnc(email: string): void {
sendFire('vnc', { type: 'vnc:stop', id: nextId(), email });
}
export function isVncConnected(): boolean {
return findSidecarHandling('vnc') !== undefined;
}
// The VNC helpers that were here — startVnc, ensureVncPassword, stopVnc, isVncConnected — left with the
// desktop plugin on 2026-08-21. They were the clearest example of the platform knowing what a plugin was
// about: four functions and a dozen `vnc:*` message types describing a feature that is not the
// platform's. What replaced them is `sendSidecarCommand` above, which is generic.