Private Linux desktops for Claude: test GUI apps in a headless labwc realm or an Omarchy VM, and watch them live in a pane

Private Linux desktops for Claude. When Claude needs to run and test a GUI app, it gets a desktop of its own, a lightweight realm or a full Omarchy VM, and never touches your screen, mouse or clipboard. You watch it live next to the conversation and can take over at any time.

claude plugin marketplace add Zeus-Deus/claude-realms && claude plugin install realms@realms
Restart Claude Code, then ask Claude to test something with a window. It sets itself up on first use. If your system is missing a package, Claude tells you exactly what to install (the ready-to-run command on Arch and Omarchy).
claude plugin uninstall realms@realms && claude plugin marketplace remove realms
This removes the plugin and everything it stored: realm homes, VM disks and the computer-use driver.
Installing adds four things to Claude Code:
realm tools and the computer-use tools (look, click, type) that act inside the realm/realm command, so you can watch and take overOn first use it sets up what it needs by itself: its Python environment, the computer-use driver and, for the VM, the Omarchy image. All of it lives in the plugin's own data folder, which the uninstall command removes.
labwc xorg-xwayland wayvnc grim wlr-randr glib2 dbus at-spi2-core bubblewrapqemu-full edk2-ovmf mtools openssh socat jq, KVM, a systemd user session and an existing ~/.ssh/id_ed25519. The VM image builds itself on first use (it reuses one that hermes-realms already built, or downloads the signed Omarchy ISO, about 5 GB).The Realm pane opens by itself when a realm starts. How sharp it looks depends on your terminal:
| Terminal | Realm pane |
|---|---|
| Ghostty, kitty | Sharp, full-resolution picture |
| Codemux | Opens in a Codemux browser pane (sharp) |
| Everything else (foot, Alacritty, WezTerm, GNOME Terminal, tmux, herdr, ...) | Low-resolution preview: you see the layout, not readable text |
Anywhere, Full view (f in the pane) opens the live desktop sharp in your browser. Take control (t) lets you click and type in it yourself. The ◈ realm live button at the right of the prompt footer shows or hides the pane.
| Command | |
|---|---|
/realm | status of this session's realm |
/realm view / /realm full | show the pane / open it in your browser |
/realm on [omarchy] / /realm stop | start a realm or the Omarchy VM / stop it |
/realm list | every realm and VM disk kept on this machine, with sizes |
/realm delete ID / /realm clean | delete one stopped realm / all stopped ones |
/realm help | everything else |
A realm stops when its session ends or after 30 idle minutes, and comes back with its files on the next start. Stopped ones unused for 30 days are deleted automatically. Claude can list them, but only you can delete them.
This is the Claude Code port of hermes-realms.
MIT. Vendored noVNC, pako and omarchy-vm keep their own licenses (see src/realms_core/web/THIRD_PARTY.md and src/realms_core/vendor/VENDOR.md).
hooks/register.tsx 808 lines1// claude-realms: this session's private Linux desktop, live in a pane.
2//
3// The realm itself (labwc desktop or Omarchy VM) and the agent's desktop
4// tools live in the plugin's MCP server. This module is the person's side of
5// it: the /realm command, a pane that streams the realm's screen (pixels in
6// kitty/Ghostty, half-block cells in any other terminal), taking control of
7// the desktop, a status line, and a guard that keeps Bash commands from
8// re-pointing GUI tools at the person's own screen while a realm is in use.
9
10import type { EngineInterface, PluginOptions, Register } from 'claude-code'
11import { hostEscape } from './guard'
12
13type $ = EngineInterface
14
15const PANE = 'realm'
16const SERVER = 'realms'
17const STATUS_POLL_MS = 3000
18const INPUT_HISTORY = 128
19
20type Live = { id: string; kind: string; size?: string | null; memory_mb?: number | null; vnc_socket?: string | null }
21type Job = { state: string; log: string[]; error?: string }
22type Status = {
23 enabled: boolean
24 kind: string
25 live: Live | null
26 setup: { ready: boolean; message: string; install_command?: string | null; steps: string[] }
27 frames_argv: string[]
28 input_path?: string
29 controlled?: boolean
30 jobs?: Record<string, Job>
31 events?: { at: number; text: string }[]
32 driver_tools?: number
33}
34type Picture =
35 | { kind: 'image'; source: { file: string; format: 'rgb'; width: number; height: number; generation: number } }
36 | { kind: 'raster'; columns: number; rows: number; cells: string }
37type Mode = 'image' | 'raster'
38type Control = { pid: number; socket: string; python: string; client: string; ancestors: { pid: number }[] }
39
40// The plugin's server: its tools' name prefix as Claude Code lists them, and
41// how the mod reaches it for the person's own actions
42const toolPrefix = 'mcp__plugin_realms_realms__'
43let control: Control | null = null
44let connectError: string | null = null
45let status: Status | null = null
46let poller: { cancel: () => void } | null = null
47
48// The pane and its stream
49let isPaneOpen = false
50let isDismissed = false
51let stream: AsyncGenerator<unknown, unknown, unknown> | null = null
52let streamGeneration = 0
53let streamSocket: string | null = null
54let streamBox = { columns: 0, rows: 0 }
55let mode: Mode = 'image'
56let modeLocked = false
57let picture: Picture | null = null
58let framebuffer = { width: 1920, height: 1080 }
59let viewError: string | null = null
60let box = { columns: 80, rows: 22 }
61let restartTimer: { cancel: () => void } | null = null
62
63// Taking control
64let isControlled = false
65let inputEvents: { [key: string]: unknown }[] = []
66let inputId = 0
67let inputEpoch = ''
68
69
70// Setup jobs the server is running, to report how they ended
71let fastPoller: { cancel: () => void } | null = null
72let callsInFlight = 0
73let runningJobs = new Set<string>()
74
75// The terminal: Codemux (its browser pane hosts the full view) and the shape
76// of a text cell (height / width), which keeps the realm's 16:9 picture 16:9.
77let inCodemux = false
78let cellAspect = 2.1
79// The realm a Codemux browser pane already shows, so it opens once per realm
80let browserShownFor: string | null = null
81
82// Where the realm is watched: the terminal pane, or (in Codemux, whose
83// terminal draws no pictures) a Codemux browser pane at full resolution.
84function viewsInBrowser(): boolean {
85 const where = option<string>('view_in', 'auto')
86 return where === 'browser' || (where === 'auto' && inCodemux)
87}
88
89let options: PluginOptions = {}
90
91function option<T extends string | number | boolean>(name: string, fallback: T): T {
92 const value = options[name]
93 return (typeof value === typeof fallback ? value : fallback) as T
94}
95
96// ------------------------------------------------------------------ server
97//
98// The person's actions reach the plugin's server over its private control
99// socket, not through $.mcp: Claude Code routes a plugin's own MCP calls
100// through the tool permission flow, which would ask the person to approve
101// their own clicks. The server publishes, under XDG_RUNTIME_DIR, which
102// processes it descends from; the one below this Claude Code process is ours.
103
104async function locate($: $): Promise<Control | null> {
105 if (control && (await $.fs.exists(control.socket))) return control
106 control = null
107 const probe = await $.process.run(['/bin/sh', '-c', 'echo "$PPID $(id -u)"'])
108 const [pid, uid] = probe.stdout.trim().split(' ').map(Number)
109 const runtime = (await $.env.get('XDG_RUNTIME_DIR')) || '/tmp'
110 for (const base of [runtime, '/tmp']) {
111 const directory = base + '/claude-realms-' + uid
112 if (!(await $.fs.exists(directory))) continue
113 const entries = await $.fs.list(directory)
114 const candidates: Control[] = []
115 for (const entry of entries) {
116 if (!entry.name.endsWith('.json')) continue
117 try {
118 const data = JSON.parse(await $.fs.read(directory + '/' + entry.name)) as Control
119 if (data.ancestors.some((process) => process.pid === pid) && (await $.fs.exists(data.socket))) candidates.push(data)
120 } catch {}
121 }
122 candidates.sort((a, b) => b.pid - a.pid)
123 if (candidates[0]) {
124 control = candidates[0]
125 connectError = null
126 return control
127 }
128 }
129 connectError = 'the realms server is not running (still starting, or it stopped: /mcp shows it and can reconnect plugin:realms:realms)'
130 return null
131}
132
133const SLOW = new Set(['on', 'setup', 'doctor', 'shot', 'driver', 'push', 'pull', 'stop', 'off'])
134
135async function realm($: $, args: Record<string, unknown>): Promise<{ text: string; data: unknown; isError: boolean }> {
136 const found = await locate($)
137 if (!found) return { text: 'Realms: ' + connectError, data: null, isError: true }
138 const timeoutMs = SLOW.has(String(args.action)) ? 10 * 60 * 1000 : 30 * 1000
139 const result = await $.process.run([found.python, '-I', '-S', found.client, found.socket, JSON.stringify(args)], { timeoutMs })
140 let reply: { texts?: string[]; isError?: boolean; unreachable?: boolean } = {}
141 try {
142 reply = JSON.parse(result.stdout)
143 } catch {
144 return { text: 'Realms: no reply from the server ' + result.stderr.trim(), data: null, isError: true }
145 }
146 if (reply.unreachable) control = null
147 const [first = '', second] = reply.texts ?? []
148 let data: unknown = null
149 if (second !== undefined) {
150 try {
151 data = JSON.parse(second)
152 } catch {
153 data = second
154 }
155 }
156 return { text: first, data, isError: Boolean(reply.isError) }
157}
158
159// One status request at a time: polls that pile up behind a slow reply would
160// tie up the server's worker threads that the agent's own calls need.
161let refreshing: Promise<Status | null> | null = null
162
163function refresh($: $): Promise<Status | null> {
164 if (!refreshing) refreshing = refreshOnce($).finally(() => { refreshing = null })
165 return refreshing
166}
167
168async function refreshOnce($: $): Promise<Status | null> {
169 try {
170 const { data, isError } = await realm($, { action: 'status' })
171 if (!isError && data && typeof data === 'object') status = data as Status
172 } catch (error) {
173 $.ui.log('claude-realms: status failed: ' + String(error), { to: 'debug' })
174 }
175 isControlled = status?.controlled ?? isControlled
176 showStatusLine($)
177 await followJobs($)
178 await syncStream($)
179 return status
180}
181
182function showStatusLine($: $) {
183 // The footer toggle (SessionMode) shows the realm's state; it redraws here.
184 $.ui.invalidate('ui.render')
185}
186
187// The realm at full resolution in the browser (noVNC), with control.
188async function openFullView($: $) {
189 const { text, data, isError } = await realm($, { action: 'watch', control: true })
190 const url = (data as { url?: string } | null)?.url
191 if (isError || !url) {
192 $.ui.toast(text)
193 return
194 }
195 const where = option<string>('full_view', 'auto')
196 if ((where === 'auto' && inCodemux) || where === 'codemux') {
197 // Codemux's own browser pane: full resolution, beside the conversation.
198 let pane = await $.process.run(['codemux', 'browser', 'open', url], { timeoutMs: 15000 }).catch(() => ({ exitCode: 1 }))
199 if (pane.exitCode !== 0) {
200 // No browser pane yet in this workspace: make one, then show the realm.
201 await $.process.run(['codemux', 'browser', 'create'], { timeoutMs: 15000 }).catch(() => undefined)
202 pane = await $.process.run(['codemux', 'browser', 'open', url], { timeoutMs: 15000 }).catch(() => ({ exitCode: 1 }))
203 }
204 if (pane.exitCode === 0) {
205 browserShownFor = status?.live?.id ?? null
206 $.ui.toast('The realm is open in a Codemux browser pane')
207 return
208 }
209 }
210 const opened = await $.process.run(['xdg-open', url], { timeoutMs: 10000 }).catch(() => ({ exitCode: 1 }))
211 if (opened.exitCode === 0) {
212 $.ui.toast('Full view opened in your browser')
213 } else {
214 await $.ui.copy({ text: url }).catch(() => undefined)
215 $.ui.toast('Full view link copied to the clipboard')
216 }
217}
218
219async function togglePane($: $) {
220 if (isPaneOpen) await closePane($)
221 else if (viewsInBrowser() && status?.live) await openFullView($)
222 else await openPane($, { asked: true })
223}
224
225function startPolling($: $) {
226 if (poller) return
227 poller = $.clock.every(STATUS_POLL_MS, () => {
228 if (!isPaneOpen && !status?.live) {
229 poller?.cancel()
230 poller = null
231 return
232 }
233 void refresh($)
234 })
235}
236
237// ------------------------------------------------------------------ stream
238
239function isOurTool(tool: string): boolean {
240 return tool.startsWith(toolPrefix)
241}
242
243async function syncStream($: $) {
244 const socket = isPaneOpen ? status?.live?.vnc_socket ?? null : null
245 if (!socket) {
246 if (stream) await stopStream($)
247 if (!status?.live) picture = null
248 return
249 }
250 if (stream && streamSocket === socket) return
251 await stopStream($)
252 void runStream($, socket)
253}
254
255async function stopStream($: $) {
256 streamGeneration += 1
257 const current = stream
258 stream = null
259 streamSocket = null
260 restartTimer?.cancel()
261 restartTimer = null
262 // Ending the stream's loop ends the viewer process. Not awaited: a stream
263 // is only interruptible at its next piece, and the generation check above
264 // already drops anything a stopped stream still says.
265 if (current) void current.return(undefined).catch(() => undefined)
266 void $
267}
268
269function restartStream($: $) {
270 restartTimer?.cancel()
271 restartTimer = $.clock.after(50, async () => {
272 restartTimer = null
273 picture = null
274 await stopStream($)
275 await syncStream($)
276 $.ui.invalidate('ui.render')
277 })
278}
279
280function setMode($: $, next: Mode, why?: string) {
281 if (mode === next) return
282 mode = next
283 if (why) $.ui.log('claude-realms: live view switched to ' + next + ': ' + why, { to: 'debug' })
284 restartStream($)
285}
286
287async function runStream($: $, socket: string) {
288 if (!status) return
289 const generation = ++streamGeneration
290 const requested = { ...box }
291 const argv = [
292 ...status.frames_argv,
293 '--socket', socket,
294 '--mode', mode,
295 '--columns', String(requested.columns),
296 '--rows', String(requested.rows),
297 '--fps', String(option('view_fps', 10)),
298 ...(status.input_path ? ['--input', status.input_path] : []),
299 ]
300 const child = $.process.spawn({ argv }) as unknown as AsyncGenerator<{ stream: string; text: string }, unknown, unknown>
301 stream = child
302 streamSocket = socket
303 streamBox = requested
304 viewError = null
305 let pending = ''
306 try {
307 for await (const { stream: pipe, text } of child) {
308 if (generation !== streamGeneration) break
309 if (pipe !== 'stdout') continue
310 const lines = (pending + text).split('\n')
311 pending = lines.pop() ?? ''
312 for (const line of lines) await onLine($, line)
313 }
314 } catch (error) {
315 viewError = 'the live view stopped: ' + String(error)
316 } finally {
317 if (stream === child) {
318 stream = null
319 streamSocket = null
320 }
321 }
322 if (generation === streamGeneration && isPaneOpen) $.ui.invalidate('ui.render')
323}
324
325async function onLine($: $, line: string) {
326 if (line.startsWith('@hello ')) {
327 const hello = JSON.parse(line.slice(7)) as { width: number; height: number }
328 framebuffer = { width: hello.width, height: hello.height }
329 $.ui.invalidate('ui.render')
330 return
331 }
332 if (line.startsWith('@size ')) {
333 const [, w, h] = line.split(' ')
334 framebuffer = { width: Number(w), height: Number(h) }
335 restartStream($)
336 return
337 }
338 if (line.startsWith('@error ')) {
339 viewError = line.slice(7)
340 $.ui.invalidate('ui.render')
341 return
342 }
343 const frame = /^@file (\S+) (\d+) (\d+) (\d+)$/.exec(line)
344 if (frame) {
345 const source = { file: frame[1]!, format: 'rgb' as const, width: Number(frame[2]), height: Number(frame[3]), generation: Number(frame[4]) }
346 if (picture?.kind !== 'image') {
347 picture = { kind: 'image', source }
348 $.ui.invalidate('ui.render')
349 return
350 }
351 picture = { kind: 'image', source }
352 const result = await $.ui.blit({ requestId: PANE, key: 'view', source })
353 // Only refusals that mean "this terminal shows no pictures" switch to
354 // coloured cells; others (a redraw in flight, a busy surface) pass.
355 if (result.deny && /draws no placeholder images|cannot read files on this machine|8-bit image id/.test(result.deny)) {
356 if (!modeLocked) setMode($, 'raster', result.deny)
357 }
358 return
359 }
360 const raster = /^@raster (\d+) (\d+) (\S+)$/.exec(line)
361 if (raster) {
362 const columns = Number(raster[1])
363 const rows = Number(raster[2])
364 const cells = raster[3]!
365 if (columns !== box.columns || rows !== box.rows) {
366 restartStream($)
367 return
368 }
369 if (picture?.kind !== 'raster' || picture.columns !== columns || picture.rows !== rows) {
370 picture = { kind: 'raster', columns, rows, cells }
371 $.ui.invalidate('ui.render')
372 return
373 }
374 picture = { kind: 'raster', columns, rows, cells }
375 const result = await $.ui.blit({ requestId: PANE, key: 'view', cells })
376 if (result.deny) restartStream($)
377 }
378}
379
380// Fit the realm's aspect into the pane, knowing how tall a text cell is
381// against its width (about 2 in kitty and Ghostty, 2.4 in Codemux).
382function fit(bodyColumns: number, viewportRows: number, maxColumns: number, maxRows: number) {
383 const room = Math.max(6, Math.min(maxRows, viewportRows - 8))
384 let columns = Math.max(16, Math.min(maxColumns, bodyColumns))
385 let rows = Math.max(4, Math.round((columns * framebuffer.height) / framebuffer.width / cellAspect))
386 if (rows > room) {
387 rows = room
388 columns = Math.max(16, Math.min(columns, Math.round((rows * cellAspect * framebuffer.width) / framebuffer.height)))
389 }
390 return { columns, rows }
391}
392
393// ------------------------------------------------------------------ setup
394
395function startFastPolling($: $) {
396 if (fastPoller) return
397 fastPoller = $.clock.every(500, () => {
398 if (callsInFlight === 0 && runningJobs.size === 0) {
399 fastPoller?.cancel()
400 fastPoller = null
401 return
402 }
403 void refresh($)
404 })
405}
406
407// Follow the server's setup jobs and say how they ended.
408async function followJobs($: $) {
409 const jobs = status?.jobs ?? {}
410 const running = new Set(Object.entries(jobs).filter(([, job]) => job.state === 'running').map(([name]) => name))
411 if (running.size) startFastPolling($)
412 for (const name of runningJobs) {
413 if (running.has(name)) continue
414 const job = jobs[name]
415 if (job?.state === 'failed') $.ui.toast('Realm setup failed: ' + (job.error ?? 'unknown error'))
416 }
417 runningJobs = running
418}
419
420// ------------------------------------------------------------------- pane
421
422async function openPane($: $, { asked }: { asked: boolean }) {
423 const opened = await $.ui.open({ id: PANE, title: 'Realm' })
424 if (!opened.isPlaced && !asked) {
425 // Opened unasked in a narrow terminal it would pop up later; offer instead.
426 await $.ui.close({ id: PANE })
427 $.ui.toast('The realm is live · /realm view to watch it')
428 return false
429 }
430 isPaneOpen = true
431 isDismissed = false
432 startPolling($)
433 await refresh($)
434 return true
435}
436
437async function closePane($: $) {
438 await $.ui.close({ id: PANE })
439}
440
441async function takeControl($: $, held: boolean) {
442 const { isError, text } = await realm($, { action: 'control', control: held })
443 if (isError) {
444 $.ui.toast(text)
445 return
446 }
447 isControlled = held
448 inputEvents = []
449 inputId = 0
450 // A fresh epoch tells the viewer that ids start over for this takeover.
451 inputEpoch = held ? String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8) : ''
452 if (held) {
453 await $.ui.focus({ requestId: PANE, key: 'takeover' }).catch(() => undefined)
454 }
455 showStatusLine($)
456 $.ui.invalidate('ui.render')
457}
458
459async function writeInput($: $, events: { [key: string]: unknown }[]) {
460 if (!status?.input_path || !isControlled) return
461 for (const event of events) inputEvents.push({ ...event, id: ++inputId })
462 inputEvents = inputEvents.slice(-INPUT_HISTORY)
463 await $.fs.write(status.input_path, JSON.stringify({ epoch: inputEpoch, events: inputEvents }))
464}
465
466// ---------------------------------------------------------------- command
467
468const USAGE = [
469 '/realm status of this session\'s realm',
470 '/realm view [image|raster] watch it live in a pane',
471 '/realm on [omarchy] start it (regular realm, or the Omarchy VM)',
472 '/realm off stop it and turn agent use off for this session',
473 '/realm stop power it down, keep its home',
474 '/realm control take or hand back control of its desktop',
475 '/realm full open it full size in your browser (noVNC)',
476 '/realm watch copy that browser link instead',
477 '/realm setup [omarchy] install what it needs',
478 '/realm size WxH resize the regular realm',
479 '/realm launch CMD start a program on the realm desktop',
480 '/realm repair reconnect the desktop driver, keep the desktop',
481 '/realm list realms and VM disks kept on this machine, with sizes',
482 '/realm delete ID delete one stopped realm or VM disk',
483 '/realm clean delete every stopped realm except this session\'s',
484 '/realm driver [check|update|rollback]',
485 '/realm doctor',
486].join('\n')
487
488function describeSetup(data: unknown, started: boolean): string {
489 const setup = (data as { setup?: Status['setup'] } | null)?.setup
490 if (!setup) return ''
491 // Once the setup job runs, its own progress (in /realm status) says the rest.
492 const lines = started ? [] : [setup.message]
493 if (setup.install_command) lines.push('Run: ! ' + setup.install_command)
494 return lines.join('\n')
495}
496
497async function command($: $, raw: string): Promise<string> {
498 const [verb = 'status', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
499 switch (verb) {
500 case 'help':
501 return USAGE
502 case 'status': {
503 const { text, data, isError } = await realm($, { action: 'status' })
504 if (!isError && data) status = data as Status
505 showStatusLine($)
506 const jobs = Object.entries(status?.jobs ?? {})
507 .map(([name, job]) => ' ' + name + ': ' + job.state + (job.error ? ' (' + job.error + ')' : job.log.length ? ' · ' + job.log.at(-1) : ''))
508 .join('\n')
509 const events = (status?.events ?? []).map((event) => ' ' + event.text).join('\n')
510 return [text, jobs && 'Setup jobs:\n' + jobs, events && 'Recent:\n' + events, '/realm help lists commands.'].filter(Boolean).join('\n')
511 }
512 case 'view': {
513 if (rest[0] === 'image' || rest[0] === 'raster') {
514 modeLocked = true
515 setMode($, rest[0])
516 } else if (rest[0] === 'auto') {
517 modeLocked = false
518 }
519 if (viewsInBrowser() && !rest[0] && status?.live) {
520 await openFullView($)
521 return 'Watching ' + status.live.id + ' at full resolution in a Codemux browser pane. (/realm view raster shows the small cell preview here.)'
522 }
523 await openPane($, { asked: true })
524 return status?.live ? 'Watching ' + status.live.id + ' in the Realm pane.' : 'Realm pane open; nothing is running yet.'
525 }
526 case 'hide':
527 await closePane($)
528 return 'Realm pane closed.'
529 case 'on': {
530 const kind = rest[0] === 'omarchy' || rest[0] === 'vm' ? 'omarchy' : 'realm'
531 const { text, isError } = await realm($, { action: 'on', kind })
532 await refresh($)
533 if (!isError) {
534 if (viewsInBrowser() && status?.live) await openFullView($)
535 else await openPane($, { asked: true })
536 }
537 return text
538 }
539 case 'off':
540 case 'stop': {
541 const { text } = await realm($, { action: verb })
542 if (isControlled) isControlled = false
543 await refresh($)
544 return text
545 }
546 case 'control':
547 await takeControl($, !isControlled)
548 return isControlled ? 'You hold the realm desktop; the agent waits. /realm control hands it back.' : 'The agent has the realm desktop again.'
549 case 'full':
550 await openFullView($)
551 return 'Opening the full view…'
552 case 'watch': {
553 const { text, data, isError } = await realm($, { action: 'watch', control: rest[0] === 'control' })
554 const url = (data as { url?: string } | null)?.url
555 if (!isError && url) {
556 const copied = await $.ui.copy({ text: url }).catch(() => ({ isCopied: false }))
557 return text + ((copied as { isCopied?: boolean }).isCopied ? '\n(link copied to the clipboard)' : '')
558 }
559 return text
560 }
561 case 'setup': {
562 const { text, data } = await realm($, { action: 'setup', ...(rest[0] ? { kind: rest[0] === 'omarchy' ? 'omarchy' : 'realm' } : {}) })
563 startPolling($)
564 await refresh($)
565 const started = ((data as { started?: string[] } | null)?.started ?? []).length > 0
566 return [text, describeSetup(data, started)].filter((line, index, all) => line && all.indexOf(line) === index).join('\n')
567 }
568 case 'size': {
569 const { text } = await realm($, { action: 'size', size: rest[0] ?? '' })
570 await refresh($)
571 return text
572 }
573 case 'list': {
574 const { text } = await realm($, { action: 'list' })
575 return text
576 }
577 case 'delete': {
578 if (!rest[0]) return 'Usage: /realm delete ID (see /realm list)'
579 const { text } = await realm($, { action: 'delete', id: rest[0] })
580 return text
581 }
582 case 'clean': {
583 const { text } = await realm($, { action: 'clean' })
584 return text
585 }
586 case 'repair': {
587 const { text } = await realm($, { action: 'repair' })
588 return text
589 }
590 case 'launch': {
591 const { text } = await realm($, { action: 'launch', command: rest.join(' ') })
592 return text
593 }
594 case 'driver': {
595 const { text } = await realm($, { action: 'driver', operation: rest[0] ?? 'status' })
596 return text
597 }
598 case 'doctor': {
599 const { text } = await realm($, { action: 'doctor' })
600 return text
601 }
602 case 'shot': {
603 const { text, isError } = await realm($, { action: 'shot' })
604 return isError ? text : 'Captured the realm screen (the agent can see it with realm shot).'
605 }
606 default:
607 return 'Unknown: /realm ' + raw + '\n' + USAGE
608 }
609}
610
611// --------------------------------------------------------------- register
612
613export const register: Register = (on, pluginOptions) => {
614 options = pluginOptions
615 mode = option<string>('view_mode', 'auto') === 'raster' ? 'raster' : 'image'
616 modeLocked = option<string>('view_mode', 'auto') !== 'auto'
617
618 on('session.start', async ($, e, next) => {
619 // Codemux marks its terminal panes (CODEMUX=1, TERM_PROGRAM=codemux). Its
620 // terminal is xterm.js, which shows no pictures, so the realm's view there
621 // is a Codemux browser pane instead.
622 inCodemux = (await $.env.get('CODEMUX')) === '1' || (await $.env.get('TERM_PROGRAM')) === 'codemux'
623 || Boolean(await $.env.get('CODEMUX_PANE_ID'))
624 const aspect = option<number>('view_cell_aspect', 0)
625 cellAspect = aspect >= 1 && aspect <= 4 ? aspect : inCodemux ? 2.4 : 2.1
626 await $.command.register({
627 name: 'realm',
628 description: "This session's private Linux desktop: status, view, on/off, control, setup",
629 argumentHint: 'view | on [omarchy] | off | stop | control | watch | list | delete ID | clean | setup [omarchy] | launch CMD | driver | doctor',
630 })
631 void (async () => {
632 // The server starts with the session; give it a moment to publish itself.
633 for (let attempt = 0; attempt < 20 && !(await locate($)); attempt++) await $.clock.sleep(500)
634 await refresh($)
635 if (status?.live) startPolling($)
636 })()
637 return next(e)
638 })
639
640 on('command.run', { command: 'realm' }, async ($, e) => {
641 try {
642 return { text: await command($, e.args) }
643 } catch (error) {
644 return { text: 'claude-realms: ' + String(error) }
645 }
646 })
647
648 // The agent used the realm: pick up the new state, and show the desktop the
649 // first time it goes live unless the person closed the pane this session.
650 on('tool.call', async ($, e, next) => {
651 if (!isOurTool(e.tool)) return next(e)
652 // Only Claude Code knows which loop made a call: tell the server when a
653 // subagent did, so one that asked for its own realm gets it.
654 const agentId = (e as { agentId?: string }).agentId
655 const wasLive = Boolean(status?.live)
656 callsInFlight++
657 startFastPolling($)
658 let result
659 try {
660 result = await next(agentId ? ({ ...e, _agent: agentId } as typeof e) : e)
661 } finally {
662 callsInFlight--
663 }
664 await refresh($)
665 if (!wasLive && status?.live && !isDismissed && option('auto_view', true)) {
666 if (viewsInBrowser()) {
667 if (browserShownFor !== status.live.id) void openFullView($)
668 } else if (!isPaneOpen) {
669 void openPane($, { asked: false })
670 }
671 }
672 if (status?.live) startPolling($)
673 return result
674 })
675
676 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
677 if (!status?.live || !option('host_guard', true)) return next(e)
678 const refusal = hostEscape((e as { command?: unknown }).command)
679 return refusal ? { deny: refusal } : next(e)
680 })
681
682 on('ui.close', async ($, e, next) => {
683 if (e.id !== PANE) return next(e)
684 isPaneOpen = false
685 if (e.origin?.kind === 'person') isDismissed = true
686 if (isControlled) await takeControl($, false)
687 await stopStream($)
688 picture = null
689 return next(e)
690 })
691
692 on('ui.message', async ($, e, next) => {
693 if (e.element !== 'takeover') return next(e)
694 const data = e.data as { events?: { [key: string]: unknown }[] } | null
695 if (data?.events?.length) await writeInput($, data.events)
696 return {}
697 })
698
699 on('session.end', async ($, e, next) => {
700 poller?.cancel()
701 poller = null
702 await stopStream($)
703 return next(e)
704 })
705
706 // A toggle at the right of the prompt footer while a realm is live: one
707 // press opens the Realm pane, another closes it.
708 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
709 const live = status?.live
710 if (!live && !isPaneOpen) return next(e)
711 const engine = await next(e)
712 const { Box, Button } = $.ui.resolve(e)
713 const label = (isPaneOpen ? '◉ ' : '◈ ') + (live ? (live.kind === 'omarchy-vm' ? 'omarchy realm' : 'realm') + (isControlled ? ' · yours' : ' live') : 'realm')
714 return (
715 <Box flexDirection="row" gap={2}>
716 {engine}
717 <Button key="realm-toggle" plain label={label} onPress={() => togglePane($)} />
718 </Box>
719 )
720 })
721
722 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
723 const { Box, Text, Button } = $.ui.resolve(e)
724 const live = status?.live
725 const header = live ? (
726 <Text bold>
727 {live.kind === 'omarchy-vm' ? 'Omarchy VM ' : 'Realm '}
728 {live.id}
729 <Text dimColor>{' ' + (live.size ?? (live.memory_mb ? live.memory_mb + ' MB' : '')) + (isControlled ? ' · you have control' : ' · agent has control')}</Text>
730 </Text>
731 ) : (
732 <Text bold>Realm</Text>
733 )
734 const controls = (
735 <Box flexDirection="row" gap={1}>
736 {live && (
737 <Button key="control" hotkey="t" label={isControlled ? 'Hand back' : 'Take control'} variant={isControlled ? 'primary' : undefined} onPress={() => takeControl($, !isControlled)} />
738 )}
739 {live && <Button key="full" hotkey="f" label="Full view" onPress={() => openFullView($)} />}
740 {live && <Button key="stop" hotkey="x" label="Stop" onPress={() => command($, 'stop').then(() => undefined)} />}
741 {!live && <Button key="start" hotkey="s" label="Start realm" onPress={() => command($, 'on').then((text) => $.ui.toast(text))} />}
742 <Button key="hide" role="dismiss" label="Close" onPress={() => closePane($)} />
743 </Box>
744 )
745
746 if (!live) {
747 const setup = status?.setup
748 return (
749 <Box flexDirection="column" gap={1}>
750 {header}
751 <Text>{connectError ? 'The realms server is not connected: ' + connectError : !status ? 'Connecting…' : !status.enabled ? 'Realm use is off for this session.' : setup && !setup.ready ? setup.message : 'No realm is running. It starts when the agent first uses a desktop tool.'}</Text>
752 {setup?.install_command && <Text dimColor>{'Run: ! ' + setup.install_command}</Text>}
753 {controls}
754 </Box>
755 )
756 }
757
758 if (e.surface !== 'terminal') {
759 return (
760 <Box flexDirection="column" gap={1}>
761 {header}
762 <Text>The live picture draws in the terminal. Full view opens it in your browser.</Text>
763 {controls}
764 </Box>
765 )
766 }
767
768 const { Image, Raster, Client } = $.ui.resolve(e)
769 const viewportRows = e.viewport?.rows ?? 40
770 const bodyColumns = (e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 80
771 const target = mode === 'image' ? fit(bodyColumns, viewportRows, 255, 255) : fit(bodyColumns, viewportRows, 512, 256)
772 if (target.columns !== box.columns || target.rows !== box.rows) {
773 box = target
774 // Raster cells are sized at the source; pixels scale in the terminal.
775 if (mode === 'raster' && stream && (streamBox.columns !== box.columns || streamBox.rows !== box.rows)) restartStream($)
776 }
777 let view
778 if (picture?.kind === 'image' && mode === 'image') {
779 view = <Image key="view" source={picture.source} columns={box.columns} rows={box.rows} alt="the realm's screen" />
780 } else if (picture?.kind === 'raster' && mode === 'raster' && picture.columns === box.columns && picture.rows === box.rows) {
781 view = <Raster key="view" columns={picture.columns} rows={picture.rows} cells={picture.cells} />
782 } else {
783 view = <Text dimColor>{viewError ?? "Connecting to the realm's screen…"}</Text>
784 }
785 return (
786 <Box flexDirection="column">
787 {header}
788 <Box flexDirection="column">
789 {view}
790 {isControlled && picture && (
791 <Box position="absolute" top={0} left={0}>
792 <Client key="takeover" module="./takeover.tsx" width={box.columns} height={box.rows} />
793 </Box>
794 )}
795 </Box>
796 <Text dimColor>
797 {isControlled
798 ? 'Click the picture, then type: keys and clicks go to the realm. Esc returns the keyboard.'
799 : mode === 'raster'
800 ? 'This terminal can only show a low-resolution preview (Ghostty and kitty show it sharp). Full view (f) opens it sharp in your browser.'
801 : 'Live view of the realm. The agent works here, never on your screen.'}
802 </Text>
803 {controls}
804 </Box>
805 )
806 })
807}
808hooks/guard.ts 125 lines1// Refuses Bash commands that re-point GUI tooling at the person's own desktop
2// while this session works in a realm (a port of realms_core/host_guard.py).
3//
4// A realm's tools never see the host display, bus or input handles, but nothing
5// stops a command from putting them back: `env WAYLAND_DISPLAY=wayland-1 app`
6// is enough for a GUI tool to act on the real desktop while the agent believes
7// it is working privately. This is an agent-judgment guardrail, not a
8// containment boundary: it reports one recognised pattern out loud.
9
10export const GUARDED_KEYS = [
11 'WAYLAND_DISPLAY',
12 'HYPRLAND_INSTANCE_SIGNATURE',
13 'HYPRLAND_CMD',
14 'DISPLAY',
15 'XDG_RUNTIME_DIR',
16 'DBUS_SESSION_BUS_ADDRESS',
17 'AT_SPI_BUS_ADDRESS',
18 'SWAYSOCK',
19 'I3SOCK',
20 'YDOTOOL_SOCKET',
21 'CUA_INJECT_SOCKET',
22 'CUA_DRIVER_SOCKET',
23 'XAUTHORITY',
24] as const
25
26const PREFIXES = new Set(['env', 'export', 'declare', 'typeset', 'setenv', ';', '&&', '||', '|', '&'])
27const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/
28const OPERATORS = /[;&|]+$/
29
30// Shell words as POSIX sh splits them (quotes, escapes, comments); null when
31// the text does not parse, which the guard treats as ordinary.
32export function shellWords(text: string): string[] | null {
33 const words: string[] = []
34 let word = ''
35 let hasWord = false
36 let i = 0
37 while (i < text.length) {
38 const c = text[i]!
39 if (c === "'") {
40 const end = text.indexOf("'", i + 1)
41 if (end < 0) return null
42 word += text.slice(i + 1, end)
43 hasWord = true
44 i = end + 1
45 } else if (c === '"') {
46 i += 1
47 let closed = false
48 while (i < text.length) {
49 const d = text[i]!
50 if (d === '\\' && i + 1 < text.length && '"\\$`\n'.includes(text[i + 1]!)) {
51 word += text[i + 1]
52 i += 2
53 } else if (d === '"') {
54 closed = true
55 i += 1
56 break
57 } else {
58 word += d
59 i += 1
60 }
61 }
62 if (!closed) return null
63 hasWord = true
64 } else if (c === '\\') {
65 if (i + 1 < text.length) word += text[i + 1]
66 hasWord = true
67 i += 2
68 } else if (c === '#' && !hasWord) {
69 const end = text.indexOf('\n', i)
70 i = end < 0 ? text.length : end
71 } else if (/\s/.test(c)) {
72 if (hasWord) words.push(word)
73 word = ''
74 hasWord = false
75 i += 1
76 } else {
77 word += c
78 hasWord = true
79 i += 1
80 }
81 }
82 if (hasWord) words.push(word)
83 return words
84}
85
86// A guarded key is only an escape when it is pointed somewhere real.
87export function isHostValue(key: string, value: string): boolean {
88 if (!value) return false
89 if (key === 'XDG_RUNTIME_DIR') return /^\/run\/user\/[0-9]+\/?$/.test(value)
90 if (key === 'DISPLAY') return /^:[0-9]+(\.[0-9]+)?$/.test(value)
91 if (key === 'DBUS_SESSION_BUS_ADDRESS' || key === 'AT_SPI_BUS_ADDRESS')
92 return /\/run\/user\/[0-9]+\/(bus|at-spi)/.test(value)
93 return true
94}
95
96function splitOperator(token: string): [string, boolean] {
97 return [token.replace(OPERATORS, ''), OPERATORS.test(token)]
98}
99
100// The refusal for a command that re-points a guarded key, else null.
101export function hostEscape(command: unknown): string | null {
102 if (typeof command !== 'string' || !command.trim()) return null
103 const tokens = shellWords(command)
104 if (tokens === null) return null
105 const found: string[] = []
106 tokens.forEach((token, index) => {
107 const [bare] = splitOperator(token)
108 const match = ASSIGNMENT.exec(bare)
109 if (!match) return
110 const key = match[1]!
111 const value = match[2]!
112 if (!(GUARDED_KEYS as readonly string[]).includes(key) || !isHostValue(key, value)) return
113 const [previous, ended] = index ? splitOperator(tokens[index - 1]!) : ['', true]
114 if (index === 0 || ended || PREFIXES.has(previous) || ASSIGNMENT.test(previous)) found.push(key)
115 })
116 if (found.length === 0) return null
117 return (
118 'Blocked: this command re-points ' +
119 [...new Set(found)].join(', ') +
120 " at the person's own desktop while this session works in a realm. That is how an agent " +
121 'ends up driving the real screen while reporting it is working privately. Use realm_exec or ' +
122 'realm_launch for the realm, or ask the person (they can run /realm off for host access).'
123 )
124}
125hooks/takeover.tsx 49 lines1// Laid over the live realm picture while the person holds control: forwards
2// what they type and click to the realm's desktop. Keys arrive as presses (the
3// terminal reports no releases); pointer positions arrive in cells, with the
4// sub-cell fraction where the terminal reports pixels.
5
6import type { ClientModule, ClientPointerEvent, JsonValue } from 'claude-code'
7
8type Event = { [key: string]: JsonValue }
9type State = { queue: Event[] }
10
11const Takeover: ClientModule<JsonValue, State> = (_props, surface) => {
12 if (surface.state === undefined) {
13 const state: State = { queue: [] }
14 surface.onKey((e) => {
15 const event: Event = { t: 'key', key: e.key }
16 if (e.ctrl) event.ctrl = true
17 if (e.shift) event.shift = true
18 if (e.meta) event.meta = true
19 state.queue.push(event)
20 })
21 surface.onPointer((e: ClientPointerEvent) => {
22 if (e.type === 'enter' || e.type === 'leave') return
23 // A hover move without a button carries no information for the desktop
24 // worth a post per frame; drags (move with a button) do.
25 if (e.type === 'move' && !e.button) return
26 state.queue.push({
27 t: 'ptr',
28 type: e.type,
29 button: e.button ?? 'left',
30 x: e.fine?.x ?? e.x + 0.5,
31 y: e.fine?.y ?? e.y + 0.5,
32 columns: surface.columns,
33 rows: surface.rows,
34 })
35 })
36 // One post per frame at most: send everything queued since the last one.
37 surface.every(30, () => {
38 if (state.queue.length === 0) return
39 const events = state.queue.splice(0)
40 surface.post({ events })
41 })
42 surface.setState(state)
43 }
44 const { Box } = surface.elements
45 return <Box width="100%" height="100%" />
46}
47
48export default Takeover
49