SLOPSHOPPER

browser-pane

One shared browser window for mods: each mod adds a tab ($.browserPane), and a Guide tab lists them

commandprocessnetworktimer
v0.0.1MITupdated 2026-10-19sandipchitale/browser-pane
A shopper browsing a rack in a slop shop
README

browser-pane

Claude Code mods are no longer limited to what a terminal can draw. browser-pane gives every mod a tab in one shared browser window, and with it the whole web platform: 3D with Three.js, CAD with JSCAD, rich editors, live charts, Web Audio and MIDI, speech in and out, WebRTC, and whatever else a web page can do. The terminal stays where the conversation happens; the browser is where a mod shows and works with what the conversation is about.

A mod adds its tab through $.browserPane, a Guide tab lists them all, and browser-pane runs the one local server and window behind them, securely. The integration guide explains the idea and walks you through building your own.

The shared window's Guide tab, listing the tabs mods added

  • One window per session. Showing a tab while the window is open switches that window; a second one is never opened.
  • Tabs are static pages, plus an optional Node module. The shared server serves a tab's files and sends the tab's api/ requests to its module.
  • Locked to you. The server listens on 127.0.0.1 only, checks the Host header, and needs a per-session token: a header for the mod, a cookie for the window.

Install

At the prompt of a terminal session:

/plugin install browser-pane --marketplace sandipchitale/browser-pane

From a shell: claude plugin install browser-pane --marketplace sandipchitale/browser-pane. Answer y to add the marketplace, then choose a scope.

You usually don't install it yourself: a mod that uses it lists it as a dependency, and installing that mod installs browser-pane too. To try it, install one of the examples:

/plugin install browser-pane-template --marketplace sandipchitale/browser-pane

Commands: /browser-pane opens the window on the Guide tab, /browser-pane <tab> on a tab.

It needs Node.js on your PATH and Google Chrome, which opens the window as an app window in a profile of its own. Without Chrome it opens your default browser.

Using it from a mod

examples/images/ is a complete mod that uses it; this section walks through the same steps.

1. Depend on it

In your mod's .claude-plugin/plugin.json:

"dependencies": ["browser-pane"]

Claude Code then loads your mod only when browser-pane is installed, and lays browser-pane's types into your mod's .claude-plugin/types/, so $.browserPane is typed.

A bare name is looked up in your own marketplace, so list browser-pane there too. Then one install line installs both:

{
  "name": "browser-pane",
  "source": { "source": "github", "repo": "sandipchitale/browser-pane" }
}

Two mods that each list it share one copy: the second is skipped, as it has the same name.

2. Register a tab

From session.start. This is cheap: nothing starts until the first show.

on('session.start', async ($, e, next) => {
  await $.browserPane.register({
    id: 'images',                                        // lowercase letters, digits, dashes
    title: 'Images',
    root: `${$.plugin.root}/tab`,                        // your static files: index.html at least
    server: `${$.plugin.root}/tab-server/server.mjs`,    // optional
    description: 'One line for the Guide tab.',
    pinned: true,                                        // optional: first in the Guide and the tab strip
  })
  return next(e)
})

3. Show it

const r = await $.browserPane.show({ tab: 'images', select: '2', data: { any: 'json' } })
if (!r.isOpened) {
  // r.reason says why: no $.process here (desktop app, IDE), no Node, ...
  // Show the content your own way, say in a Pane.
}
  • select reaches the tab's page (see onSelect below).
  • data reaches the tab's server module (update) before the tab shows.
  • r.how is 'opened' or 'reused'.

$.browserPane.update({ tab, data }) hands new data to the server module without bringing the window forward. The latest data is kept, and sent again if the server restarts.

4. The tab's page

Your root is served at /tab/<id>/. Load the client in <head>, so the window's theme applies before the page paints:

<head>
  <script src="/bp/client.js"></script>
</head>
<script>
  browserPane.onSelect(select => { /* show it */ })      // the one it opened with first
  browserPane.onRefresh(() => { /* reload your data */ }) // the window's Refresh button; without one, the page reloads
  browserPane.api('info').then(r => r.json()).then(info => { /* ... */ })  // /tab/<id>/api/info
</script>

The window draws the controls every tab would otherwise repeat, as icons at the right of the tab strip: Refresh (the tab showing) and Theme (one button each for System, Light and Dark, remembered). A tab draws neither. It styles itself from <html data-theme="light|dark">, which the client keeps in step, and can read browserPane.theme or listen with browserPane.onTheme(theme => ...).

Dotfiles, and links that lead outside root, are never served.

5. The tab's server module (optional)

A Node ES module whose default export makes the tab's handlers. It runs inside browser-pane's server, with full Node:

export default function create({ id, root }) {
  let data = {}
  return {
    update(next) { data = next },                  // from show({ data }) and update()
    handle(req, res, sub) {                        // requests under /tab/<id>/api/; sub = 'info'
      if (sub === 'info') { res.end(JSON.stringify(data)); return true }
      return false                                 // 404
    },
  }
}

If the module fails to load or throws, only that tab is affected: its requests answer 404 or 500, and the Guide tab shows the error. A changed module is imported afresh the next time the tab registers.

Run commands as argv arrays (spawn(cmd, args)), never through a shell, and serve only files you chose, never a path the page sent.

Examples

Each example is an independent mod: its own folder, its own plugin.json with browser-pane as a dependency, and its own .claude-plugin/marketplace.json. This repository's marketplace lists them too, so each installs from GitHub in one line, browser-pane with it. Each folder is also shaped like a repository of its own, the way a mod you write against browser-pane would be: its own marketplace lists the mod (./) and browser-pane (from GitHub).

ModTabWhat it does
browser-pane-template (examples/template)TemplateA minimal starter: a page, a server module, and the hooks between them. Copy it to start your own mod; its integration.md explains the integration with browser-pane, and its CLAUDE.md points Claude Code there. /template [text].
browser-pane-images (examples/images)ImagesBrowse the images pasted or dropped into the prompt: 🖼 Image #N chips above the prompt, a master/detail tab with preview, open in the OS viewer, show in Finder, copy path. `/images [n \pane]`.
browser-pane-editors (examples/editors)Plugins, MCP, CockpitThree tabs from one server module. Plugins: install, enable, update plugins and manage marketplaces. MCP: servers by scope with live health, add, edit, enable, disable. Cockpit: this session live (tokens and cost, subagents, search), with meters for context fill and the 5-hour, weekly and spend limits (how far through each window, when it resets). `/editors [plugins \mcp \cockpit]`.

Install one, inside Claude Code:

/plugin install browser-pane-images --marketplace sandipchitale/browser-pane

Write your own

The integration guide explains why browser-pane is shaped the way it is, and walks you step by step from a copy of the template to a published mod, with Claude Code.

In short: copy examples/template to a folder of your own, open Claude Code there, and ask it to build what you want. Its integration.md covers the dependency and marketplace, the $.browserPane calls, the page's client, the server module, the safety rules, testing and the development loop, and lists what to rename.

How it works

your mod ── $.browserPane.show ──▶ browser-pane's hook ── POST /control/* (token header) ──▶ server.mjs
                                                                                              │
              Chrome app window ◀── /?t=token → cookie bp_<port> ── shell: tab strip + Guide ─┘
                                     /tab/<id>/…       your static files
                                     /tab/<id>/api/…   your server module
  • The server starts on the first show, and stops a minute after the session's heartbeat stops.
  • The window polls the server once a second. When a show comes while it polls, the open window switches tabs. This matters because Chrome hands a second launch with the same profile to the running process, which would open a new window.
  • When the session ends, the window stays and says so; close it yourself.

Developing

./test.sh                     # everything: the servers (node --test), then each mod's hooks (claude plugin test)
claude plugin validate .      # the manifest, the contract and the hooks module

claude plugin test <dir> runs every *.test.ts under <dir>, so each mod is tested from its own folder: test.sh tests browser-pane from a copy without examples/.

License

MIT

Source 3 files
hooks/register.ts 105 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type {
5  BrowserPaneJson,
6  BrowserPaneRegisterResult,
7  BrowserPaneShowResult,
8  BrowserPaneTab,
9  BrowserPaneUpdateResult,
10} from '../types'
11import { ensure, isUp, post } from './link'
12
13const tabs = atom({ plugin: 'browser-pane', key: 'tabs' } as const, {} as Record<string, BrowserPaneTab>)
14const data = atom({ plugin: 'browser-pane', key: 'data' } as const, {} as Record<string, BrowserPaneJson>)
15
16const TAB_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
17const GUIDE = 'guide'
18const NOT_ANSWERED = 'browser-pane did not answer'
19
20// Each tab the server has not been told about yet is told when it (re)starts, with its latest data.
21async function pushAll($: any) {
22  const all = await read($, tabs)
23  const latest = await read($, data)
24  for (const tab of Object.values(all)) {
25    await post($, '/control/register', tab)
26    if (tab.id in latest) await post($, '/control/data', { tab: tab.id, data: latest[tab.id] })
27  }
28}
29
30const isAbsolute = (p: unknown) => typeof p === 'string' && (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p))
31
32function checkTab(tab: BrowserPaneTab): string | undefined {
33  if (!TAB_ID.test(String(tab.id)) || tab.id === GUIDE) return `tab id "${tab.id}" must be lowercase letters, digits and dashes, and not "${GUIDE}"`
34  if (typeof tab.title !== 'string' || tab.title.trim() === '') return 'a tab needs a title'
35  if (!isAbsolute(tab.root)) return 'a tab root must be an absolute folder'
36  if (tab.server !== undefined && !isAbsolute(tab.server)) return 'a tab server must be an absolute path'
37  return undefined
38}
39
40export const register: Register = on => {
41  // The methods' bodies stand beneath this mod's hooks: a caller reaches them only when the hooks
42  // didn't answer (one failed or ran out of time), so each says so in its own result.
43  on('engine.create', async (_$, e, next) => {
44    const built = await next(e)
45    return {
46      ...built,
47      browserPane: {
48        register: async (): Promise<BrowserPaneRegisterResult> => ({ isRegistered: false, reason: NOT_ANSWERED }),
49        show: async (): Promise<BrowserPaneShowResult> => ({ isOpened: false, reason: NOT_ANSWERED }),
50        update: async (): Promise<BrowserPaneUpdateResult> => ({ isSent: false, reason: NOT_ANSWERED }),
51      },
52    }
53  })
54
55  on('browserPane.register', async ($, e) => {
56    const tab: BrowserPaneTab = { id: e.id, title: e.title, root: e.root }
57    if (e.server !== undefined) tab.server = e.server
58    if (e.description !== undefined) tab.description = e.description
59    if (e.pinned !== undefined) tab.pinned = e.pinned === true
60    const problem = checkTab(tab)
61    if (problem) return { value: { isRegistered: false, reason: problem } }
62    await update($, tabs, all => ({ ...all, [tab.id]: tab }))
63    if (isUp()) await post($, '/control/register', tab)
64    return { value: { isRegistered: true } }
65  })
66
67  on('browserPane.update', async ($, e) => {
68    if (!(e.tab in (await read($, tabs)))) return { value: { isSent: false, reason: `no tab "${e.tab}"` } }
69    const value = e.data
70    await update($, data, all => ({ ...all, [e.tab]: value }))
71    if (!isUp()) return { value: { isSent: false, reason: 'the window is not open; kept for when it opens' } }
72    const answer = await post($, '/control/data', { tab: e.tab, data: value })
73    return { value: answer ? { isSent: true } : { isSent: false, reason: 'the server did not answer' } }
74  })
75
76  on('browserPane.show', async ($, e) => {
77    const tab = e.tab ?? GUIDE
78    if (tab !== GUIDE && !(tab in (await read($, tabs)))) return { value: { isOpened: false, reason: `no tab "${tab}"` } }
79    if (e.data !== undefined && tab !== GUIDE) await update($, data, all => ({ ...all, [tab]: e.data }))
80    // A server that starts now is told every tab's latest data as it comes up, this one's included.
81    const wasUp = isUp()
82    const failure = await ensure($, () => pushAll($))
83    if (failure) return { value: { isOpened: false, reason: failure } }
84    if (wasUp && e.data !== undefined && tab !== GUIDE) await post($, '/control/data', { tab, data: e.data })
85    const answer = await post($, '/control/show', { tab, select: e.select })
86    if (!answer) return { value: { isOpened: false, reason: 'the server did not answer' } }
87    return { value: answer.isOpened ? { isOpened: true, how: answer.how } : { isOpened: false, reason: answer.reason ?? 'no window opened' } }
88  })
89
90  on('session.start', async ($, e, next) => {
91    await $.command.register({
92      name: 'browser-pane',
93      description: 'Open the shared browser window: /browser-pane [tab]',
94    })
95    return next(e)
96  })
97
98  on('command.run', { command: 'browser-pane' }, async ($, e) => {
99    const tab = e.args.trim() || undefined
100    const r = await $.browserPane.show(tab ? { tab } : {})
101    if (r.isOpened) return { text: r.how === 'reused' ? 'browser-pane: switched the open window.' : 'browser-pane: window opened.' }
102    return { text: `browser-pane: no window (${r.reason}).` }
103  })
104}
105
hooks/link.ts 79 lines
1// The shared server (server/server.mjs): one per session, started on the first show and kept up by a
2// heartbeat. The child lives as long as the loop reading it, which ends with this module or the server.
3const HEARTBEAT_MS = 15_000
4const START_TIMEOUT_MS = 6_000
5
6let port: number | undefined
7let token = ''
8let starting: Promise<string | undefined> | undefined
9let hasHeartbeat = false
10
11export const isUp = () => port !== undefined
12
13// POSTs to the server's control API. Resolves the parsed answer, or undefined when it didn't answer.
14export async function post($: any, path: string, body: unknown = {}): Promise<any> {
15  if (port === undefined) return undefined
16  try {
17    const res = await $.http.fetch(`http://127.0.0.1:${port}${path}`, {
18      method: 'POST',
19      headers: { 'x-browser-pane-token': token, 'content-type': 'application/json' },
20      body: JSON.stringify(body),
21    })
22    if (!res.ok) return undefined
23    return res.text ? JSON.parse(res.text) : {}
24  } catch {
25    return undefined
26  }
27}
28
29// Starts the server unless it is up. Resolves undefined once it answers (after `onUp` has told it the
30// tabs), or the reason it can't start. The spawn runs from a timer, so the module owns the child and
31// not the dispatch that asked for it.
32export function ensure($: any, onUp: () => Promise<void>): Promise<string | undefined> {
33  if (port !== undefined) return Promise.resolve(undefined)
34  if (starting) return starting
35  starting = new Promise(resolve => {
36    let settled = false
37    const settle = (reason: string | undefined) => {
38      if (settled) return
39      settled = true
40      if (reason !== undefined) starting = undefined
41      resolve(reason)
42    }
43    $.clock.after(START_TIMEOUT_MS, () => settle('the browser-pane server did not start in time'))
44    $.clock.after(0, () => {
45      void (async () => {
46        let buffer = ''
47        let failure = 'the browser-pane server stopped'
48        try {
49          const child = $.process.spawn({ argv: ['node', `${$.plugin.root}/server/server.mjs`] })
50          for await (const piece of child) {
51            if (!('stream' in piece) || piece.stream !== 'stdout' || port !== undefined) continue
52            buffer += piece.text
53            const newline = buffer.indexOf('\n')
54            if (newline < 0) continue
55            const hello = JSON.parse(buffer.slice(0, newline))
56            token = hello.token
57            port = hello.port
58            if (!hasHeartbeat) {
59              hasHeartbeat = true
60              $.clock.every(HEARTBEAT_MS, () => void post($, '/control/ping'))
61            }
62            await onUp()
63            settle(undefined)
64          }
65        } catch (error) {
66          failure = `the browser-pane server can't run here: ${String(error)}`
67          $.ui.log(`browser-pane: ${failure}`, { to: 'debug' })
68        } finally {
69          port = undefined
70          token = ''
71          starting = undefined
72          settle(failure)
73        }
74      })()
75    })
76  })
77  return starting
78}
79
types/index.d.ts 95 lines
1// The browser-pane contract: what this mod adds to `$`, and the state it keeps.
2// A mod that lists "browser-pane" under "dependencies" gets this file laid into
3// its own .claude-plugin/types/browser-pane/, so `$.browserPane` is typed there.
4
5/**
6 * A tab's data: plain JSON (objects, arrays, strings, numbers, booleans, null), as it crosses to the
7 * tab's server module. Typed `unknown` because the engine freezes every argument deeply, and a
8 * recursive JSON type is too deep for that.
9 */
10export type BrowserPaneJson = unknown
11
12/**
13 * A tab in the shared browser window, as a mod registers it.
14 *
15 * `root` is a folder of static files (an `index.html` at least), served at
16 * `/tab/<id>/`. `server`, when given, is a Node ES module the shared server
17 * imports; its default export is a BrowserPaneServerModule factory, and it
18 * answers the tab's requests under `/tab/<id>/api/`.
19 */
20export type BrowserPaneTab = {
21  /** Lowercase letters, digits and dashes; unique across mods (use your mod's name). */
22  id: string
23  /** The label on the tab. */
24  title: string
25  /** Absolute folder of the tab's static files. Usually `$.plugin.root + '/tab'`. */
26  root: string
27  /** Absolute path of the tab's server module (`.mjs`), if it has one. */
28  server?: string
29  /** One line for the Guide tab. */
30  description?: string
31  /**
32   * Pinned tabs come first, in the Guide and the tab strip, with a pin. This is the tab's default;
33   * the person pins and unpins tabs in the Guide, and the window remembers their choice.
34   */
35  pinned?: boolean
36}
37
38export type BrowserPaneRegisterResult = { isRegistered: boolean; reason?: string }
39
40export type BrowserPaneShowArgs = {
41  /** The tab to bring forward; the Guide tab when left out. */
42  tab?: string
43  /** What the tab should select, handed to its page (`onSelect`). */
44  select?: string
45  /** New data for the tab's server module (`update`), sent before the tab shows. */
46  data?: BrowserPaneJson
47}
48
49/**
50 * `opened`: a new window was asked for. `reused`: an open window switched to
51 * the tab. `isOpened: false` comes with a reason: there is no `$.process`
52 * here (desktop app, IDE), Node or the server would not start, or the tab is
53 * unknown. A caller then shows the content its own way, say in a Pane.
54 */
55export type BrowserPaneShowResult = { isOpened: boolean; how?: 'opened' | 'reused'; reason?: string }
56
57export type BrowserPaneUpdateArgs = { tab: string; data: BrowserPaneJson }
58export type BrowserPaneUpdateResult = { isSent: boolean; reason?: string }
59
60export type BrowserPane = {
61  /** Adds or replaces a tab. Cheap: nothing starts until the first `show`. Call it from `session.start`. */
62  register: (tab: BrowserPaneTab) => Promise<BrowserPaneRegisterResult>
63  /** Opens the window (or reuses the open one) on a tab, optionally selecting something in it. */
64  show: (args: BrowserPaneShowArgs) => Promise<BrowserPaneShowResult>
65  /** Hands a tab's server module new data without bringing the window forward. Kept for the next start. */
66  update: (args: BrowserPaneUpdateArgs) => Promise<BrowserPaneUpdateResult>
67}
68
69/**
70 * What a tab's server module default-exports: called once per import with the
71 * tab, it answers the tab's requests. It runs in the shared Node server.
72 *
73 * `handle` gets the request below `/tab/<id>/api/` (`sub` without a leading
74 * slash) after the server checked its token and Host; it returns false to
75 * answer 404. `update` receives each `show`/`update` data, latest wins.
76 */
77export type BrowserPaneServerModule = (tab: { id: string; root: string }) => {
78  update?: (data: BrowserPaneJson) => void | Promise<void>
79  handle?: (req: unknown, res: unknown, sub: string) => boolean | Promise<boolean>
80}
81
82declare module 'claude-code' {
83  interface EngineInterface {
84    browserPane: BrowserPane
85  }
86  interface PluginState {
87    'browser-pane': {
88      /** Registered tabs by id; kept across reloads of this mod, which consumers don't repeat. */
89      tabs: Record<string, BrowserPaneTab>
90      /** Each tab's latest data, pushed again when the server (re)starts. */
91      data: Record<string, BrowserPaneJson>
92    }
93  }
94}
95