Ports pane for the folder a session runs in: see the dev servers, stop them, and keep the branch and ports in the status line

<img src="docs/icon.png" alt="Elsewhere app icon" width="112" />
<img src="docs/banner.png" alt="Elsewhere: work on a remote dev VM as if it were next to you" />
<a href="https://github.com/Eliezir/elsewhere/releases/latest">Download</a> · <a href="https://eliezir.github.io/elsewhere/">Website</a> · <a href="https://github.com/Eliezir/elsewhere/releases">Releases</a>
Work on a remote dev VM as if it were next to you. Elsewhere is a desktop app that sits between your Mac and a VM you reach over Tailscale. It moves work back and forth, forwards the dev servers it finds, keeps agents running when your laptop closes, and shows what the VM is doing, all through the SSH connection you already have. There are no servers of ours, no account and no analytics.
<a href="https://github.com/Eliezir/elsewhere/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/Eliezir/elsewhere?color=7c5cff&label=release" /></a> <img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20preview-1c1633" /> <img alt="License" src="https://img.shields.io/github/license/Eliezir/elsewhere?color=7c5cff" />
Send, sync and bring back
.env files are copied only if you opt in.Ports and servers
Keep agents running
Worktrees and cleanup
At a glance
macOS: download the latest .dmg from the releases page. The build is not signed yet, so on first launch right-click the app and choose Open (or run xattr -dr com.apple.quarantine /Applications/Elsewhere.app).
Windows (preview): download the .exe installer from the same page. It is unsigned, so SmartScreen shows a warning: choose More info, then Run anyway. VM health, ports and forwarding, storage, Tailscale and Claude usage are the parts built for Windows. Send and bring back, and the Conductor and T3 Code shortcuts, are macOS only for now. It needs the OpenSSH client, included with Windows 10 and 11.
ssh <user>@<vm-host> works. Tailscale SSH may ask you to approve the first connection in a browser, and Elsewhere shows an Approve button when it does.ss over SSH, finds the folder and git branch each one runs from, and opens one ssh -L tunnel per project port from a background loop in the app.tmux new-session -A on the VM, so reopening attaches to the running agent.The mods/ folder holds mods for Claude Code and the Code tab of Claude Desktop. workbench adds a Ports pane (/ports) for the folder a session runs in, with Kill, and puts the branch and ports in the status line.
/plugin install workbench --marketplace Eliezir/elsewhere
In Claude Desktop, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Eliezir/elsewhere, then Add plugin.
ssh <user>@<vm-host> echo ok prints ok without a prompt.localhost:5173 opens a different project. Something else on your Mac holds that port. Use the link on the port's row, or pin a port.apps/desktop: Electron app (electron-vite, React, Tailwind v4, Base UI, Motion, TanStack Query)apps/daemon: small Node service that runs on the VM (tailnet-only, token auth)packages/shared: Zod schemas and the IPC contract shared by bothpnpm install
pnpm dev # Electron app in dev mode
pnpm build
pnpm typecheck
pnpm test
pnpm lint
pnpm --filter @vmc/desktop package # macOS .dmg and .zip in apps/desktop/release
pnpm --filter @vmc/desktop package:win # Windows installer (run on Windows)
Render the popover to a PNG without clicking the tray:
pnpm --filter @vmc/desktop build
cd apps/desktop && VMC_DEBUG_SCREENSHOT=/tmp/popover.png pnpm exec electron .
The mods/ folder holds mods for Claude Code and the Code tab of Claude Desktop. workbench adds a Ports pane (/ports) for the folder a session runs in, with Kill, and puts the branch and ports in the status line.
/plugin install workbench --marketplace Eliezir/elsewhere
In Claude Desktop, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Eliezir/elsewhere, then Add plugin.
Push a tag such as v0.1.0 and the Release workflow builds the macOS and Windows apps and attaches the .dmg, .zip and .exe files to a GitHub release.
MIT
hooks/register.tsx 95 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { LIST_SCRIPT, insideRoot, parsePorts, relative, statusText } from './lib'
6
7const PANE = 'workbench'
8const REFRESH_MS = 5000
9
10const snapshot = atom({ plugin: 'workbench', key: 'snapshot' } as const, null)
11const confirming = atom({ plugin: 'workbench', key: 'confirming' } as const, null)
12
13async function refresh($: EngineInterface, cwd: string): Promise<void> {
14 const result = await $.process.run(['sh', '-c', LIST_SCRIPT], { cwd, timeoutMs: 15000 }).catch(() => null)
15 if (result === null || result.exitCode !== 0) {
16 await update($, snapshot, () => ({ cwd, rows: [], others: 0, updatedAt: Date.now(), error: 'Could not list the listening ports.' }))
17 return
18 }
19 const all = parsePorts(result.stdout)
20 const mine = all.filter(row => insideRoot(row.cwd, cwd))
21 const next: Snapshot = { cwd, rows: mine, others: all.length - mine.length, updatedAt: Date.now(), error: null }
22 await update($, snapshot, () => next)
23 $.ui.status(statusText(mine))
24}
25
26async function kill($: EngineInterface, cwd: string, port: number, force: boolean): Promise<void> {
27 const listing = await $.process.run(['sh', '-c', LIST_SCRIPT], { cwd, timeoutMs: 15000 }).catch(() => null)
28 const row = listing === null ? undefined : parsePorts(listing.stdout).find(entry => entry.port === port)
29 if (!row || !insideRoot(row.cwd, cwd)) {
30 $.ui.toast(`Nothing from this folder is listening on :${port} any more.`)
31 } else {
32 await $.process.run(['kill', force ? '-KILL' : '-TERM', String(row.pid)]).catch(() => null)
33 $.ui.toast(`Stopped :${port}.`)
34 }
35 await update($, confirming, () => null)
36 await $.clock.sleep(1200)
37 await refresh($, cwd)
38}
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 await $.command.register({
43 name: 'ports',
44 description: 'Show the dev servers running from this folder, and stop them',
45 })
46 void refresh($, e.cwd)
47 $.clock.every(REFRESH_MS, () => void refresh($, e.cwd))
48
49 return next(e)
50 })
51
52 on('command.run', { command: 'ports' }, async $ => {
53 await $.ui.open({ id: PANE, title: 'Ports' })
54
55 return { text: 'Ports pane opened.' }
56 })
57
58 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59 const { Box, Button, Text } = $.ui.resolve(e)
60 const state = await read($, snapshot)
61 const pending = await read($, confirming)
62
63 if (state === null) return <Text dimColor>Looking for running servers…</Text>
64
65 return (
66 <Box flexDirection="column">
67 <Box>
68 <Text dimColor>{state.cwd}</Text>
69 </Box>
70 {state.error !== null && <Text>{state.error}</Text>}
71 {state.rows.length === 0 && state.error === null && <Text dimColor>Nothing from this folder is listening.</Text>}
72 {state.rows.map(row => (
73 <Box key={`row-${row.port}`}>
74 <Text>:{row.port} </Text>
75 <Text dimColor>
76 {row.command} {relative(row.cwd, state.cwd)} {row.branch ?? ''}{' '}
77 </Text>
78 {pending === row.port ? (
79 <Box>
80 <Button key={`yes-${row.port}`} label="Stop it" variant="primary" onPress={() => kill($, state.cwd, row.port, false)} />
81 <Button key={`force-${row.port}`} label="Force" onPress={() => kill($, state.cwd, row.port, true)} />
82 <Button key={`no-${row.port}`} label="Cancel" onPress={() => update($, confirming, () => null)} />
83 </Box>
84 ) : (
85 <Button key={`kill-${row.port}`} label="Kill" onPress={() => update($, confirming, () => row.port)} />
86 )}
87 </Box>
88 ))}
89 {state.others > 0 && <Text dimColor>{state.others} more listening outside this folder.</Text>}
90 <Button key="refresh" label="Refresh" onPress={() => refresh($, state.cwd)} />
91 </Box>
92 )
93 })
94}
95hooks/lib.ts 63 lines1export type PortRow = {
2 port: number
3 pid: number
4 command: string
5 cwd: string | null
6 branch: string | null
7}
8
9export const LIST_SCRIPT = `
10if [ "$(uname)" = Linux ]; then
11 ss -ltnpH 2>/dev/null | while read -r _ _ _ local _ users; do
12 port=\${local##*:}
13 pid=$(printf %s "$users" | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p')
14 [ -n "$pid" ] || continue
15 cmd=$(printf %s "$users" | sed -n 's/.*(("\\([^"]*\\)".*/\\1/p')
16 cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
17 branch=""
18 [ -n "$cwd" ] && branch=$(git -C "$cwd" rev-parse --abbrev-ref HEAD 2>/dev/null)
19 echo "$port|$pid|$cmd|$cwd|$branch"
20 done
21else
22 lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {n=split($9,a,":"); print a[n] "|" $2 "|" $1}' | sort -u | while IFS='|' read -r port pid cmd; do
23 cwd=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p')
24 branch=""
25 [ -n "$cwd" ] && branch=$(git -C "$cwd" rev-parse --abbrev-ref HEAD 2>/dev/null)
26 echo "$port|$pid|$cmd|$cwd|$branch"
27 done
28fi
29`
30
31export function parsePorts(output: string): PortRow[] {
32 const rows = new Map<number, PortRow>()
33 for (const line of output.split('\n')) {
34 const [port, pid, command, cwd, branch] = line.trim().split('|')
35 const portNumber = Number(port)
36 const pidNumber = Number(pid)
37 if (!Number.isInteger(portNumber) || portNumber < 1 || !Number.isInteger(pidNumber) || pidNumber < 2 || !command) continue
38 if (portNumber === 22) continue
39 if (!rows.has(portNumber)) {
40 rows.set(portNumber, { port: portNumber, pid: pidNumber, command, cwd: cwd || null, branch: branch || null })
41 }
42 }
43 return [...rows.values()].sort((a, b) => a.port - b.port)
44}
45
46export function insideRoot(cwd: string | null, root: string): boolean {
47 if (cwd === null) return false
48 return cwd === root || cwd.startsWith(root.endsWith('/') ? root : `${root}/`)
49}
50
51export function statusText(rows: readonly PortRow[]): string | undefined {
52 if (rows.length === 0) return undefined
53 const first = rows[0]
54 const ports = rows.map(row => `:${row.port}`).join(' ')
55 return first?.branch ? `${first.branch} · ${ports}` : ports
56}
57
58export function relative(cwd: string | null, root: string): string {
59 if (cwd === null) return ''
60 if (cwd === root) return '.'
61 return cwd.startsWith(`${root}/`) ? cwd.slice(root.length + 1) : cwd
62}
63types/index.d.ts 25 lines1export type PortRow = {
2 port: number
3 pid: number
4 command: string
5 cwd: string | null
6 branch: string | null
7}
8
9export type Snapshot = {
10 cwd: string
11 rows: PortRow[]
12 others: number
13 updatedAt: number
14 error: string | null
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 workbench: {
20 snapshot: Snapshot | null
21 confirming: number | null
22 }
23 }
24}
25