SLOPSHOPPER

Realms

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

newpanespinnerguardcommandtoast
v0.1.0MITupdated 2026-10-09Zeus-Deus/claude-realms
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · realms
│ ┃ realm ✕ › fix the failing auth test and add an audit log call │ ┃ Realm │ ┃ ⏺ Read(src/auth.ts) │ ┃ The realms server is not connected: the ⎿ Read 6 lines │ ┃ realms server is not running (still ⏺ Update(src/auth.ts) │ ┃ starting, or it stopped: /mcp shows it and ⎿ Added 2 lines, removed 1 line │ ┃ can reconnect plugin:realms:realms) ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Start realm ] [ Close ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /realm │ ⎿ realms: Realms: the realms server is not running (still starting │ ⎿ realms: /realm help lists commands. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · realm
Realm The realms server is not connected: the realms server is not running (still starting, or it stopped: /mcp shows it and can reconnect plugin:realms:realms) [ Start realm ] [ Close ]
README

Realms for Claude Code

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 testing the Omarchy VM in Ghostty, with the live Realm pane on the right

Install

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).

Uninstall

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.

How it works

Installing adds four things to Claude Code:

  • an MCP server with the realm tools and the computer-use tools (look, click, type) that act inside the realm
  • the Realm pane and the /realm command, so you can watch and take over
  • a skill that tells Claude when to use a realm and how
  • a guard that keeps Claude's shell commands off your own desktop while a realm is live

On 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.

Requirements

  • Linux (x86-64 or arm64) and Claude Code 2.1.287 or newer
  • labwc xorg-xwayland wayvnc grim wlr-randr glib2 dbus at-spi2-core bubblewrap
  • For the Omarchy VM, also: qemu-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).

Watching

The Realm pane opens by itself when a realm starts. How sharp it looks depends on your terminal:

TerminalRealm pane
Ghostty, kittySharp, full-resolution picture
CodemuxOpens 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.

Commands

Command
/realmstatus of this session's realm
/realm view / /realm fullshow the pane / open it in your browser
/realm on [omarchy] / /realm stopstart a realm or the Omarchy VM / stop it
/realm listevery realm and VM disk kept on this machine, with sizes
/realm delete ID / /realm cleandelete one stopped realm / all stopped ones
/realm helpeverything 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.

More

  • Settings: options, the realm config file and how the computer-use driver stays up to date
  • Safety model: what a realm isolates, and how the pieces fit together
  • Development: tests in a disposable Docker container

This is the Claude Code port of hermes-realms.

License

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).

Source 3 files
hooks/register.tsx 808 lines
1// 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}
808
hooks/guard.ts 125 lines
1// 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}
125
hooks/takeover.tsx 49 lines
1// 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