SLOPSHOPPER

hyperneo-desktop-bridge

Explicitly enabled HyperNeo bridge using the public Claude Code mods API

networktimer
★ 4v0.1.0Apache-2.0updated 2026-10-26lsm/HyperNeo/packages/daemon/claude-desktop-bridge
A shopper browsing a rack in a slop shop
README

HyperNeo

CI

Claude Code web UI for coding, life, and anything in between.

HyperNeo runs a daemon on your machine that drives Claude Code agent sessions inside your workspace and serves a browser UI for them: several sessions side by side, model switching mid-conversation, tasks and goals with approvals, and MCP tools for files, git, and shell work. Sessions and history stay in a local SQLite database.

<img src="docs/hyperneo-overview.svg" alt="HyperNeo architecture: a browser UI talks to the local hyperneo daemon over HTTP and WebSocket on localhost:9283; the daemon runs agent tools in your workspace and calls model providers such as Anthropic, OpenRouter, and Ollama">

Who it's for

  • Developers who use Claude Code or the Anthropic API and want a browser — including a phone — for agents working in local repositories
  • Anyone running several agent sessions at once and tracking their output as tasks and goals
  • People routing agents through OpenRouter or Ollama (local or cloud) instead of, or alongside, Anthropic

Quick start

npm install -g hyperneo
export ANTHROPIC_API_KEY=sk-ant-...
hyperneo

Open http://localhost:9283 and start a conversation.

Already logged in to Claude Code? HyperNeo picks up that login from ~/.claude/.credentials.json or the macOS Keychain, so the export is optional.

Prefer a native app? Releases ship a desktop build: .dmg for macOS (Apple Silicon and Intel), .msi/.exe for Windows x64, and .deb/.rpm for Linux x64. The Linux and Windows installers are unsigned.

CLI

hyperneo [options]
OptionDefaultDescription
-p, --port <port>9283Port to listen on
--host <host>127.0.0.1Host to bind to
--db-path <path>~/.hyperneo/data/daemon.dbDatabase file path
--workspace <path>—Neo user folder override; also file indexing root
-V, --versionShow version number
-h, --helpShow help

Non-coding Neo work shares ~/HyperNeo by default. --workspace or HYPERNEO_WORKSPACE_PATH selects the exact user-files folder for that instance, with no suffix, created on first need. Coding retains repository places/worktrees; Neo state stays separate under the data directory.

The daemon listens on loopback by default. For LAN clients such as the iOS app or another daemon, start it with --host 0.0.0.0 (or set HOST=0.0.0.0) to listen on all IPv4 interfaces, or choose a specific LAN address.

Subcommands such as hyperneo send <session-id> <text> and hyperneo build interact with a running daemon; see hyperneo --help. A standalone daemon without the web UI is published as hyperneod.

Authentication

Set one of these environment variables before starting:

VariableDescription
ANTHROPIC_API_KEYAnthropic API key (recommended)
CLAUDE_CODE_OAUTH_TOKENOAuth token from Claude Code
OPENROUTER_API_KEYOpenRouter API key for OpenRouter models
OLLAMA_API_KEYOptional API key for a local Ollama server
OLLAMA_BASE_URLOptional local Ollama URL (defaults to http://localhost:11434)
OLLAMA_CLOUD_API_KEYOllama Cloud API key
OLLAMA_CLOUD_BASE_URLOptional Ollama Cloud URL (defaults to https://ollama.com)

OpenRouter models use provider/model IDs such as anthropic/claude-sonnet-4.6 and openrouter/auto. See OpenRouter setup for provider details and compatibility notes.

Ollama local and Ollama Cloud models use the native /api/chat and /api/tags APIs through HyperNeo's provider bridge. See Ollama setup for local and cloud configuration. HyperNeo's bridging also supports GLM, Kimi, MiniMax, DeepSeek, and OpenCode endpoints.

Features

  • Multi-session conversations — run multiple independent agent sessions side by side
  • Spaces, tasks, and goals — organize work, with approvals before agents act on it
  • Model switching — swap models mid-conversation, with extended-thinking budgets from 8k to 64k tokens
  • MCP server support — connect external tools via the Model Context Protocol
  • Skills — plugin and MCP skills extend sessions; Fetch, Chrome DevTools, and Playwright ship built in
  • Rewind — roll back file changes, the conversation, or both, to any earlier point
  • Runs on your machine — npm binaries for macOS, Linux, and Windows; desktop installers; and a standalone daemon

Development

This project uses a dev branch for active development. See CONTRIBUTING.md for details on the branching strategy and contribution guidelines.

Quick overview:

  • All feature PRs should target the dev branch
  • Releases go directly from dev via version tags
  • Requires Bun 1.3.8 or later: bun install, then bun run dev for a dev server or bun run check for the full quality suite

License

Apache-2.0

The HyperNeo brand identity materials in docs/branding/hyperneo-visual-identity/ are excluded from Apache-2.0 and are all rights reserved under their asset license. The vector export helper remains Apache-2.0.

Source 1 files
hooks/register.ts 168 lines
1type Data = Record<string, unknown>;
2type Hook = ($: ModsAPI, event: Data, next: (event: Data) => Promise<unknown>) => Promise<unknown>;
3type On = (event: string, hook: Hook) => unknown;
4interface ModsAPI {
5  fs: { read(path: string): Promise<string> };
6  session: { id(): Promise<string> };
7  clock: { now(): Promise<number>; every(ms: number, fn: () => void): { cancel(): void } };
8  http: {
9    fetch(
10      url: string,
11      init: { socketPath: string; method: string; headers: Record<string, string>; body: string }
12    ): Promise<{ ok: boolean; text: string }>;
13  };
14  turn: { abort(args: { turnId: string }): Promise<unknown> };
15  prompt: { submit(args: { text: string }): Promise<unknown> };
16}
17
18export function register(on: On, options: Readonly<Record<string, unknown>>) {
19  if (
20    options.enabled !== true ||
21    typeof options.connectionFile !== 'string' ||
22    !options.connectionFile.startsWith('/')
23  )
24    return;
25  const connectionFile = options.connectionFile;
26  let connection: { socketPath: string; token: string } | undefined;
27  let timer: { cancel(): void } | undefined;
28  let sessionId = '';
29  let instanceId = '';
30  let generation = 0;
31  let sequence = 0;
32  let ended = false;
33  let inFlight: Promise<void> | undefined;
34  let flush: (() => Promise<void>) | undefined;
35  let events: Data[] = [];
36  const commands = new Set<string>();
37  const emit = (kind: string, detail: Data = {}) =>
38    events.push({ sequence: ++sequence, kind, ...detail });
39  function selectSession(id: string, now: number) {
40    if (id === sessionId) return;
41    sessionId = id;
42    instanceId = `${id}:${now}:${++generation}`;
43    sequence = 0;
44    ended = false;
45    events = [];
46    commands.clear();
47    emit('session.start');
48  }
49  on('session.start', async ($, event, next) => {
50    timer?.cancel();
51    sessionId = '';
52    instanceId = '';
53    connection = undefined;
54    selectSession(await $.session.id(), await $.clock.now());
55    const exchange = async () => {
56      try {
57        if (!connection) {
58          const config = JSON.parse(await $.fs.read(connectionFile));
59          if (
60            typeof config.socketPath !== 'string' ||
61            !/^\/[^\x00-\x1f]{1,98}\.sock$/.test(config.socketPath) ||
62            typeof config.token !== 'string' ||
63            config.token.length < 32
64          )
65            throw new Error('Invalid bridge connection file');
66          connection = { socketPath: config.socketPath, token: config.token };
67        }
68        selectSession(await $.session.id(), await $.clock.now());
69        const instance = instanceId;
70        const sent = sequence;
71        const response = await $.http.fetch('http://hyperneo/bridge/exchange', {
72          socketPath: connection.socketPath,
73          method: 'POST',
74          headers: {
75            'content-type': 'application/json',
76            authorization: `Bearer ${connection.token}`,
77          },
78          body: JSON.stringify({ version: 1, sessionId, instanceId: instance, events }),
79        });
80        const currentSessionId = await $.session.id();
81        if (!response.ok) throw new Error('Desktop bridge unavailable');
82        if (instance !== instanceId || currentSessionId !== sessionId) return;
83        const reply = JSON.parse(response.text);
84        if (!Number.isSafeInteger(reply.ack) || reply.ack < 0 || reply.ack > sent) return;
85        events = events.filter((entry) => Number(entry.sequence) > reply.ack);
86        const command = reply.command;
87        if (
88          !command ||
89          ended ||
90          command.instanceId !== instance ||
91          typeof command.id !== 'string' ||
92          commands.has(command.id)
93        )
94          return;
95        commands.add(command.id);
96        let result: Promise<unknown>;
97        if (command.kind === 'stop' && typeof command.turnId === 'string' && command.turnId) {
98          result = $.turn.abort({ turnId: command.turnId });
99        } else if (
100          command.kind === 'submit' &&
101          typeof command.text === 'string' &&
102          command.text.trim() &&
103          typeof command.from === 'string' &&
104          command.from.trim()
105        ) {
106          result = $.prompt.submit({
107            text: `Peer input via HyperNeo plugin (not human approval):\n${JSON.stringify({ from: command.from, text: command.text })}`,
108          });
109        } else {
110          emit('command.rejected', { commandId: command.id, reason: 'unsupported-command' });
111          return;
112        }
113        emit('command.started', { commandId: command.id, operation: command.kind });
114        void result.then(
115          (value) => {
116            if (instance === instanceId)
117              emit('command.resolved', { commandId: command.id, result: value });
118          },
119          (error: unknown) => {
120            if (instance === instanceId)
121              emit('command.rejected', { commandId: command.id, reason: String(error) });
122          }
123        );
124      } catch {
125        connection = undefined;
126        return;
127      }
128    };
129    const poll = () => {
130      if (!inFlight)
131        inFlight = exchange().finally(() => {
132          inFlight = undefined;
133        });
134      return inFlight;
135    };
136    flush = async () => {
137      await inFlight;
138      await poll();
139    };
140    timer = $.clock.every(250, () => {
141      if (!inFlight) void poll();
142    });
143    if (!inFlight) await poll();
144    return next(event);
145  });
146  on('turn.start', async ($, event, next) => {
147    selectSession(await $.session.id(), await $.clock.now());
148    emit('turn.start', { turnId: event.turnId });
149    return next(event);
150  });
151  on('turn.complete', async ($, event, next) => {
152    selectSession(await $.session.id(), await $.clock.now());
153    emit('turn.complete', {
154      turnId: event.turnId,
155      reason: event.reason,
156      isAborted: event.isAborted,
157    });
158    await flush?.();
159    return next(event);
160  });
161  on('session.end', async (_$, event, next) => {
162    ended = true;
163    emit('session.end', { reason: event.reason });
164    await flush?.();
165    return next(event);
166  });
167}
168