跳到主要内容
    ↑↓ 选择↵ 打开esc 关闭
    huynle

    Hub

    @huynle/hub·v0.6.1·通知与集成

    Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.

    GitHub 星标

    0

    月装机量

    0

    综合评分

    20.0

    生态多维模型

    最近提交

    17 小时前

    2026-10-20

    快速安装与配置

    opencode.json

    写入当前项目的 opencode.json,只对这个仓库生效。

    opencode.json

    {
      "$schema": "https://opencode.ai/config.json",
      "plugin": ["@huynle/hub@0.6.1"]
    }

    OpenCode 启动时会通过内嵌运行时自动加载 npm 依赖并缓存至本地目录,无需手动在全局环境执行安装。

    Hub is a central hub that your AI agent harnesses connect to. Each harness (a coding agent's terminal app, server or IDE plugin) runs a thin client, a spoke, that connects outbound to your hub; the hub gives you one place to follow, steer and be notified about every agent session on every machine, from your phone or any browser.

    Hub is harness-agnostic by design: the hub's API, web console and spoke protocol are not tied to one agent. OpenCode is the first supported harness, through the @huynle/hub OpenCode plugin. More harnesses can connect by speaking the same spoke protocol (src/protocol.ts).

    Formerly opencode-voice and, briefly, opencode-hub (what changed).

    Features

    • Remote console: one hub connects all your agent processes and machines. Follow live activity (tool calls, thinking, subagents, streamed replies), read the conversation, send a turn (it steers the run while the session is busy), stop a whole session tree, answer permissions, switch models, and start new sessions, from the phone.
    • Notifications: web push when a session asks a question, needs permission, fails or finishes, with per-device toggles, quiet hours and per-session mute; opt-in Activity updates (one quiet, self-replacing notification per session) to follow subagents, long tools and scripts with the screen off.
    • Live without waste: the console's stream resumes where it left off after a lock or app switch (event ids + replay), pauses while the page is hidden, and carries streamed text only for the session on screen.
    • Running detection: a session counts as working while OpenCode reports it busy or retrying, or while a shell or terminal it started still runs (e.g. a background sleep 60 & or build script), also when it was already running before the plugin started.
    • Voice (optional feature, /voice): dictation and a hands-free conversation mode with a voice agent that answers in short spoken sentences on a fast model and hands real work to background subagents. Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
    • What's open in your terminals first: sessions open in Herdr or the OpenCode TUI, grouped like Herdr's sidebar, with the rest under Recent; rename, pin and archive in sync across devices.
    • Session details: context usage, todos, MCP status, branch and per-file diffs, on demand; unread marks synced per user.
    • Self-hostable: a hub on your computer with nothing extra to install, or your own hub for phones anywhere (standalone binary, Docker or Bun), with single-owner pairing or sign-in with Google for several people.

    In a harness, /hub configures the harness's connection to the hub, and each feature is its own /<command>: today /voice; more features will be added the same way.

    Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness. The hub runs on OpenCode's own runtime, a standalone binary, Docker, or Bun ≥ 1.3.

    Quick start (OpenCode)

    Install the plugin (adds it to your global OpenCode config), then restart the background service so it loads:

    opencode plugin add @huynle/hub
    opencode service restart
    

    Install from npm: the npm package ships the built web app, so the plugin can run a local hub. A GitHub install (opencode plugin add github:huynle/hub) has no built web app (web/dist is not in the repository): it works for connecting to a hub that runs elsewhere, but it does not start a local hub (/hub → Start local hub and the automatic start say the web app is missing; a hub started by hand from such an install answers 503 on its pages). To run a hub yourself, use the npm package, a release binary or Docker, or run npm install && npm run build:web in a source checkout.

    Then, in OpenCode:

    Command What it does
    /hub Configures this harness's hub connection: the server URL, connect and approve (enroll) this machine, which sessions to share (mode), pair a phone, and open the hub in the browser. The first time it offers Set up Hub with two paths (below).
    /voice The Voice feature: talks to the session on screen. It connects (running the setup first when needed), shares the session for Voice and opens it in the browser with Voice mode starting.

    Local: everything on this computer

    1. /hub → On this computer (local — nothing to install). The plugin starts a hub in the background, connects this machine (a fresh local hub approves it by itself), shares every session ("On this computer, only sessions I choose" shares only sessions you switch on), and opens the hub in your browser with a one-time sign-in link.
    2. Use it: open any session to follow and steer it. For Voice, set up speech once (Settings → Speech): pick a provider and paste its API key (one OpenRouter key covers both directions, or point both at your own OpenAI-compatible servers), Save, then Test voice. Keys stay on this computer in ~/.local/state/hub.
    3. /voice in a session opens it in the browser with Voice mode (the hands-free loop) starting; /hub → Open hub in browser opens the home page any time.

    Your own hub (phones anywhere)

    Phones need HTTPS (for the microphone and push), so for phone use away from this computer, run the hub on a server behind your HTTPS reverse proxy (see Running the hub), then:

    1. Open https://<your hub>/pair in the browser you will approve from and enter the 6-digit code from the hub's log (first device only; later devices get codes from /hub → Pair a phone).
    2. /hub → Connect to a hub… → https://hub.example.com. The plugin checks the hub answers, then opens its approve page: check the code and approve this machine. OpenCode notices the approval and asks which sessions to share (default: all).
    3. On each further machine: /hub → Connect to a hub… again. Add phones with /hub → Pair a phone.

    Pre-configured machines: with "server": "https://hub.example.com" in ~/.config/hub/config.json, /hub leads with Connect to hub.example.com. /hub → Server → Set up again… switches between local and a hosted hub later.

    Optional: put the voice agent on a fast model in ~/.config/hub/config.json: "voiceModel": "anthropic/claude-haiku-4-5" (any provider/model you have). Subagents keep the session's model.

    Running the hub

    The hub serves the web app, pairs devices, enrolls machines, and does push and (for Voice) speech. Pick one:

    Way For How
    /hub → On this computer Local use; nothing to install (plugin from npm) started by the plugin with OpenCode's own runtime (BUN_BE_BUN=1 opencode bin/hub.ts), or bun when installed
    Standalone binary Any server or laptop, no runtime needed hub-<os>-<arch> from the releases
    Docker Servers ghcr.io/huynle/hub (linux/amd64, linux/arm64)
    bunx / from source Bun users, development bunx --package @huynle/hub hub-server · bun bin/hub.ts

    Every way reads the same configuration (env vars over hub.json, see Configuration) and answers --help and --version. While no device is paired, the hub logs a pairing code for the first one.

    Standalone binary. One file per platform with the runtime and the web app embedded: hub-<darwin|linux>-<arm64|x64> and hub-windows-x64.exe, plus SHA256SUMS.

    curl -LO https://github.com/huynle/hub/releases/latest/download/hub-linux-x64
    curl -LO https://github.com/huynle/hub/releases/latest/download/SHA256SUMS
    sha256sum --check --ignore-missing SHA256SUMS && chmod +x hub-linux-x64
    HUB_PUBLIC_URL=https://hub.example.com ./hub-linux-x64
    

    On macOS, a downloaded binary is quarantined: xattr -d com.apple.quarantine hub-darwin-arm64. Build them yourself with scripts/build-binaries.sh [target…] (writes dist/).

    Docker.

    docker run -d --name hub -p 127.0.0.1:4097:4097 -v hub-data:/data \
      ghcr.io/huynle/hub
    docker logs hub          # the first device's pairing code
    

    Without HUB_PUBLIC_URL the app is at http://127.0.0.1:4097. Behind a reverse proxy, set HUB_PUBLIC_URL to the public origin. Docker Compose:

    services:
      hub:
        image: ghcr.io/huynle/hub:latest
        restart: unless-stopped
        ports: ["127.0.0.1:4097:4097"]   # your reverse proxy terminates HTTPS in front of this
        environment:
          HUB_PUBLIC_URL: https://hub.example.com
          # OPENROUTER_API_KEY: sk-or-…  # optional, for Voice; keys can be set in the app instead
        volumes: ["hub-data:/data"]
    volumes:
      hub-data:
    

    The image sets HUB_HOST=0.0.0.0, HUB_PORT=4097 and HUB_STATE_DIR=/data. Build it locally with docker build -t hub ..

    bunx / from source. bunx --package @huynle/hub hub-server (needs Bun ≥ 1.3), or in a checkout: npm install --no-audit --no-fund && bun bin/hub.ts. scripts/hub-local.sh runs it in the foreground with hub.json; scripts/io.github.huynle.hub.plist is a launchd template. The opencode-hub and opencode-voice-hub commands still work as aliases of hub-server.

    Security: the hub can drive your OpenCode sessions, which can run tools. Every API call needs a paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public hostname, consider sign-in with Google or an authenticating proxy in front of the web app, and leave /spoke/*, /auth/*, /healthz, the manifest and icons reachable for spokes, sign-in and the PWA.

    Configuration

    • Hub: env vars (HUB_HOST, HUB_PORT, HUB_PUBLIC_URL, HUB_STATE_DIR, OPENROUTER_API_KEY, …) over ~/.config/hub/hub.json ($HUB_CONFIG); see Central server and hub.json. Speech providers and keys are easiest to set in the app (Settings → Speech).
    • Plugin (OpenCode spoke): ~/.config/hub/config.json ($HUB_SPOKE_CONFIG; server, mode, machine token, voice model…), written by /hub; see Spoke.
    • The former VOICE_*, OPENCODE_VOICE_CONFIG and OPENCODE_HUB_CONFIG names and the opencode-voice and opencode-hub directories still work; see Migrating from opencode-voice or opencode-hub.

    How it works

    The hub serves the web app, push and (for Voice) speech, and talks to harnesses only through spokes over one protocol (src/protocol.ts). The OpenCode plugin is the first spoke: it runs in every OpenCode process (the background service and each opencode --standalone), connects outbound to the hub over a WebSocket, lists sessions, forwards compact events, and runs prompts, stops and permission replies on the hub's request.

    Remote console (hub API)

    Every session a connected process of yours reports can be inspected and controlled; Voice only gates conversation mode and utterances (403 not_exposed for sessions not shared with Voice in session mode).

    Route Result
    POST /api/sessions/:id/turn {text, images?} sends a turn; while the session tree is running it steers instead (202 {mode: "steer", inboxID}). An idle turn answers its TurnResult ({outcome, reply, message?}) when it ends; with Prefer: respond-async (the console sends it) a turn still running after 1.5 s answers 202 {mode: "turn"} (Preference-Applied: respond-async) and its result follows as an SSE turn event {sessionID, outcome, reply, message?}, so no proxy cuts a long request. The session stays busy until the turn ends either way. images is up to 4 {mime, data, name?} (PNG, JPEG, WebP or GIF; canonical base64 whose bytes match the type; each at most 512 KB decoded, together at most 750,000 base64 characters); text may be empty when images are attached. They reach OpenCode as inline file attachments. 400 empty, over 100,000 characters or invalid images (with the reason); 409 "A turn is already running for this session"
    GET /api/sessions/:id/timeline?limit=N&order=desc · ?limit=N&before=<older> the conversation as timeline rows, newest first: the latest limit rows (1–200, default 50) in chronological order, plus older (the cursor of the rows before them) while there are more. before=<older> returns the page before that cursor. Cursors are row keys, so they stay valid while new messages arrive. A page starts at a message boundary when it can (it may be up to twice limit then). 400 for an unknown cursor (history reverted or compacted: start again from the newest page). The forward mode ?limit=N&cursor= (oldest rows first, cursor for the next page) still works; it cannot be combined with before or order=desc. The spoke reads OpenCode's history for a first page and serves the following pages from a short-lived per-session cache (30 s, dropped when the session changes)
    GET /api/sessions/:id/timeline/messages/:message/files/:index one image attached to a user message, as bytes with its type (cache-control: private, no-store). Timeline user items list their attachments as files: [{index, mime, name?}] (never the bytes). Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB)
    POST /api/sessions/:id/rename {title} → {title}: renames the session in OpenCode (session.update), through the process that owns it, else any other process that reports it. The title is trimmed and must be 1–200 characters (400 otherwise). Works with Voice off; the new title shows in every console at once (the spoke reports it before answering)
    POST /api/sessions/:id/stop interrupts running descendants deepest first, then the root; idempotent. Also releases the hub's turn lock for the tree: a turn request still waiting answers {outcome: "interrupted"} and the next turn can start (/interrupt does the same)
    GET /api/sessions/:id/summary context usage, todos, MCP status (at most 100 servers), branch and working-tree change stats (each part optional). changes lists at most 500 files, most-changed first, with totalFiles and truncated; its additions/deletions cover every file
    GET /api/sessions/:id/diff?file= one file's patch, on demand (capped at 400 KB as JSON, then truncated: true); never stored in the hub
    POST /api/sessions/:id/read · GET /api/reads per-user read marks, synced to your other devices
    GET /api/events?lite=1&session=<id>&last=<id> the live stream (SSE). Every message has an id:; reconnecting with Last-Event-ID (EventSource does it) or ?last= replays what was missed when the hub still has it (last 2000 events or 5 minutes per user; one fresh catalog stands for any catalog changes), else sends the catalog then {type: "resync"} (the page refetches what it shows). Ids are <epoch>.<n>; a hub restart starts a new epoch. lite=1 leaves out delta/reasoning except of session's tree (status, activity, catalog, turns and the rest still come). Heartbeat: event: ping every 20 s
    GET /api/push/prefs?endpoint= {prefs, quietHours, activityMinutes, muted, subscriptions, subscribed?}: this device's toggles (handoff, question, permission, finished, failed, say, activity), activity update interval and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds endpoint
    PUT /api/push/prefs {toggles…, quietHours?, activityMinutes?} boolean toggles (a 0.5.0 blocked sets question and permission); quietHours {start: "HH:MM", end: "HH:MM", timeZone} or null; activityMinutes a whole number 1–60 (400 when invalid)
    POST /api/push/subscribe {subscription, prefs?, oldEndpoint?} · POST /api/push/unsubscribe {endpoint} this device's subscriptions; oldEndpoint is dropped (rotated subscription). The endpoint must be https on a public hostname that resolves only to public addresses (no loopback, private, link-local, CGNAT or ULA hosts, no localhost/*.local/*.internal), else 400
    POST /api/push/test a test notification to the caller's device only: {sent, failed: [{host, status?}], subscriptions}; 429 with retry-after within 10 s of the last one
    PUT /api/push/mute {sessionID, muted} mutes or unmutes a session's notifications for the user: {muted}

    Spoke (OpenCode plugin)

    The plugin is a thin client ("spoke") of the hub. Every OpenCode process that loads it opens one outbound WebSocket (/spoke/ws, protocol in src/protocol.ts), lists the sessions it knows, forwards their events, and runs turns, stops and permission replies on the hub's request. The web app, speech and phones live on the hub.

    Besides the session, tool and permission events, a spoke forwards session.status (busy/retry/idle) and the shells and terminals sessions start (session.shell.started/ended, shell.created/exited/deleted, pty.created/updated/exited/deleted) in compact form: id, session, command (≤ 200 characters), status and exit code; never output, files or environment. When it connects (and again after its hub connection or OpenCode event stream comes back) it asks its own server what runs now (session.active and shell.list) and sends a session.seed event per running session, so a session that was already running shows as working at once.

    Entry File Role
    server server.ts → src/index.ts Starts the process-wide spoke (src/spoke/), hub.* RPC (also under the legacy id voice.*), the voice agent, short-reply and orchestrator context hook
    tui tui.ts → src/tui.ts /hub dialog and the feature commands (/voice), registered through src/tui-commands.ts (ids hub.<id>, palette group "Hub"); reports the sessions it shows (hub.presence)
    rpc rpc.ts → src/rpc.ts HubRpc (id hub; VoiceRpc is the deprecated id voice): status / setServer / checkServer / enroll / startLocalHub / setMode / setSession / pairCode / handoff / openLink / conversation / presence

    The plugin ids are hub (server) and hub.tui. OpenCode resolves a local plugin directory as <dir>/server, <dir>/tui and <dir>/rpc (package.json main is ignored), and an installed package through its exports (./server, ./tui, ./rpc).

    • Config: ~/.config/hub/config.json (or $HUB_SPOKE_CONFIG), shared by all processes on the machine, mode 0600, watched live. Holds the server URL, mode, machine token, subagent (the agent conversation mode delegates to; default general, [A-Za-z0-9_.-]+), autoStartLocalHub (default true; see Local only), herdr (default true; read open sessions and workspaces from Herdr), and list (the history window, default {"days": 30, "max": 100}; days 1–365, max 1–500; out-of-range values fall back per field).
    • Modes: off (no connection), always (every session this process knows, Voice on for all), session (Voice only for sessions switched on in /hub or from the phone, plus their subagent sessions). Every reported session can be inspected and controlled in both modes; in session mode the others are listed with exposed: false so the phone can offer "Enable voice", and only Voice (conversation mode, utterances) is refused for them.
    • /hub configures the hub connection. Before this machine is enrolled (or after the hub revoked it), Set up Hub: On this computer (local hub, every session or only chosen ones), Connect to a hub… (URL checked with hub.checkServer, i.e. GET <url>/healthz, then device-code approval in the browser), or Not now; a configured remote server comes first as "Connect to ". The decisions are pure functions in src/setup.ts. Once set up, the title shows the server and connection state, and the menu has: Server (keep it, Change server URL… with the same reachability check, Use a hub on this computer, Set up again…), Start local hub (a local server that is not answering), This machine: approved / waiting for approval / revoked, Mode, This session on/off (mode "This session only"), Pair a phone, Open hub in browser (home page via a one-time sign-in link; starts a local hub first when it is down, hub.startLocalHub), and for a shared session Send to phone and Conversation mode on/off.
    • /voice (no menu; src/voice-command.ts): with a session on screen, it (1) runs the /hub setup when the machine is not set up (then continues once connected; a hub that still waits for approval says to run /voice again), asks for a mode when sharing is off, and connects (starting a local hub when needed); (2) shares the session for Voice (hub.setSession in "This session only" mode); (3) opens <hub>/s/<session>?voice=1 through a one-time sign-in link (hub.openLink {sessionID, voice: true}). The page turns Voice mode on for that session (turning it off in any other session first) and removes ?voice=1 from the address. When the browser will not start audio without a tap (a link opened from the terminal), the page shows Tap to start Voice mode instead of an error. The voice agent keeps its name.
    • One-time links (hub.openLink, sessionID and voice optional): <hub>/login?code=…&next=/s/<id> (with voice: next=/s/<id>?voice=1) or next=/; /login accepts no other next. While speech is not usable (see ready below) the hub redirects to <next>#speech, and the page opens Settings → Speech.
    • One active spoke per process (globalThis singleton); the shared background service (opencode serve --service) also lists its root sessions from the last list.days days, at most list.max, newest first (no subagent children, nothing archived; src/spoke/listing.ts), plus sessions that are always listed whatever their age: open in a terminal, running or blocked, in conversation mode, and voice-enabled sessions in session mode (fetched one by one when the window misses them). Each comes with its workspace: project = the OpenCode project root (worktrees map to their repository), else the directory minus its subpath; projectName = the project name or the root's basename. It re-lists every 60 s and 2 s after session.created/renamed/deleted. Snapshots are capped at 500 sessions / 900 kB (exposed, then most recent, are kept).
    • Session browser ops: expose {sessionID, on} (any process, only sessions it reported; in session mode it edits the config's session list, which every process watches; always answers exposed: true) and create {directory, title?, herdrWorkspace?} (service process only; directory must be a reported workspace root or session directory, or a folder of a Herdr pane, else unknown workspace; in session mode the new session is exposed). Both send a fresh snapshot before answering. With herdrWorkspace the service then opens the session in Herdr (below) and answers {sessionID, openedIn: "herdr"}, or {sessionID, warning} when Herdr failed (the session exists either way).

    Open sessions (Herdr and TUIs)

    Sessions open in a terminal are reported with open: true and terminal (src/spoke/herdr.ts, src/spoke/presence.ts, src/tui-presence.ts):

    • Herdr: the background service's spoke runs herdr agent list every 5 s and herdr workspace list + herdr pane list every 30 s (3 s timeout each). Every opencode pane with a session ID is open with terminal: {source: "herdr", workspaceID, workspace (label), title (pane title without "OC | "), status}. It sends a terminals frame with the Herdr workspaces in Herdr's order, each with its pane folders, most used first. No herdr, its server down, or "herdr": false: no Herdr data, silently. Standalone processes never read Herdr (one reader per machine); a machine without a background service only gets TUI presence.
    • TUIs: the TUI plugin reports the root of the session it shows and its open tabs (when session tabs are on) to its server's spoke with hub.presence({sessionIDs, reporter}) on change (polled every 2 s) and every 15 s. A session is open while some TUI reported it within 45 s (terminal: {source: "tui"}); a TUI's report replaces its previous one, so a closed tab stops counting at once. Herdr wins when both apply.
    • Opening in Herdr (create with herdrWorkspace): herdr tab create --workspace <id> --cwd <dir> [--label <title>] --no-focus, then in its root pane herdr agent start <title-slug>-<id-tail> --kind opencode --pane <pane> --timeout 20000 -- --session <sessionID>. The hub waits up to 45 s for this op.

    Conversation mode (OpenCode side)

    In conversation mode the session's main agent becomes a voice orchestrator: it answers in one or two spoken sentences, handles quick answers and up to two low-risk tool calls directly, and hands multi-step work to a background subagent. Routine workers inherit the fast voice model; coding, debugging, architecture and risky work use the configured full work model. Successful workers finish with a short speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start a fresh one. Several independent requests in one breath ("check the deploy and update the changelog") start one background worker each, in parallel, in the same turn; each worker gets a short name ("Deploy check") that the task board and announcements use.

    The phone composer exposes separate Voice and Worker model controls for each session. Each picker lists the connected OpenCode process's available models and variants. Choices are saved per user and session, apply to the next turn or newly started worker, and fall back to the machine's voiceModel and workModel when unset.

    The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear between messages as compact expandable rows. Reopening a session pages its projected message history from OpenCode. Expanding a tool fetches its complete input/output from the owning OpenCode process; the hub does not persist those potentially sensitive details.

    Spoke side (src/spoke/conversation.ts, src/spoke/spoke.ts):

    • The plugin registers a primary agent voice through ctx.agent.transform (the agent editor's update() creates missing agents). Its system prompt is the orchestrator prompt; it inherits the default build agent's permissions so your rules still apply. A voice agent you define yourself (e.g. ~/.config/opencode/agents/voice.md) takes precedence and is left untouched.
    • conversation on remembers the session's current agent and switches it to voice; off switches back (or to the default agent), unless you picked another agent in the meantime. If no voice agent exists, the session keeps its agent and the context hook adds the orchestrator prompt instead.
    • The delegate agent (subagent in the config) is added per request by the context hook, so changing it needs no restart.
    • Task board: the context hook also gives the voice agent (and a conversation on another agent) a live list of the workers it started (src/spoke/taskboard.ts, from this process's events): title, sessionID, state (working, blocked with what it waits for, done with its latest text, failed with the reason, stopped) and for how long, at most 8 (live ones first) of at most 200 characters each. So "what's running?" or "how's the deploy going?" needs no tool call. A session without workers gets no board.
    • Stopping one task by voice: the plugin adds a tool hub_task_stop {sessionID} | {all: true}. The voice agent calls it for "cancel the deploy one" (that worker and whatever runs below it are interrupted, deepest first) or "stop all the workers" (every running worker of the session). It only reaches sessions below the calling session (never the caller itself, its siblings or another session's workers), so the voice agent keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still interrupt the root turn at once.
    • Child text and execution events are paired by the hub. The latest assistant message of a successful child is emitted as root-session speech; interruptions and stale partial results are not. Announcements (hub, src/central/registry.ts): while another worker of the session runs or waits, or one was announced in the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the voice agent already said in the last two minutes is not read again. A failed worker is announced once with its reason ("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary says without seq (messageID announce:failed:<session>, announce:permission:<id>, announce:form:<id>), so the phone queues them after the sentence playing and never cuts one off.
    • Server ops: steer sends the text with delivery: "steer" (metadata {source: "hub", mode: "conversation"}, or mode: "remote" for a session not shared with Voice; 0.5 wrote source: "opencode-hub" and 0.4 and earlier source: "opencode-voice", and readers accept all three) and returns the inbox ID at once; interrupt interrupts the session; conversation turns the mode on/off and returns the agent now in effect, and is refused for sessions not shared with Voice. Replies reach the hub as forwarded session.text.ended, session.inbox.delivered and session.agent.selected events.
    • State is per process and in memory; the hub re-sends conversation on after a reconnect.
    • From the terminal: /voice opens the current session in the browser with Voice mode starting; /hub → Conversation mode toggles conversation mode only (same code path as the phone), then offers to send the session to your phone.

    Central server

    The hub (bin/hub.ts, src/central/) serves the web app, pairs devices, enrolls machines, routes turns to the right OpenCode process, does speech for Voice, and sends web push notifications.

    See Running the hub for the binaries, the Docker image and bunx. src/central/main.ts holds the startup (bin/hub.ts serves web/ from disk; the compiled binaries embed it through src/central/assets.ts, generated entry scripts/gen-web-entry.ts).

    Env Default
    HUB_CONFIG ~/.config/hub/hub.json optional config file (Local only); env vars win over it
    HUB_AUTH pairing oidc for several users signing in with Google; see Multi-user
    HUB_HOST / HUB_PORT 127.0.0.1 / 4097 listen address
    HUB_PUBLIC_URL http://<host>:<port> (http://127.0.0.1:<port> when the host is 0.0.0.0 / ::) origin used in links, cookies and CSRF checks
    HUB_STATE_DIR ~/.local/state/hub state.json (0600): users, machines and devices (token hashes only) with their owners, VAPID keys, push subscriptions, speech settings (server default and per user; provider API keys set from the phone), the audit log (last 500 entries)
    HUB_TURN_TIMEOUT_MS 600000 the hub waits this + 15 s so the spoke's own timeout result arrives first
    HUB_VAPID_SUBJECT the https public URL, else mailto:hub@localhost contact sent to push services
    OPENROUTER_API_KEY default key for the openrouter speech provider (never sent to other providers)
    HUB_STT_MODEL, HUB_TTS_MODEL, HUB_VOICE, HUB_MAX_* see src/central/config.ts speech defaults; the phone's speech settings override them

    The Docker image installs only the hub's runtime packages (web-push and its dependencies, pinned by package-lock.json; see docker/hub-deps.ts).

    First device: while nothing is paired the hub logs a 6-digit code; open <public URL>/pair and enter it. Later devices get codes from /hub → Pair a phone. Machines: /hub → Connect to a hub… shows a code; approve it on the phone (/approve?code=…, or Settings → Machines waiting). A local hub approves the very first machine by itself (see Local only).

    Turn routing: a process that owns the session (viewed or ran it last) → a service process that reports it → the service process of the reporting machine → otherwise the reporting process with the most recent activity.

    Phone app (PWA)

    web/ is the Hub console, a Solid + Vite + TypeScript PWA built to web/dist (npm run build:web; the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:

    • Sidebar (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a terminal grouped by project (or server) with +, Recents; rows show status, a pulsing green dot left of the title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a ⋯ menu (rename, pin, archive, stop, Allow/Disallow Voice).
    • Archive: Archive in a session's ⋯ menu takes it out of the table, the sidebar's groups, Recents and Running, on all your devices (with Undo). Nothing is deleted and OpenCode's history is untouched: archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive will map to it). The Archived chip on / lists archived sessions with Unarchive. One exception: an archived session that needs you (a permission or question) also shows under Needs you (sidebar and the Needs you chip), so a question is never missed. In the table, tick rows (or the header box for all shown rows) and press Archive (or Unarchive under Archived); Archive inactive… archives every session without activity for more than N days (30 by default; not running, pinned or open in a terminal) after a confirmation with the count. Sessions archived in OpenCode itself (time.archived) never reach the hub. Sessions hidden with an earlier version are archived now (same stored list, hiddenSessions).
    • Rename: Rename… in a session's ⋯ menu (table row, sidebar row, phone list, the session's title menu), or a click on the title in the session's top bar, turns the title into a text field with the title selected: Enter (or clicking away) saves, Escape cancels. The new title shows at once and is set in OpenCode itself (POST /api/sessions/:id/rename), so terminals see it too; if that fails the old title comes back with a message.
    • /: All sessions as a sortable, filterable table (a list on phones); filters and sort are kept per device. There is no Voice column or Voice filter: the live Voice session gets the green dot before its title, and the row's ⋯ menu has Allow Voice / Disallow Voice.
    • /s/<id>: the transcript (markdown replies, "Thought for Ns", "Used N tools" with full details on demand, subagent rows linking to their sessions), permission cards, and the composer: Send while idle, Steer while the session tree runs, Stop for the whole tree, Dictate (into this session's draft; leaving the session while recording cancels it; each session keeps its own unsent draft), Voice mode (hands-free, see below; a toggle that turns Voice on first when needed; one session at a time, so turning it on elsewhere turns the first one off; it keeps running while you look at other sessions, which show a "Voice: " pill with End) with a microphone picker, and the Voice/Worker model menus. The right rail (a bottom sheet on phones) has cards that collapse from their header (remembered per device): Environment (machine with online/offline/Connecting, project folder with the full path as tooltip, branch, and where it is open: TUI, Herdr or background), Context, Connectors · N connected (MCP servers) and Changes with a diff viewer. Every session works with Voice off; Voice only adds speech.
    • /new, /settings (account, notifications, Voice, Speech, machines and approvals, devices, admin), /pair, /approve.

    Pure logic lives in web/src/core and web/src/lib (bun tests in test/web-*.test.ts).

    Accounts (multi-user hub, contract in docs/multi-user.md; pure logic in web/account-core.js, tested with bun):

    • Signing in: with HUB_AUTH=oidc the sign-in screen shows Sign in with Google (/auth/login?next=<page>), with the pairing code behind "Have a pairing code?" (a code signs the browser in as the owner of the computer that made it). In pairing mode the pair screen is unchanged.
    • Account menu (avatar in the header): name and email (or "This hub" in pairing mode), an Admin badge, My devices, Speech, Admin (admins only) and Sign out (POST /api/logout, then the sign-in screen).
    • Speech is per user: each field you have not set shows "Using server default: …" (keys: "Key: server default"), and each card has Reset to server default (sends null for every field and the key). Admins also get Server default (/api/settings/speech/default), used by everyone for what they have not set themselves.
    • Admin (in Settings, admins only): Users (search, filter, sort; make/remove admin and disable/enable with a confirmation; the hub refuses to demote or disable the last active admin and the page shows its message; not shown in pairing mode), Machines (every machine with its owner; Revoke), Approvals (pending machines; approving makes the machine yours) and the Audit log (newest first, e.g. "Approved a machine", "Changed a role").
    • Approving a machine (/approve?code=) says "This machine will belong to you ()". Only admins see the list of pending requests; everyone else approves by the code OpenCode shows.
    • A session link that is not yours (or is gone) shows Session not found with a link home.

    Session browser (/; contract in docs/session-view.md; pure logic in web/src/core/view-core.ts and web/src/core/browser-core.ts, tested with bun). Three tabs with counts:

    • Open (default): what is open in your terminals right now, like Herdr's sidebar. A Needs you strip first (blocked sessions from every machine, open or not, with the blocker and an Open button), then one group per Herdr workspace (in Herdr's order), then one per project folder for sessions open in an OpenCode TUI. Rows are compact: status dot (Herdr's agent status combined with the session state: blocked > working > error > idle), workspace · title, and opencode · folder or what it is doing now (shell · bun test, Thinking…). Within a group: pinned, then blocked, working, idle, then most recent; pinned groups come first. Sessions that are not open but working or in conversation mode are listed under Not in a terminal. With nothing open: "No sessions open in a terminal" with links to Recent and New session. Machine headers appear only when several machines are online; offline machines are one line at the end.
    • Recent: everything else the machine reported (its history window), grouped by workspace with nested worktrees and subfolders, with search (title, project name, directory; case- and diacritic-insensitive, all words must match, matches highlighted; opens every group) and filter chips All · Active · Needs you · Today · Enabled. Groups that are pinned, active, blocked or changed within a day start open; only open groups render rows, 50 at a time ("Show more"). A Standalone section lists standalone processes.
    • Hidden: hidden workspaces (with how many sessions they hide) and hidden sessions, each with Unhide.
    • Menus: ⋯ on a row (or long-press / right-click it): Open, Pin to top / Unpin, Hide / Unhide. ⋯ on a group: New session here, Pin / Unpin workspace, Hide / Unhide workspace (and Collapse / Expand in Recent). Hiding a workspace hides the sessions in it: a Herdr workspace (herdr:<id>), a project folder, or a nested folder such as a worktree.
    • Synced view settings: pins, archived sessions and hidden workspaces are per user on the hub (GET/PUT /api/prefs/view), shown at once, reverted with a message if the hub refuses, with Undo after an archive. Other devices update live (SSE prefs). Pins from 0.3 (stored on the device) are moved into them once. With an older hub without /api/prefs/view, they stay on the device. Open/closed groups and the filter stay per device.
    • Voice off sessions (exposed: false, mode session) open, show their status and live activity, and take Send, Steer, Stop and permission replies like any other session. Only Voice is off: the row's ⋯ menu has Allow Voice (POST /api/sessions/:id/expose {on:true}; shown at once, reverted on error), and Voice mode on such a session turns Voice on first.
    • New session (/new; + in the header, + on any group, or a group's ⋯ menu), composer first: "Describe a task or ask a question" (images can be attached), and under it where it runs: Environment (your machines on this hub: online first with their process and OpenCode version, offline ones listed but not selectable; ✓ on the chosen one), Project (a popover with search: Herdr workspaces with their pane folders, then known folders), the folder's branch (from the summary of the latest session in that folder, when there is one) and Open in Herdr tab (a Herdr workspace on a machine that runs Herdr; on by default). Submitting sends POST /api/machines/:id/sessions {directory, herdrWorkspace?}, opens the new session and sends the prompt (and images) as its first turn (POST /api/sessions/:id/turn). The last environment and folder are remembered on the device (localStorage hub.newSession); the group "+" preselects with ?machine=&key=&dir=. "Opened in Herdr · " confirms the new tab; if Herdr failed, the session still exists and the hub's warning is shown. 409 (background service not connected) is shown under the composer.

    Notifications (web push, VAPID keys generated once and stored in the state file). Each device picks what it wants (Settings → Notifications): a session asks a question, needs permission, failed, finished (a root session's run succeeded, except voice turns started from the page), hand-offs from the terminal, and Voice replies when you're not watching (pref say: spoken replies while no device shows the session). Questions, permissions, hand-offs and Voice replies are on by default; finished and failed are opt-in (0.5.0's single "blocked" toggle became question + permission, and its "finished" became finished + failed, migrated on start). Optional quiet hours per device (local start/end time in the browser's time zone) hold everything except hand-offs; Mute notifications in a session's ⋯ menu silences one session for you on every device (hand-offs still come through). At most one per session and kind every 30 s (Voice replies: 10 s); expired subscriptions (404/410) are pruned.

    Activity updates (pref activity, off by default on every device; Settings → Notifications shows a hint until it is on): to know what happens without keeping the screen on. Steps of interest in a session tree — a subagent started or ended, a tool that took 30 s or more, a background shell or terminal that ended (with its exit code: "bun run build exited 0", "was killed", "timed out"), the session going idle — become one notification per session, tagged per session so each replaces the last quietly (no new sound, normal urgency, 5-minute TTL). The first step after a quiet spell goes out at once; later ones are collected until the device's interval ends (Settings: 1–60 minutes, default 5) and sent together ("3 tools · explore finished · Finished"). Short tools are only counted. Quiet hours and muted sessions apply; nothing is sent while a device shows the session.

    • Status per device: Settings → Notifications shows the browser permission, whether the service worker is registered and whether this hub holds this browser's subscription. "Allowed, but this device is not subscribed on this hub" means nothing will arrive (typical after the hub moved to a new address): Enable notifications on this device fixes it. When permission is granted, the app re-subscribes by itself on load if the hub lost the subscription, and the service worker re-subscribes when the browser rotates it (pushsubscriptionchange). Send test notification sends one to this device only and shows the result (sent, or the push service's status).
    • Delivery: questions, permissions and failures are sent with urgency: high and a TTL of one hour so a phone in doze gets them; they stay on screen until handled (requireInteraction), vibrate, and replace the previous one of the same session and kind. A permission notification asks the hub (with the device's cookie, from the service worker) what the request is, shows it, and offers Allow once / Reject; the payload itself never carries it. Payloads are {kind, sessionID, title, body, requestID?}.
    • Android (Chrome), for notifications that arrive with the screen off: open the public URL, sign in or pair, then Chrome's menu → Install app (or Add to Home screen) and use the installed app; Settings → Notifications → Enable notifications on this device and Send test notification. In Android settings → Apps → Chrome (and the installed app, if listed): Notifications allowed (all categories), and Battery → Unrestricted (not "Optimized"), so Chrome may receive pushes while dozing. Notifications of an app you have not opened for a long time can be delayed by Android's adaptive battery.
    • iPhone/iPad: web push needs iOS/iPadOS 16.4+ and the app added to the Home Screen (Share → Add to Home Screen); open it from the icon, then enable notifications. Audio replies need a tap first (the mic or Send button unlocks playback).
    • Microphone and push need a secure context: HTTPS, or http://localhost / 127.0.0.1 for testing.

    Multi-user (sign in with Google)

    One hub can serve several people. Each person signs in with Google (or any OpenID Connect provider) and sees and controls only their own machines, sessions, phones and speech settings. Without it (HUB_AUTH=pairing, the default) the hub has one owner, exactly as before. The full contract is in docs/multi-user.md.

    Google Cloud console

    1. APIs & Services → OAuth consent screen: set it up (External or Internal; scopes openid, email, profile are enough).
    2. APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.
    3. Authorized redirect URI: <public URL>/auth/callback, e.g. https://hub.example.com/auth/callback (Google wants https, except for localhost).
    4. Copy the client ID and client secret into the hub's environment or hub.json.

    Configuration (env vars win over hub.json's auth object):

    Env hub.json
    HUB_AUTH auth.mode pairing (default) or oidc
    HUB_OIDC_ISSUER auth.issuer default https://accounts.google.com; must be https
    HUB_OIDC_CLIENT_ID auth.clientId required
    HUB_OIDC_CLIENT_SECRET auth.clientSecret required; never logged or returned. In hub.json it is ignored (with a warning) unless the file is chmod 600
    HUB_OIDC_ALLOWED auth.allowed required: who may sign in, emails and/or @domain entries (comma list, or an array in hub.json)
    HUB_ADMIN_EMAILS auth.admins at least one admin email (admins are always allowed)
    HUB_PUBLIC_URL=https://hub.example.com \
    HUB_AUTH=oidc \
    HUB_OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
    HUB_OIDC_CLIENT_SECRET=GOCSPX-… \
    HUB_OIDC_ALLOWED=alice@example.com,bob@example.com,@example.org \
    HUB_ADMIN_EMAILS=alice@example.com \
    hub-server
    
    {
      "publicURL": "https://hub.example.com",
      "auth": {
        "mode": "oidc",
        "clientId": "1234567890-abc.apps.googleusercontent.com",
        "clientSecret": "GOCSPX-…",
        "allowed": ["bob@example.com", "@example.org"],
        "admins": ["alice@example.com"]
      }
    }
    

    An invalid or incomplete oidc setup stops the hub with the reason. The startup log shows the issuer, the redirect URI to register and how many allow-list entries and admins there are.

    Who may sign in: an email on the allow-list, any address of an @domain entry (exactly that domain, not its subdomains), or an admin. The provider must report the email as verified. There is no open sign-up. Others get a "Not allowed" page (and an audit entry).

    Admins manage users (make admin, disable), everyone's machines and devices, the server default speech settings and pending machine enrollments, and read the audit log (Account → Admin). They do not see other users' sessions. Emails in HUB_ADMIN_EMAILS become admins when they sign in; the last active admin cannot be demoted or disabled. Disabling a user signs out all their devices and disconnects their machines at once.

    Ownership: a machine belongs to whoever approves its enrollment code; pairing codes and one-time login links from /hub belong to the machine's owner. The bootstrap pairing code and the local first-machine auto-approve exist only in pairing mode.

    Speech: each user can set their own provider, models, voice and key (Settings → Speech); fields they leave empty come from the server default (admins: Settings → Speech → Server default), then hub.json, then the env. A user's key is only ever shown (masked) to that user.

    Migrating a single-owner hub: existing machines and devices belong to the built-in owner local. The first time an admin signs in, all of them move to that admin (audit migrate.local) and local is disabled. Nothing else changes; a running spoke stays connected.

    Security notes

    • /auth/login and /auth/callback must be reachable from browsers (an auth proxy in front of the hub is still optional, but must let /auth/*, /spoke/* and the app through).
    • Sign-in uses the authorization code flow with PKCE (S256), state and nonce; the ID token comes straight from the provider's token endpoint over TLS (client_secret_post) and its claims are checked (iss, aud/azp, exp, nonce, email_verified).
    • Another user's sessions, machines and devices answer 404 (like unknown ones), so IDs don't leak; SSE events and push notifications only go to the owner.
    • HUB_OIDC_INSECURE_ISSUER=1 allows a plain-http issuer on loopback. It exists for local development and tests with a fake provider only; never set it in production.

    Session browser (hub)

    Catalog sessions (GET /api/machines → machines[].processes[].sessions[], SSE catalog) carry, besides the reported id, title, directory, parentID?, updated, owned, status, busy, conversation: exposed (any process exposes it), project?, projectName?, processID, machineID, state (whole subtree: blocked if anything waits for the user, else error (own), working, idle), blockers[] ({kind, id, summary, sessionID, sessionTitle} for the subtree) and now? ({kind: "tool" | "thinking", tool?, summary?}, the latest running activity item), and open? / terminal? as the process reported them (GET /api/sessions/:id: open from any reporting process, Herdr first). Machines carry terminals?: {herdr?: {workspaces: [{id, label, directories}]}}, the latest terminals frame of the machine, while the process that sent it is connected. now and state are snapshots: the phone follows SSE activity and status for live changes. Every reported session has a status board, Voice on or off: unexposed sessions report status, state, blockers, now and activity like exposed ones. An unchanged sessions frame does not re-broadcast the catalog.

    Route Spoke op Result
    POST /api/sessions/:id/expose {on} expose 200 {exposed} from a reporting process (service first). 400 bad body, 404 not reported, 502 spoke error
    POST /api/machines/:machineID/sessions {directory, title?, herdrWorkspace?} create 201 {sessionID, openedIn?: "herdr", warning?} from the machine's service process; with herdrWorkspace (≤ 100 characters) the session is also opened in a new tab of that Herdr workspace, and when that fails the answer has a warning instead of openedIn. 400 bad input or unknown workspace (not a workspace root, session directory or Herdr pane folder of that machine), 404 unknown machine, 409 no service process connected
    GET /api/prefs/view – {prefs: {hiddenSessions, hiddenWorkspaces, pinnedSessions, pinnedWorkspaces}}, the caller's view preferences (pairing mode: the single user). hiddenSessions is the user's archived sessions (the name is from before Archive was called Hide)
    PUT /api/prefs/view {any of the four lists} – Replaces the lists given (each ≤ 500 non-empty strings, hiddenSessions ≤ 5000, of ≤ 4096 characters, duplicates dropped; other keys 400). Stored on the user record; SSE {type: "prefs", prefs} goes to the user's other devices (never to other users, not to the device that changed them)

    For a session reported only with exposed: false, utterance and conversation answer 403 {error: "Voice is not enabled for this session", code: "not_exposed"}; inspecting and controlling it (timeline, turn, stop, interrupt, permission and form replies, models, context) works as for any other session.

    Conversation mode (hub and phone)

    Hub API

    All routes need the device cookie; POSTs also need a same-origin Origin (or Referer). Every /api/sessions/:id… route answers 404 {"error": "Not found"} for a session that none of the caller's machines reports (since 0.3.0; before, a session nobody reported was 409), so another user's session looks exactly like an unknown one. For a loopback public URL, localhost, 127.0.0.1 and [::1] with the same scheme and port count as the same origin.

    Route Spoke op Result
    POST /api/sessions/:id/utterance {text} steer 202 {inboxID}; allowed while the session is busy (no turn lock). 400 empty or over 4000 chars, 404 no machine of yours reports the session, 502 spoke error, 504 no answer in 15 s
    POST /api/sessions/:id/interrupt interrupt 204
    POST /api/sessions/:id/conversation {on} conversation 200 {on, agent}. 404 when no machine of yours reports the session
    GET /api/sessions/:id {id, title, directory, parentID?, project?, projectName?, updated, exposed, state, blockers, now?, busy, conversation, status, activity}; catalog sessions also carry conversation
    POST /api/sessions/:id/presence {visible} 204; the phone posts on visibilitychange and every 30 s while visible. A root session is watched while any device reported it (or one of its subagent sessions) visible within 45 s
    POST /api/sessions/:id/permission {requestID, decision} permission 204. decision is once, always or reject; :id is the session or any ancestor. Tried on every process that forwarded the request (OpenCode keeps pending permissions per process and per location; the spoke replies through the plugin instance of the location that holds it). 409 {code: "gone"} "This request was already answered or expired" when none holds it (its card is dropped). 400 bad decision
    POST /api/sessions/:id/form {formID, answer} · {formID, cancel: true} form 204. Answers (or dismisses) a pending OpenCode form, e.g. the agent's question tool. answer is field key → text, number, true/false or list of texts (≤ 50 fields, texts ≤ 20,000 characters, lists ≤ 100). Form blockers carry their fields (key, type, title, description, options, custom, required, limits, when) so a client can render them. The plugin API has no forms: the spoke uses its own server's HTTP API (the background service, or a foreground opencode serve with --port and OPENCODE_PASSWORD), else relays to a TUI of that server that polls RPC hub.formActions; with neither, 502 "answer it in the terminal". 409 {code: "gone"} when no process holds it

    The hub keeps the conversation flag per session in memory. When a process sends a sessions snapshot, owns a session in conversation mode, and was not told yet (it reconnected or took over ownership), the hub sends it {op: "conversation", on: true} once. Deleting the session clears the flag.

    SSE (/api/events) adds:

    • Each session's status tree entry may carry jobs ([{id, kind: "shell" | "pty", command?, status: "running" | "exited" | "timeout" | "killed", exit?, background?, started?, ended?}]: running ones plus the last 5 that ended) and retry ({attempt?, message?}, with detail "Retrying: …"). A session is working while its run, a busy/retry session.status or a running job lasts. The console shows a job count next to the status (table, sidebar), "Retrying …" or "· 1 job" in the session header, and a Jobs card in the rail (command, running time, exit code for a minute after it ends).

    • {type: "conversation", sessionID, on} when the flag changes.

    • {type: "say", sessionID, messageID, ordinal, seq, text}: root text in conversation mode is spoken while it is written. The hub cuts the streamed session.text.deltas of each text part (messageID, ordinal) into sentences (src/central/say-stream.ts: sentence ends followed by the next sentence, line breaks, whole fenced code blocks, abbreviations/decimals/file names kept together, runs over 300 chars cut at a comma) and sends one say per completed sentence right after its delta, seq counting from 0 per text part; session.text.ended (authoritative: missed deltas are recovered) flushes the rest. Sentences are raw slices of the text: /api/speak makes each one speakable. One process's deltas are used per text part and the end is handled once, so each sentence is sent once; an interrupted or failed run, or conversation mode turning off, drops the unspoken rest. A successful subagent's final message is spoken through its root as before: one say per text part without seq. Blank text is skipped.

    • {type: "heard", sessionID, inboxID} when OpenCode delivers an utterance's inbox item (also when the delivery event arrives before the steer result).

    • {type: "activity", sessionID, rootID, item} when an activity item is created or changes state, for every session (all of the owner's devices see it, like status). item is {id, sessionID, kind: "tool" | "thinking", tool?, summary?, background?, state: "running" | "done" | "failed", error?, at}; sessionID is where it happened (a subagent session maps to its root rootID). The hub keeps the 30 most recent items per root (in memory) and returns them, newest last, as activity in GET /api/sessions/:id. Spokes forward tool and reasoning events in compact form (compactActivity): tool name, a one-line summary of the input (command, path, pattern…), ok/error; never file contents or outputs. Thinking items close on session.reasoning.ended, when the next activity in that session starts, or when its run ends.

    • {type: "delta", sessionID, rootID, messageID?, text} (assistant text) and {type: "reasoning", sessionID, rootID, messageID?, delta} (reasoning text; spokes cut each delta to 2000 chars and never forward the full text of session.reasoning.ended) for root and subagent sessions. Per session they are coalesced to at most one send per 100 ms (the first delta goes out at once, later ones in the window are joined); anything else in that session (e.g. the say for that text) is sent only after its held text.

    • status entries of working sessions carry since (ms epoch the current run started), also in the catalog and GET /api/sessions/:id.

    Pushes in conversation mode depend on presence: while the session is watched, "finished" is not sent (the phone is open and speaking). When nobody watches, "finished" is sent as usual and each spoken text of the root session (the whole text part, when it ends) is pushed as kind say (title = session title, body = the text, max 180 chars; at most one every 10 s per session; on by default, pref say). "blocked" is always sent. /api/transcribe and /api/speak have no rate limit; /api/speak accepts up to HUB_MAX_SPEECH_CHARS per call, so the phone sends sentence-sized chunks. /api/speak streams the provider's MP3 through as it arrives (chunked; nothing is buffered on the hub, and a phone that cancels aborts the upstream request), so playback can start before synthesis ends; provider errors still answer 502 before any audio.

    Speech settings

    STT and TTS each use a provider configured from the phone (Settings → Speech) and stored in state.json. Precedence, per direction and field: phone settings > hub.json speech > the environment (OPENROUTER_API_KEY, HUB_STT_MODEL, HUB_TTS_MODEL, HUB_VOICE). When the phone picks another provider than hub.json, the file's section is ignored for that direction; when it picks another base URL, the file's key is not sent there. Providers:

    • openrouter (default, base https://openrouter.ai/api/v1): STT is an OpenAI-style multipart upload (file, model, language?: the audio goes up as binary, not base64 JSON), TTS is POST /audio/speech.
    • openai-compatible (baseURL required: https://…, or http://localhost / 127.0.0.1 for local servers): STT is multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS is POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}. No env key.

    A TTS speed (0.5–2.0) is sent when set; a provider that answers 400 gets one retry without it.

    Route Result
    GET /api/settings/speech {stt: {provider, model, baseURL?, language?, apiKey}, tts: {provider, model, voice, baseURL?, speed?, apiKey}, voiceHints, ready}. apiKey is {set, last4?, source: "settings" | "config" | "env" | "none"} (config: hub.json); a key is never returned. voiceHints maps model ID prefixes to known voices. ready is {stt, tts, reason?}: a direction is usable when openrouter has a key or openai-compatible has a base URL; reason says what is missing. The page shows a Set up speech card (home and session view) until both are ready, and the mic opens speech settings instead of recording while STT is not usable
    PUT /api/settings/speech {stt?, tts?} partial update, answers like GET. apiKey: string sets, null clears, absent keeps; changing provider or baseURL without a new key drops the stored one. 400 for unknown providers, names over 200 chars, bad URLs, speed outside 0.5–2.0, openai-compatible without baseURL
    GET /api/settings/speech/models?kind=stt|tts {models: [{id, name, current?}], note?}, the configured model first (current, added when the provider does not list it). OpenRouter: its model list filtered by output modality (transcription / speech), cached 10 min, 502 when unreachable. OpenAI-compatible: GET {baseURL}/models classified by kind (speech-to-text: transcribe/whisper/stt/asr names or audio-in/text-out audio models; text-to-speech: tts/speech/voice names or speech/audio output, never chat, embedding, image, rerank or music models); when nothing is labeled, every model with note; only the configured model on any failure
    POST /api/settings/speech/test {kind: "tts", text?} audio/mpeg preview with the current settings (text capped at 300 chars). kind: "stt" is 501

    Writes need the same-origin Origin. Provider failures are 502 with the provider's message.

    Conversation mode (phone)

    On /s/<id>, the Conversation button next to the mic starts hands-free mode. It turns on the mic (mono, with the browser's echo cancellation, noise suppression and auto gain on), then calls POST …/conversation {on:true}, keeps the screen awake (Screen Wake Lock, taken again when the page becomes visible) and shows a level ring around the mic with the current state: Listening, Hearing you, Transcribing, Sent ✓ and Speaking. Tap it again, say "pause listening", or leave the page to turn it off. Leaving the page calls {on:false} with keepalive. While conversation mode is off, tap-to-talk works as before.

    • Speech detection (web/src/core/speech-vad.ts; ~20 ms frames from web/public/vad-worklet.js, with a ScriptProcessor fallback): on-device Silero VAD v5 (MIT) through onnxruntime-web (MIT, wasm, single-threaded, so no COOP/COEP headers are needed) in a Web Worker (web/src/voice/silero-worker.ts). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within 3 dB of the adaptive noise floor skip the model) and as the fallback while the model loads, if it fails to load (logged once in the console) or stalls: noise floor that falls fast and rises slowly, threshold 10 dB above it with 4 dB of hysteresis, 300 ms to count. 300 ms of audio before the onset is kept. The model (2.3 MB) and the wasm (12 MB; 2.3 MB with Brotli) load when Voice mode first starts, as hashed static assets the hub serves with cache-control: immutable (and compressed when the browser accepts it), outside the main JS chunk; the Voice bar's tooltip says which detector runs and its time per chunk (about 1 ms).
    • Mic sensitivity (Settings → Voice, per device, Normal by default, applies at once): Low needs ≥ 0.75 for 350 ms (ends below 0.45; pre-gate 6 dB; energy fallback 13 dB / 400 ms), Normal as above, High ≥ 0.45 for 180 ms (ends below 0.25; no pre-gate margin; energy fallback 7 dB / 250 ms).
    • Echo guard: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most of the reply from the mic first.
    • Noise transcripts (web/src/core/junk-transcript.ts): what speech-to-text makes of noise is dropped before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.", "you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two words from under 400 ms of speech unless they hold a command or answer word ("yes", "no", "okay", "stop", "cancel", "wait", "go ahead", "cancel that" …). With a Whisper model the hub also asks for verbose_json and leaves out segments Whisper rates no_speech_prob > 0.6 (OpenRouter and OpenAI-compatible providers; one that refuses verbose_json gets plain requests from then on).
    • Endpointing: a segment ends after 350 ms of silence. It is encoded to 16 kHz mono 16-bit WAV and sent to /api/transcribe at once. A transcript that reads as complete is sent right away: it ends in . ? !, or has 3+ words and doesn't end in "and", "but", "so", "um", "the" and the like. Otherwise it waits up to 0.8 s. If you speak again during the wait, the next segment's transcript is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
    • Commands: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on" calls POST …/interrupt and stops speech. "Pause listening" turns the mode off.
    • Speech out: root replies arrive one sentence at a time while they are written (say with seq); the phone plays them in seq order per text part and drops duplicates (SayAssembler). A say without seq (a subagent's result) is split into sentences. Abbreviations, decimals, file names and list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to /api/speak, up to two ahead of playback, and plays through one <audio> element while its MP3 is still downloading: through MediaSource (audio/mpeg SourceBuffer; Chrome, Android, Firefox), appending bytes as they arrive (web/src/core/audio-stream.ts). Browsers without MP3 in MediaSource (some iOS Safari versions) play each sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays (barge-in, past the echo guard) pauses playback, clears the queue and mutes the rest of the replies that were speaking, also a reply still being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a new reply speaks again. Barge-in never interrupts anything: what you say steers the agent, and background workers keep running (only a stop phrase interrupts the agent's turn).
    • Turn timing: each turn's steps are performance.marks (hub-voice:speech-end, …:transcript, …:accepted, …:first-say, …:first-byte (the reply's first audio byte arrived), …:first-audio (it plays); visible in the browser's performance tools), and the Voice bar's state has the last turn as its tooltip: "last turn: 1.8 s to first audio" with the steps (transcript, accepted, first reply, first byte, audio). A typed turn counts from when the hub accepted it. The transcript shows your utterances with ✓ (sent) and ✓✓ (heard). Agent text is merged with the bubble already streamed from deltas. Subagents starting, finishing or failing appear as small notes. Typed text is sent as an utterance while the mode is on.
    • Android Chrome is the main target. Install the app ("Add to Home screen") so it runs in a standalone window. By default the screen stays on while listening; Settings → Voice → "Keep listening when screen is off" is a best-effort alternative (see below).
    • iOS caveats: Safari needs a tap before audio plays (the Conversation tap unlocks it). The Screen Wake Lock API needs iOS 16.4+ and, before iOS 18.4, works only in Safari tabs, not in Home Screen apps. Locking the screen or switching apps suspends the mic and the AudioContext, so turn the mode on again when you come back. Echo cancellation is weaker with the speaker at full volume, so use headphones or lower the volume if the phone keeps interrupting itself.
    • Local UI checks without OpenCode: bun test/web-mock-hub.ts serves web/ with a fake hub API (queue transcripts with POST /mock/transcripts, push SSE messages with POST /mock/emit, set the status tree with POST /mock/status {sessions}, add activity with POST /mock/activity {sessionID?, item}, read calls from GET /mock/log). It also fakes presence, push prefs and the speech settings API, and the session browser: ~55 sessions in 8 workspaces (worktrees, blocked, working, voice-off ones, a standalone process, an offline machine), 14 of them open (Herdr workspaces general, api-server, research, ops; one in a TUI), expose, create (with herdrWorkspace) and 403 not_exposed, and the view prefs (GET/PUT /api/prefs/view, SSE prefs). POST /mock/prefs {…lists} acts as another device changing prefs, POST /mock/herdr {on?, fail?} removes Herdr or makes opening in Herdr fail (201 with a warning). POST /mock/session {id, ...patch} changes a session live, POST /mock/fail {expose?, create?, models?, prefs?} makes those calls fail, POST /mock/service {connected} disconnects the service (create answers 409). POST /mock/stream {sessionID?, kind: "text" | "reasoning", messageID, text} streams text in chunks; POST /mock/scenario plays a run of ses_mock (reasoning, a shell tool, two subagents streaming output, reply text with say in conversation mode, then idle).

    Voice feedback (phone)

    Pure logic is in web/src/core/feedback-core.ts (tested with bun); web/src/core/cues.ts plays cues.

    • Voice speed: 0.75×–2.0× in 0.05 steps, next to "Read replies aloud" and in Settings → Voice (default 1.0×, stored per device in localStorage). It is applied on the phone as the audio element's playbackRate with preservesPitch, so it changes the reply that is playing right away. It is separate from the optional server-side TTS speed in Settings → Speech.
    • Now header: a sticky line at the top of the session view. While the session or any subagent works it shows a spinner, "Working… 12s" (since the run started: status since, else when the phone first saw it busy) and what is happening: "Thinking", "Running shell · bun test", "Writing reply", "2 subagents working" (combined, e.g. "Writing reply · 2 subagents working"). Otherwise "Needs you" or "Idle · last activity 2m ago". Screen readers hear only changes of what is happening, not the timer. This works for models without reasoning too (e.g. Claude Haiku): status, tools and streamed text drive it.
    • Activity feed: a collapsible panel below it: a live "Thinking…" block streaming the reasoning text (last ~4 lines; More/Less expands it; finished ones stay as "Thought"), tool calls (▶ shell · bun test → ✓, or ✗ with a short error), and subagents ("Subagent: — working/done") with their latest streamed output line and their own steps indented below. It is seeded from <code>GET /api/sessions/:id</code> (<code>activity</code>), then follows SSE <code>activity</code>, <code>reasoning</code>, <code>delta</code> and status trees. Items with the same id update in place; the newest are at the bottom, and it keeps about 30. The agent's reply streams into its bubble (one bubble per assistant message); in conversation mode the spoken <code>say</code> text is merged into the bubble of the same message. The home list shows the same "now" text with a spinner for working sessions.</li> <li><strong>Progress cues</strong> (conversation mode; Settings → Voice → "Progress cues", on by default): a soft tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the session is working and the agent has not spoken within 2.5 s of the end of your speech. A hand-off phrase ("I'll hand that off.", "Getting a helper on it." …) plays when a subagent starts, unless the agent spoke in the last 3 s. A still-working phrase ("Still on it.", "Almost there." …) plays after 40 s without speech while work continues, and a double tone plays when a subagent finishes. Failed tools are not spoken (they show in the feed). Cues never play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent speaks. Cues are event-driven: one timer for the next deadline (no polling), re-armed on speech, status and playback changes. Each cue has several variants (<code>CUE_VARIANTS</code> in <code>web/src/core/feedback-core.ts</code>), chosen from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode cancels what is left) and kept in memory and in the browser's Cache Storage (<code>hub-cues-v1</code>, keyed by TTS provider, model, voice, speed and server, so a later session starts with them ready and a voice change fetches them again); a ready phrase starts playing at once. A phrase that is not ready yet when its cue fires plays a short tone instead.</li> <li><strong>Presence</strong>: while a session view is open, the phone posts <code>POST /api/sessions/:id/presence {visible}</code> on visibility changes and every 30 s while visible, and <code>{visible:false}</code> on <code>pagehide</code> and when leaving the view. The hub uses this to decide when to push.</li> <li><strong>Screen off</strong> (Settings → Voice → "Keep listening when screen is off", off by default). Off: the Screen Wake Lock keeps the screen on while listening. On: no wake lock. The mic and VAD keep running when the page is hidden. A silent looping audio element plays to make suspension less likely. <strong>This is best-effort:</strong> whether listening survives a locked screen depends on Android Chrome (battery saver, OS version), and iOS suspends it. Android Chrome usually stops the microphone when the screen turns off (the track is muted or ended, the AudioContext suspended, no audio frames). Voice mode watches for that: no frames for 1.5 s, a muted/ended track or a suspended AudioContext → the Voice bar says "Paused — mic stopped by the phone (screen off). Unlock to resume.", the notification becomes "Voice mode paused · unlock to talk", the lock screen controls show paused and a short falling tone plays. While hidden it keeps nudging the audio (resume every 3 s, the silent loop); it says "listening" again only once audio frames arrive. On unlock (visible, <code>pageshow</code>, focus, track unmute) it resumes the AudioContext, asks for the microphone again if the track ended (or shows "Tap to start Voice mode" when that needs a tap), retries with backoff while visible, and plays a rising tone. The OpenCode session keeps running on the laptop either way. When the phone is not listening, turn on the <code>say</code> notification to get replies as push notifications.</li> <li><strong>"Voice mode on" notification</strong>: while Voice mode is on, the page shows a silent, persistent system notification through the service worker (tag <code>voice-mode</code>): "<session title> · listening" (thinking, speaking, muted; updated at most every 3 s, at once when the phone stops or gives back the microphone) with <strong>End</strong> and <strong>Open</strong>. End reaches the page (which ends Voice mode); with no page open, the service worker turns conversation mode off at the hub itself with the device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission: without it the Voice bar shows <strong>Allow notifications</strong>. The Media Session shows "Voice mode on" with the session title on the lock screen while Voice audio plays; its pause and stop end Voice mode (logic in <code>web/src/core/voice-notifier.ts</code>).</li> <li><strong>Settings → Speech</strong> (your own settings; admins also edit the server default): cards for Speech-to-text and Text-to-speech, with provider (OpenRouter / OpenAI-compatible), base URL (OpenAI-compatible only), model, voice (TTS), language (STT, optional), server-side speed (TTS, optional, 0.25–4) and API key. Model and voice are buttons that open a full-height picker (logic in <code>web/picker-core.js</code>): a search box (every word must match the ID or name, case- and accent-insensitive; exact ID, then ID prefix, then other matches), the options with the current one marked, and "Use “<typed text>”" for any other value. Models come from <code>GET /api/settings/speech/models</code> (loading, error with Retry, and empty states); voices from <code>voiceHints</code> for the chosen model's prefix. Arrow keys, Home/End and Enter work in the search box; Escape closes and focus returns to the field. (A <code><datalist></code> was used before; Android Chrome only suggests entries matching the current value, so nothing appeared.) The key is never shown or prefilled: the card says "Key set ••••last4 (from settings|env)", with Replace, and Clear for keys stored in settings. Save sends <code>PUT /api/settings/speech</code> with only the changed fields: cleared optional fields are <code>null</code>, the key is a string to replace it or <code>null</code> to clear it. Errors show next to each field. "Preview voice" plays <code>POST /api/settings/speech/test</code> (the saved settings).</li> </ul> <h2>Local only</h2> <p>You don't need a hosted hub: the hub runs fine on the laptop, and speech goes straight from the laptop to the providers in your config.</p> <ol> <li><code>/hub</code> → <strong>On this computer</strong>. If no hub is running, the plugin starts one in the background; the first machine is approved by itself, the mode is set, and the hub opens.</li> <li>Speech: on the page (Settings → Speech), or in <code>~/.config/hub/hub.json</code> (<code>chmod 600</code>; examples below).</li> <li><code>/voice</code> / <code>/hub</code> → <strong>Open hub in browser</strong> open the page with a one-time sign-in link; no pairing needed. To use a phone on the same network, the hub must listen beyond loopback (see notes).</li> </ol> <p>Or run the hub yourself: <code>scripts/hub-local.sh</code> (foreground, reads <code>hub.json</code>), or keep it running with the launchd template <code>scripts/io.github.huynle.hub.plist</code> (not installed by anything; install steps are in the file).</p> <h3>hub.json</h3> <p><code>~/.config/hub/hub.json</code> (or <code>$HUB_CONFIG</code>). All fields are optional.</p> <table> <thead> <tr> <th>Field</th> <th></th> </tr> </thead> <tbody><tr> <td><code>host</code>, <code>port</code></td> <td>listen address (default <code>127.0.0.1</code>, <code>4097</code>)</td> </tr> <tr> <td><code>publicURL</code></td> <td>origin used in links (default <code>http://<host>:<port></code>)</td> </tr> <tr> <td><code>stateDir</code></td> <td>hub state (default <code>~/.local/state/hub</code>; <code>~/</code> is expanded)</td> </tr> <tr> <td><code>speech.stt</code></td> <td><code>{provider, model, baseURL?, language?, apiKey?}</code></td> </tr> <tr> <td><code>speech.tts</code></td> <td><code>{provider, model, voice, baseURL?, speed?, apiKey?}</code></td> </tr> <tr> <td><code>auth</code></td> <td><code>{mode, issuer?, clientId, clientSecret, allowed, admins}</code>; see <a href="#multi-user-sign-in-with-google">Multi-user</a></td> </tr> </tbody></table> <p>Server fields: env vars (<code>HUB_HOST</code>, <code>HUB_PORT</code>, <code>HUB_PUBLIC_URL</code>, <code>HUB_STATE_DIR</code>) > hub.json > defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech settings (providers <code>openrouter</code> / <code>openai-compatible</code>; base URLs <code>https://…</code>, or <code>http://</code> for loopback and private-network hosts; speed 0.5–2.0); an invalid file stops the hub with the reason, and unknown keys are logged. The file must be private: if group or others can read it, its API keys are ignored and the hub logs <code>chmod 600 <file></code>.</p> <p>OpenRouter for both directions:</p> <pre><code class="language-json">{ "speech": { "stt": { "provider": "openrouter", "model": "openai/gpt-4o-mini-transcribe", "apiKey": "sk-or-v1-…" }, "tts": { "provider": "openrouter", "model": "openai/gpt-4o-mini-tts", "voice": "nova", "apiKey": "sk-or-v1-…" } } } </code></pre> <p>Local servers with an OpenAI-compatible API (e.g. a Whisper server on :8000 and Kokoro-FastAPI on :8880; no key needed unless the server asks for one):</p> <pre><code class="language-json">{ "port": 4097, "speech": { "stt": { "provider": "openai-compatible", "baseURL": "http://127.0.0.1:8000/v1", "model": "whisper-1", "language": "en" }, "tts": { "provider": "openai-compatible", "baseURL": "http://127.0.0.1:8880/v1", "model": "kokoro", "voice": "af_heart" } } } </code></pre> <h3>Auto-start</h3> <p>When the configured server is loopback (<code>localhost</code>, <code>127.0.0.1</code>, <code>[::1]</code>, http only) and nothing answers on it, the plugin starts <code><this repo>/bin/hub.ts</code> as a detached background process bound to that address and port. The runtime (<code>resolveHubRuntime</code> in <code>src/spoke/localhub.ts</code>): this process when it is <code>bun</code>, else <code>bun</code> on <code>PATH</code>, else <code>~/.bun/bin/bun</code>, else the current executable with <code>BUN_BE_BUN=1</code> (inside OpenCode that is the <code>opencode</code> binary, a compiled Bun, so nothing else needs installing). Output is appended to <code>~/.local/state/hub/hub.log</code> (<code>$HUB_STATE_DIR</code> if set; a legacy <code>~/.local/state/opencode-hub</code>, else <code>~/.local/state/opencode-voice</code>, is copied there first). It happens on setup (<strong>On this computer</strong>, any process), on <strong>Open hub</strong> (even with auto-start off), and when an enrolled connection is refused: the shared service process (<code>--service</code>) tries at once, other processes after 8 s so the service goes first. A lock file (<code>hub.pid</code>, <code>{pid, port, started}</code>) lets only one process start it; a lock whose pid is gone, or that has not answered for a minute, is replaced. If a hub from the lock answers on another port, nothing is started (two hubs would share one state). Failed starts are retried after 30 s, then doubling up to 15 min, and <code>/hub</code> shows the reason. Something else answering on the port (any HTTP reply) is left alone. Servers that are not loopback are never started. Turn it off with <code>"autoStartLocalHub": false</code> in <code>~/.config/hub/config.json</code>.</p> <h3>First machine</h3> <p>A local hub approves the first machine without the approval step only when all of these hold: it listens on loopback (<code>host</code> is <code>127.0.0.1</code>, <code>localhost</code> or <code>::1</code>), its public URL is loopback, the enrollment request comes from a loopback address without proxy headers (<code>Forwarded</code>, <code>X-Forwarded-*</code>, <code>X-Real-IP</code>), no machine was ever enrolled and no device paired, and it has not done so before in this run. The hub logs it. Everything else stays authenticated: loopback is never trusted for the API, devices, or later machines (a reverse proxy on the same host makes every request look local).</p> <h3>Notes</h3> <ul> <li><strong>Switching</strong> between the local hub and a hosted hub: <code>/hub → Server</code>. Machine tokens belong to one hub, so switching drops the token; <code>/hub</code> → <strong>Server</strong> → <strong>Set up again…</strong> reconnects (each hub keeps its own machines, devices, and speech settings).</li> <li><strong>One hub per machine at a time</strong> (port and state). Stop a background hub with <code>kill $(jq .pid ~/.local/state/hub/hub.pid)</code> or <code>pkill -f bin/hub.ts</code>.</li> <li>The microphone works on <code>http://localhost</code> / <code>127.0.0.1</code> without https (browsers treat loopback as a secure context); web push works on <code>127.0.0.1</code> / <code>localhost</code> in Chrome.</li> <li>Cookies are host-only, so <code>localhost</code> and <code>127.0.0.1</code> each need their own sign-in; both are accepted as origins. Over http no <code>Secure</code> flag is set.</li> <li>A phone needs the hub on a reachable address (<code>"host": "0.0.0.0"</code> plus a <code>publicURL</code> the phone can open, ideally https, e.g. through Tailscale). Then the first-machine shortcut is off, and phone mic and push need https.</li> </ul> <h2>Migrating from opencode-voice or opencode-hub</h2> <p>The project was <strong>opencode-voice</strong> (≤ 0.4), then <strong>opencode-hub</strong> (0.5, <code>@huynle/opencode-hub</code> was never published to npm), and is now <strong>Hub</strong> (0.6): same features, harness-agnostic name. Nothing needs to change on day one; everything below keeps working, with a one-time warning where noted.</p> <ol> <li>Replace the plugin: <code>opencode plugin add @huynle/hub</code> (a <code>github:huynle/hub</code> install has no built web app, so it only suits connecting to a hub that runs elsewhere), remove <code>@huynle/opencode-voice</code> or a <code>github:huynle/hub</code> / <code>github:huynle/opencode-voice</code> entry from your OpenCode plugins, and <code>opencode service restart</code>. <code>@huynle/opencode-voice</code> 0.6.0 only re-exports <code>@huynle/hub</code> and prints a rename notice. There is no <code>@huynle/opencode-hub</code> package to replace.</li> <li>Start OpenCode or the hub once: on first start, when the new location does not exist yet, the most recent legacy copy is <strong>copied</strong> there: the <code>opencode-hub</code> one first, else the <code>opencode-voice</code> one (file modes such as 0600 kept; the old files are never deleted; one log line each). Explicit paths (<code>HUB_CONFIG</code>, <code>HUB_SPOKE_CONFIG</code>, <code>HUB_STATE_DIR</code>, <code>stateDir</code> in hub.json, or their legacy names) are used as they are, without copying. Stop a running old hub first so its state is copied in a consistent state; after checking the new hub, you may remove the old directories yourself.</li> <li>Rename environment variables when convenient. The new name wins when both are set, then the most recent legacy name; a legacy name logs <code>VOICE_X is deprecated; rename it to HUB_X</code> once (or <code>… is ignored because HUB_X is set</code>).</li> </ol> <table> <thead> <tr> <th>Before (opencode-voice ≤ 0.4)</th> <th>Before (opencode-hub 0.5)</th> <th>Now (Hub 0.6)</th> </tr> </thead> <tbody><tr> <td>npm <code>@huynle/opencode-voice</code></td> <td>(not published)</td> <td><code>@huynle/hub</code></td> </tr> <tr> <td><code>github:huynle/opencode-voice</code></td> <td><code>github:huynle/hub</code></td> <td><code>github:huynle/hub</code></td> </tr> <tr> <td>plugin ids <code>opencode-voice</code> / <code>opencode-voice.tui</code></td> <td><code>opencode-hub</code> / <code>opencode-hub.tui</code></td> <td><code>hub</code> / <code>hub.tui</code></td> </tr> <tr> <td><code>/voice</code> (connection and Voice)</td> <td><code>/hub</code> (connection), <code>/voice</code> (Voice)</td> <td><code>/hub</code> (connection), <code>/voice</code> (Voice)</td> </tr> <tr> <td><code>opencode-voice-hub</code> command</td> <td><code>opencode-hub</code></td> <td><code>hub-server</code> (<code>opencode-hub</code> and <code>opencode-voice-hub</code> kept as aliases)</td> </tr> <tr> <td>binaries <code>opencode-voice-hub-<os>-<arch></code></td> <td><code>hub-<os>-<arch></code></td> <td><code>hub-<os>-<arch></code></td> </tr> <tr> <td>image <code>ghcr.io/huynle/opencode-voice-hub</code></td> <td><code>ghcr.io/huynle/hub</code></td> <td><code>ghcr.io/huynle/hub</code></td> </tr> <tr> <td><code>~/.config/opencode-voice/config.json</code> (plugin)</td> <td><code>~/.config/hub/config.json</code></td> <td><code>~/.config/hub/config.json</code> (copied)</td> </tr> <tr> <td><code>~/.config/opencode-voice/hub.json</code></td> <td><code>~/.config/hub/hub.json</code></td> <td><code>~/.config/hub/hub.json</code> (copied)</td> </tr> <tr> <td><code>~/.local/state/opencode-voice/</code> (hub state, <code>hub.log</code>)</td> <td><code>~/.local/state/hub/</code></td> <td><code>~/.local/state/hub/</code> (copied, without the <code>hub.pid</code> lock)</td> </tr> <tr> <td><code>OPENCODE_VOICE_CONFIG</code></td> <td><code>OPENCODE_HUB_CONFIG</code></td> <td><code>HUB_SPOKE_CONFIG</code></td> </tr> <tr> <td><code>VOICE_HUB_CONFIG</code></td> <td><code>HUB_CONFIG</code></td> <td><code>HUB_CONFIG</code></td> </tr> <tr> <td><code>VOICE_HOST</code>, <code>VOICE_PORT</code>, <code>VOICE_PUBLIC_URL</code>, <code>VOICE_STATE_DIR</code></td> <td><code>HUB_*</code></td> <td><code>HUB_HOST</code>, <code>HUB_PORT</code>, <code>HUB_PUBLIC_URL</code>, <code>HUB_STATE_DIR</code></td> </tr> <tr> <td><code>VOICE_AUTH</code>, <code>VOICE_OIDC_ISSUER</code>, <code>VOICE_OIDC_CLIENT_ID</code>, <code>VOICE_OIDC_CLIENT_SECRET</code>, <code>VOICE_OIDC_ALLOWED</code>, <code>VOICE_ADMIN_EMAILS</code>, <code>VOICE_OIDC_INSECURE_ISSUER</code></td> <td><code>HUB_*</code></td> <td><code>HUB_AUTH</code>, <code>HUB_OIDC_ISSUER</code>, <code>HUB_OIDC_CLIENT_ID</code>, <code>HUB_OIDC_CLIENT_SECRET</code>, <code>HUB_OIDC_ALLOWED</code>, <code>HUB_ADMIN_EMAILS</code>, <code>HUB_OIDC_INSECURE_ISSUER</code></td> </tr> <tr> <td><code>VOICE_STT_MODEL</code>, <code>VOICE_TTS_MODEL</code>, <code>VOICE_VOICE</code>, <code>VOICE_MAX_AUDIO_BYTES</code>, <code>VOICE_MAX_SPEECH_CHARS</code>, <code>VOICE_TURN_TIMEOUT_MS</code>, <code>VOICE_VAPID_SUBJECT</code></td> <td><code>HUB_*</code></td> <td><code>HUB_STT_MODEL</code>, <code>HUB_TTS_MODEL</code>, <code>HUB_VOICE</code>, <code>HUB_MAX_AUDIO_BYTES</code>, <code>HUB_MAX_SPEECH_CHARS</code>, <code>HUB_TURN_TIMEOUT_MS</code>, <code>HUB_VAPID_SUBJECT</code></td> </tr> <tr> <td>dev scripts: <code>VOICE_SERVE_LOG</code>, <code>VOICE_SERVE_PORT</code>, <code>VOICE_LOG_LEVEL</code></td> <td><code>HUB_*</code></td> <td><code>HUB_SERVE_LOG</code>, <code>HUB_SERVE_PORT</code>, <code>HUB_LOG_LEVEL</code></td> </tr> <tr> <td>launchd <code>com.opencode-voice.hub</code></td> <td><code>com.opencode-hub.hub</code></td> <td><code>io.github.huynle.hub</code> (bootout the old agent first; steps in the plist)</td> </tr> <tr> <td>RPC id <code>voice</code></td> <td><code>hub</code></td> <td><code>hub</code> (the server also registers <code>voice</code>)</td> </tr> <tr> <td>prompt metadata <code>source: "opencode-voice"</code></td> <td><code>"opencode-hub"</code></td> <td><code>"hub"</code> (readers accept all three)</td> </tr> <tr> <td>default VAPID subject <code>mailto:opencode-voice@localhost</code></td> <td><code>mailto:hub@localhost</code></td> <td><code>mailto:hub@localhost</code> (existing subscriptions keep working)</td> </tr> </tbody></table> <p>Unchanged: the <code>voice</code> agent and conversation mode, the hub's HTTP API and spoke protocol (old and new spokes and hubs interoperate), device cookies (<code>voice_device</code>, <code>voice_oidc</code>: nobody is signed out), <code>state.json</code> and <code>hub.json</code> contents.</p> <p><strong>Docker</strong>: the image sets <code>HUB_HOST</code>, <code>HUB_PORT</code> and <code>HUB_STATE_DIR</code>, which win over <code>VOICE_HOST</code>, <code>VOICE_PORT</code> and <code>VOICE_STATE_DIR</code>: if you override those on the container, rename them to <code>HUB_*</code>. Other <code>VOICE_*</code> variables (e.g. <code>VOICE_PUBLIC_URL</code>, OIDC settings) keep working. The state lives in the <code>/data</code> volume, so only the image name changes.</p> <p><strong>A new domain</strong> (e.g. moving from <code>voice.example.com</code> to <code>hub.example.com</code>):</p> <ul> <li>Set <code>HUB_PUBLIC_URL</code> (or <code>publicURL</code> in hub.json) to the new origin.</li> <li>Sign-in with Google: add <code><public URL>/auth/callback</code> (e.g. <code>https://hub.example.com/auth/callback</code>) as an authorized redirect URI of the OAuth client (and the origin, if listed). Keep the old URI until the move is done.</li> <li>Point each machine's plugin at the new URL (<code>/hub</code> → <strong>Server</strong>, or <code>"server"</code> in <code>~/.config/hub/config.json</code>). Changing the URL drops the machine token, so approve the machine again; keeping the old hostname as a redirect avoids that only for browsers, not for spokes.</li> <li>Device cookies belong to a hostname: browsers and phones sign in (or pair) once more on the new domain.</li> </ul> <h2>Development</h2> <pre><code class="language-sh">git clone https://github.com/huynle/hub && cd hub npm install # bun install works too npm test # bun test (scripts/test-ci.sh runs each file alone and builds the web app first) npm run typecheck # hub and web app npm run build:web # web/dist, served by `bun bin/hub.ts` </code></pre> <p>Console development: <code>bun test/web-mock-hub.ts 4311</code> fakes a hub with fixtures (serves <code>web/dist</code>), and <code>npm run dev:web</code> serves the console with hot reload, proxying <code>/api</code> to it (<code>HUB_URL</code> for a real hub). <code>npm run test:e2e</code> runs a headless Chromium smoke test against the mock hub at desktop and phone sizes (<code>CHROMIUM_PATH</code> if Playwright's browsers are not installed; screenshots go to the temp dir).</p> <p>Point a project's <code>.opencode/opencode.jsonc</code> at your checkout to try changes: <code>{ "plugins": [{ "package": "/path/to/hub" }] }</code>. If the plugin is also installed globally, OpenCode keeps the first copy with the same id, so test with <code>OPENCODE_CONFIG_DIR</code> pointing at a copy of your config without the global entry.</p> <p>Headless loop without a TUI (used for the end-to-end checks):</p> <pre><code class="language-sh">HUB_SERVE_LOG=$TMPDIR/hub-serve.log scripts/serve.sh & # private `opencode serve` on :4199 scripts/oc.sh POST /api/session '{"location":{"directory":"'$PWD'/test-project"}}' D=$(python3 -c 'import urllib.parse,os;print(urllib.parse.quote(os.path.abspath("test-project"),safe=""))') scripts/oc.sh POST "/api/rpc/hub/status?directory=$D" '{"input":{}}' </code></pre> <p>Set <code>HUB_SPOKE_CONFIG</code> to a temp file for such checks so the real machine config is untouched.</p> <p>Releasing: bump <code>version</code> in <code>package.json</code>, add a <code>CHANGELOG.md</code> entry, and push a tag <code>vX.Y.Z</code>. <code>.github/workflows/release.yml</code> tests, then publishes the binaries with <code>SHA256SUMS</code> to the GitHub Release, <code>ghcr.io/huynle/hub:<version></code> and <code>:latest</code>, and <code>@huynle/hub</code> to npm through trusted publishing (OIDC, no token; the trusted publisher is configured for the package on npmjs.com). <code>scripts/build-binaries.sh</code> and <code>docker build .</code> do the same locally.</p> <p>The legacy package <code>@huynle/opencode-voice</code> lives in <code>packages/opencode-voice-shim/</code> (a re-export of <code>@huynle/hub</code> with a rename notice; not part of the main package and not published by the workflow). Publish it by hand after <code>@huynle/hub</code> of the same version is on npm: <code>cd packages/opencode-voice-shim && npm publish --access public</code>, then <code>npm deprecate @huynle/opencode-voice@"<0.6.0" "Renamed to @huynle/hub"</code>. The interim name <code>@huynle/opencode-hub</code> was never published, so it has no shim. A new package name needs one manual first publish before trusted publishing can take over (see the comment in <code>release.yml</code>).</p> <h2>Known limits</h2> <ul> <li>Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no streaming STT provider yet.</li> <li>Voice detection tells speech from noise, not whose speech it is: a TV or people talking nearby still count (Mic sensitivity Low helps; mute or headphones are the sure way).</li> <li>The compiled binaries and the Docker image grow by about 14 MB (the speech model and onnxruntime's wasm).</li> <li>Questions (forms) of a headless standalone server (<code>opencode serve --stdio</code> without its TUI) cannot be answered from the hub; the background service, a foreground <code>opencode serve</code> and TUI-launched standalone servers can.</li> <li>Stop on a subagent session's page stops its whole tree (as on the root's page).</li> <li>Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not possible on iOS). Replies and alerts still arrive as push notifications.</li> <li>The voice agent registers as a visible primary agent (<code>voice</code>) in OpenCode's agent list.</li> <li>A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the replay buffer is in memory (a hub restart means a full catalog + <code>resync</code>).</li> <li>Running shells are seeded from the spoke's own server (<code>shell.list</code> for the 20 most recent session directories); shells in other directories show up once they emit an event.</li> </ul> <h2>License</h2> <p>MIT</p>

    同类生态推荐