@huynle/hub 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
package/README.md
CHANGED
|
@@ -1,3 +1,1030 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="web/public/icon.svg" alt="" width="72" align="right">
|
|
4
|
+
|
|
5
|
+
**Hub** is a central hub that your AI agent harnesses connect to. Each harness (a coding agent's
|
|
6
|
+
terminal app, server or IDE plugin) runs a thin client, a *spoke*, that connects outbound to your hub;
|
|
7
|
+
the hub gives you one place to follow, steer and be notified about every agent session on every machine,
|
|
8
|
+
from your phone or any browser.
|
|
9
|
+
|
|
10
|
+
Hub is harness-agnostic by design: the hub's API, web console and spoke protocol are not tied to one
|
|
11
|
+
agent. [OpenCode](https://opencode.ai) is the first supported harness, through the `@huynle/hub` OpenCode
|
|
12
|
+
plugin. More harnesses can connect by speaking the same spoke protocol (`src/protocol.ts`).
|
|
13
|
+
|
|
14
|
+
Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
15
|
+
([what changed](#migrating-from-opencode-voice-or-opencode-hub)).
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Remote console**: one hub connects all your agent processes and machines. Follow live activity
|
|
20
|
+
(tool calls, thinking, subagents, streamed replies), read the conversation, send a turn (it steers the
|
|
21
|
+
run while the session is busy), stop a whole session tree, answer permissions, switch models, and
|
|
22
|
+
start new sessions, from the phone.
|
|
23
|
+
- **Notifications**: web push when a session asks a question, needs permission, fails or finishes, with
|
|
24
|
+
per-device toggles, quiet hours and per-session mute.
|
|
25
|
+
- **Voice** (optional feature, `/voice`): dictation and a hands-free conversation mode with a voice agent
|
|
26
|
+
that answers in short spoken sentences on a fast model and hands real work to background subagents.
|
|
27
|
+
Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
|
|
28
|
+
- **What's open in your terminals first**: sessions open in Herdr or the OpenCode TUI, grouped like
|
|
29
|
+
Herdr's sidebar, with the rest under Recent; pin and hide in sync across devices.
|
|
30
|
+
- **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
|
|
31
|
+
marks synced per user.
|
|
32
|
+
- **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
|
|
33
|
+
anywhere (standalone binary, Docker or Bun), with single-owner pairing or sign-in with Google for
|
|
34
|
+
several people.
|
|
35
|
+
|
|
36
|
+
In a harness, `/hub` configures the harness's connection to the hub, and each feature is its own
|
|
37
|
+
`/<command>`: today `/voice`; more features will be added the same way.
|
|
38
|
+
|
|
39
|
+
Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness. The hub runs on OpenCode's own
|
|
40
|
+
runtime, a standalone binary, Docker, or [Bun](https://bun.sh) ≥ 1.3.
|
|
41
|
+
|
|
42
|
+
## Quick start (OpenCode)
|
|
43
|
+
|
|
44
|
+
Install the plugin (adds it to your global OpenCode config), then restart the background service so it
|
|
45
|
+
loads:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
opencode plugin add @huynle/hub
|
|
49
|
+
opencode service restart
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install from npm: the npm package ships the built web app, so the plugin can run a local hub. A GitHub
|
|
53
|
+
install (`opencode plugin add github:huynle/hub`) has no built web app (`web/dist` is not in the
|
|
54
|
+
repository): it works for connecting to a hub that runs elsewhere, but it does not start a local hub
|
|
55
|
+
(`/hub` → **Start local hub** and the automatic start say the web app is missing; a hub started by hand
|
|
56
|
+
from such an install answers 503 on its pages). To run a hub yourself, use the npm package, a [release binary](#running-the-hub)
|
|
57
|
+
or Docker, or run `npm install && npm run build:web` in a source checkout.
|
|
58
|
+
|
|
59
|
+
Then, in OpenCode:
|
|
60
|
+
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| **`/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). |
|
|
64
|
+
| **`/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. |
|
|
65
|
+
|
|
66
|
+
### Local: everything on this computer
|
|
67
|
+
|
|
68
|
+
1. `/hub` → **On this computer (local — nothing to install)**. The plugin starts a hub in the background,
|
|
69
|
+
connects this machine (a fresh local hub approves it by itself), shares every session ("On this
|
|
70
|
+
computer, only sessions I choose" shares only sessions you switch on), and opens the hub in your
|
|
71
|
+
browser with a one-time sign-in link.
|
|
72
|
+
2. Use it: open any session to follow and steer it. For Voice, set up speech once (Settings → Speech):
|
|
73
|
+
pick a provider and paste its API key (one [OpenRouter](https://openrouter.ai/keys) key covers both
|
|
74
|
+
directions, or point both at your own OpenAI-compatible servers), **Save**, then **Test voice**. Keys
|
|
75
|
+
stay on this computer in `~/.local/state/hub`.
|
|
76
|
+
3. **`/voice`** in a session opens it in the browser with Voice mode (the hands-free loop) starting;
|
|
77
|
+
`/hub` → **Open hub in browser** opens the home page any time.
|
|
78
|
+
|
|
79
|
+
### Your own hub (phones anywhere)
|
|
80
|
+
|
|
81
|
+
Phones need HTTPS (for the microphone and push), so for phone use away from this computer, run the hub
|
|
82
|
+
on a server behind your HTTPS reverse proxy (see [Running the hub](#running-the-hub)), then:
|
|
83
|
+
|
|
84
|
+
1. Open `https://<your hub>/pair` in the browser you will approve from and enter the 6-digit code from
|
|
85
|
+
the hub's log (first device only; later devices get codes from `/hub` → **Pair a phone**).
|
|
86
|
+
2. `/hub` → **Connect to a hub…** → `https://hub.example.com`. The plugin checks the hub answers, then
|
|
87
|
+
opens its approve page: check the code and approve this machine. OpenCode notices the approval and
|
|
88
|
+
asks which sessions to share (default: all).
|
|
89
|
+
3. On each further machine: `/hub` → **Connect to a hub…** again. Add phones with `/hub` → **Pair a
|
|
90
|
+
phone**.
|
|
91
|
+
|
|
92
|
+
Pre-configured machines: with `"server": "https://hub.example.com"` in
|
|
93
|
+
`~/.config/hub/config.json`, `/hub` leads with **Connect to hub.example.com**.
|
|
94
|
+
`/hub` → **Server** → **Set up again…** switches between local and a hosted hub later.
|
|
95
|
+
|
|
96
|
+
Optional: put the voice agent on a fast model in `~/.config/hub/config.json`:
|
|
97
|
+
`"voiceModel": "anthropic/claude-haiku-4-5"` (any `provider/model` you have). Subagents keep the
|
|
98
|
+
session's model.
|
|
99
|
+
|
|
100
|
+
## Running the hub
|
|
101
|
+
|
|
102
|
+
The hub serves the web app, pairs devices, enrolls machines, and does push and (for Voice) speech. Pick one:
|
|
103
|
+
|
|
104
|
+
| Way | For | How |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| **`/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 |
|
|
107
|
+
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
|
|
108
|
+
| **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
|
|
109
|
+
| **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
|
|
110
|
+
|
|
111
|
+
Every way reads the same configuration (env vars over `hub.json`, see [Configuration](#configuration)) and
|
|
112
|
+
answers `--help` and `--version`. While no device is paired, the hub logs a pairing code for the first one.
|
|
113
|
+
|
|
114
|
+
**Standalone binary.** One file per platform with the runtime and the web app embedded:
|
|
115
|
+
`hub-<darwin|linux>-<arm64|x64>` and `hub-windows-x64.exe`, plus `SHA256SUMS`.
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
curl -LO https://github.com/huynle/hub/releases/latest/download/hub-linux-x64
|
|
119
|
+
curl -LO https://github.com/huynle/hub/releases/latest/download/SHA256SUMS
|
|
120
|
+
sha256sum --check --ignore-missing SHA256SUMS && chmod +x hub-linux-x64
|
|
121
|
+
HUB_PUBLIC_URL=https://hub.example.com ./hub-linux-x64
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
On macOS, a downloaded binary is quarantined: `xattr -d com.apple.quarantine hub-darwin-arm64`.
|
|
125
|
+
Build them yourself with `scripts/build-binaries.sh [target…]` (writes `dist/`).
|
|
126
|
+
|
|
127
|
+
**Docker.**
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
docker run -d --name hub -p 127.0.0.1:4097:4097 -v hub-data:/data \
|
|
131
|
+
ghcr.io/huynle/hub
|
|
132
|
+
docker logs hub # the first device's pairing code
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Without `HUB_PUBLIC_URL` the app is at `http://127.0.0.1:4097`. Behind a reverse proxy, set
|
|
136
|
+
`HUB_PUBLIC_URL` to the public origin. Docker Compose:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
services:
|
|
140
|
+
hub:
|
|
141
|
+
image: ghcr.io/huynle/hub:latest
|
|
142
|
+
restart: unless-stopped
|
|
143
|
+
ports: ["127.0.0.1:4097:4097"] # your reverse proxy terminates HTTPS in front of this
|
|
144
|
+
environment:
|
|
145
|
+
HUB_PUBLIC_URL: https://hub.example.com
|
|
146
|
+
# OPENROUTER_API_KEY: sk-or-… # optional, for Voice; keys can be set in the app instead
|
|
147
|
+
volumes: ["hub-data:/data"]
|
|
148
|
+
volumes:
|
|
149
|
+
hub-data:
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The image sets `HUB_HOST=0.0.0.0`, `HUB_PORT=4097` and `HUB_STATE_DIR=/data`. Build it locally with
|
|
153
|
+
`docker build -t hub .`.
|
|
154
|
+
|
|
155
|
+
**bunx / from source.** `bunx --package @huynle/hub hub-server` (needs Bun ≥ 1.3), or in
|
|
156
|
+
a checkout: `npm install --no-audit --no-fund && bun bin/hub.ts`. `scripts/hub-local.sh` runs it in the
|
|
157
|
+
foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd template. The
|
|
158
|
+
`opencode-hub` and `opencode-voice-hub` commands still work as aliases of `hub-server`.
|
|
159
|
+
|
|
160
|
+
**Security:** the hub can drive your OpenCode sessions, which can run tools. Every API call needs a
|
|
161
|
+
paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public
|
|
162
|
+
hostname, consider [sign-in with Google](#multi-user-sign-in-with-google) or an authenticating proxy in
|
|
163
|
+
front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, the manifest and icons reachable for
|
|
164
|
+
spokes, sign-in and the PWA.
|
|
165
|
+
|
|
166
|
+
## Configuration
|
|
167
|
+
|
|
168
|
+
- **Hub**: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`, `OPENROUTER_API_KEY`, …)
|
|
169
|
+
over `~/.config/hub/hub.json` (`$HUB_CONFIG`); see [Central server](#central-server) and
|
|
170
|
+
[hub.json](#hubjson). Speech providers and keys are easiest to set in the app (Settings → Speech).
|
|
171
|
+
- **Plugin** (OpenCode spoke): `~/.config/hub/config.json` (`$HUB_SPOKE_CONFIG`; server, mode, machine
|
|
172
|
+
token, voice model…), written by `/hub`; see [Spoke](#spoke-opencode-plugin).
|
|
173
|
+
- The former `VOICE_*`, `OPENCODE_VOICE_CONFIG` and `OPENCODE_HUB_CONFIG` names and the `opencode-voice`
|
|
174
|
+
and `opencode-hub` directories still work; see
|
|
175
|
+
[Migrating from opencode-voice or opencode-hub](#migrating-from-opencode-voice-or-opencode-hub).
|
|
176
|
+
|
|
177
|
+
## How it works
|
|
178
|
+
|
|
179
|
+
The hub serves the web app, push and (for Voice) speech, and talks to harnesses only through
|
|
180
|
+
spokes over one protocol (`src/protocol.ts`). The OpenCode plugin is the first spoke: it runs in every
|
|
181
|
+
OpenCode process (the background service and each `opencode --standalone`), connects **outbound** to the
|
|
182
|
+
hub over a WebSocket, lists sessions, forwards compact events, and runs prompts, stops and permission
|
|
183
|
+
replies on the hub's request.
|
|
184
|
+
|
|
185
|
+
## Remote console (hub API)
|
|
186
|
+
|
|
187
|
+
Every session a connected process of yours reports can be inspected and controlled; Voice only gates
|
|
188
|
+
conversation mode and utterances (403 `not_exposed` for sessions not shared with Voice in `session` mode).
|
|
189
|
+
|
|
190
|
+
| Route | Result |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead. Answers `{mode: "turn" \| "steer"}`. `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" |
|
|
193
|
+
| `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) |
|
|
194
|
+
| `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) |
|
|
195
|
+
| `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 |
|
|
196
|
+
| `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 |
|
|
197
|
+
| `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
|
|
198
|
+
| `GET /api/push/prefs?endpoint=` | `{prefs, quietHours, muted, subscriptions, subscribed?}`: this device's toggles (`handoff, question, permission, finished, failed, say`) and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds `endpoint` |
|
|
199
|
+
| `PUT /api/push/prefs {toggles…, quietHours?}` | boolean toggles (a 0.5.0 `blocked` sets question and permission); `quietHours` `{start: "HH:MM", end: "HH:MM", timeZone}` or `null` (400 when invalid) |
|
|
200
|
+
| `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 |
|
|
201
|
+
| `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 |
|
|
202
|
+
| `PUT /api/push/mute {sessionID, muted}` | mutes or unmutes a session's notifications for the user: `{muted}` |
|
|
203
|
+
|
|
204
|
+
## Spoke (OpenCode plugin)
|
|
205
|
+
|
|
206
|
+
The plugin is a thin client ("spoke") of the hub. Every OpenCode process that loads
|
|
207
|
+
it opens **one** outbound WebSocket (`/spoke/ws`, protocol in `src/protocol.ts`), lists the sessions it
|
|
208
|
+
knows, forwards their events, and runs turns, stops and permission replies on the hub's request.
|
|
209
|
+
The web app, speech and phones live on the hub.
|
|
210
|
+
|
|
211
|
+
| Entry | File | Role |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| 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 |
|
|
214
|
+
| 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`) |
|
|
215
|
+
| 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` |
|
|
216
|
+
|
|
217
|
+
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`
|
|
218
|
+
`main` is ignored), and an installed package through its `exports` (`./server`, `./tui`, `./rpc`).
|
|
219
|
+
|
|
220
|
+
- **Config**: `~/.config/hub/config.json` (or `$HUB_SPOKE_CONFIG`), shared by all
|
|
221
|
+
processes on the machine, mode 0600, watched live. Holds the server URL, mode, machine token,
|
|
222
|
+
`subagent` (the agent conversation mode delegates to; default `general`, `[A-Za-z0-9_.-]+`),
|
|
223
|
+
`autoStartLocalHub` (default `true`; see [Local only](#local-only)), `herdr` (default `true`; read open
|
|
224
|
+
sessions and workspaces from [Herdr](#open-sessions-herdr-and-tuis)), and `list` (the history window,
|
|
225
|
+
default `{"days": 30, "max": 100}`; days 1–365, max 1–500; out-of-range values fall back per field).
|
|
226
|
+
- **Modes**: `off` (no connection), `always` (every session this process knows, Voice on for all),
|
|
227
|
+
`session` (Voice only for sessions switched on in `/hub` or from the phone, plus their subagent
|
|
228
|
+
sessions). Every reported session can be inspected and controlled in both modes; in `session` mode the
|
|
229
|
+
others are listed with `exposed: false` so the phone can offer "Enable voice", and only Voice
|
|
230
|
+
(conversation mode, utterances) is refused for them.
|
|
231
|
+
- **/hub** configures the hub connection. Before this machine is enrolled (or after the hub revoked it),
|
|
232
|
+
**Set up Hub**: On this computer (local hub, every session or only chosen ones), Connect to a
|
|
233
|
+
hub… (URL checked with `hub.checkServer`, i.e. `GET <url>/healthz`, then device-code approval in the
|
|
234
|
+
browser), or Not now; a configured remote server comes first as "Connect to <host>". The decisions are
|
|
235
|
+
pure functions in `src/setup.ts`. Once set up, the title shows the server and connection state, and the
|
|
236
|
+
menu has: **Server** (keep it, Change server URL… with the same reachability check, Use a hub on this
|
|
237
|
+
computer, Set up again…), **Start local hub** (a local server that is not answering), **This machine:
|
|
238
|
+
approved / waiting for approval / revoked**, **Mode**, **This session on/off** (mode "This session
|
|
239
|
+
only"), **Pair a phone**, **Open hub in browser** (home page via a one-time sign-in link; starts a local
|
|
240
|
+
hub first when it is down, `hub.startLocalHub`), and for a shared session **Send to phone** and
|
|
241
|
+
**Conversation mode on/off**.
|
|
242
|
+
- **/voice** (no menu; `src/voice-command.ts`): with a session on screen, it (1) runs the /hub setup when
|
|
243
|
+
the machine is not set up (then continues once connected; a hub that still waits for approval says to run
|
|
244
|
+
/voice again), asks for a mode when sharing is off, and connects (starting a local hub when needed); (2)
|
|
245
|
+
shares the session for Voice (`hub.setSession` in "This session only" mode); (3) opens
|
|
246
|
+
`<hub>/s/<session>?voice=1` through a one-time sign-in link (`hub.openLink {sessionID, voice: true}`).
|
|
247
|
+
The page turns Voice mode on for that session (turning it off in any other session first) and removes
|
|
248
|
+
`?voice=1` from the address. When the browser will not start audio without a tap (a link opened from the
|
|
249
|
+
terminal), the page shows **Tap to start Voice mode** instead of an error. The `voice` agent keeps its
|
|
250
|
+
name.
|
|
251
|
+
- **One-time links** (`hub.openLink`, `sessionID` and `voice` optional): `<hub>/login?code=…&next=/s/<id>`
|
|
252
|
+
(with `voice`: `next=/s/<id>?voice=1`) or `next=/`; `/login` accepts no other `next`. While speech is not
|
|
253
|
+
usable (see `ready` below) the hub redirects to `<next>#speech`, and the page opens Settings → Speech.
|
|
254
|
+
- One active spoke per process (`globalThis` singleton); the shared background service
|
|
255
|
+
(`opencode serve --service`) also lists its root sessions from the last `list.days` days, at most
|
|
256
|
+
`list.max`, newest first (no subagent children, nothing archived; `src/spoke/listing.ts`), plus sessions
|
|
257
|
+
that are always listed whatever their age: open in a terminal, running or blocked, in conversation mode,
|
|
258
|
+
and voice-enabled sessions in `session` mode (fetched one by one when the window misses them). Each comes
|
|
259
|
+
with its workspace: `project` = the OpenCode project root
|
|
260
|
+
(worktrees map to their repository), else the directory minus its `subpath`; `projectName` = the
|
|
261
|
+
project name or the root's basename. It re-lists every 60 s and 2 s after `session.created/renamed/deleted`.
|
|
262
|
+
Snapshots are capped at 500 sessions / 900 kB (exposed, then most recent, are kept).
|
|
263
|
+
- **Session browser ops**: `expose {sessionID, on}` (any process, only sessions it reported; in `session`
|
|
264
|
+
mode it edits the config's session list, which every process watches; `always` answers `exposed: true`)
|
|
265
|
+
and `create {directory, title?, herdrWorkspace?}` (service process only; `directory` must be a reported
|
|
266
|
+
workspace root or session directory, or a folder of a Herdr pane, else `unknown workspace`; in `session`
|
|
267
|
+
mode the new session is exposed). Both send a fresh snapshot before answering. With `herdrWorkspace` the
|
|
268
|
+
service then opens the session in Herdr (below) and answers `{sessionID, openedIn: "herdr"}`, or
|
|
269
|
+
`{sessionID, warning}` when Herdr failed (the session exists either way).
|
|
270
|
+
|
|
271
|
+
### Open sessions (Herdr and TUIs)
|
|
272
|
+
|
|
273
|
+
Sessions open in a terminal are reported with `open: true` and `terminal` (`src/spoke/herdr.ts`,
|
|
274
|
+
`src/spoke/presence.ts`, `src/tui-presence.ts`):
|
|
275
|
+
|
|
276
|
+
- **Herdr**: the background service's spoke runs `herdr agent list` every 5 s and `herdr workspace list` +
|
|
277
|
+
`herdr pane list` every 30 s (3 s timeout each). Every `opencode` pane with a session ID is open with
|
|
278
|
+
`terminal: {source: "herdr", workspaceID, workspace (label), title (pane title without "OC | "),
|
|
279
|
+
status}`. It sends a `terminals` frame with the Herdr workspaces in Herdr's order, each with its pane
|
|
280
|
+
folders, most used first. No `herdr`, its server down, or `"herdr": false`: no Herdr data, silently.
|
|
281
|
+
Standalone processes never read Herdr (one reader per machine); a machine without a background service
|
|
282
|
+
only gets TUI presence.
|
|
283
|
+
- **TUIs**: the TUI plugin reports the root of the session it shows and its open tabs (when session tabs
|
|
284
|
+
are on) to its server's spoke with `hub.presence({sessionIDs, reporter})` on change (polled every 2 s)
|
|
285
|
+
and every 15 s. A session is open while some TUI reported it within 45 s (`terminal: {source: "tui"}`);
|
|
286
|
+
a TUI's report replaces its previous one, so a closed tab stops counting at once. Herdr wins when both
|
|
287
|
+
apply.
|
|
288
|
+
- **Opening in Herdr** (`create` with `herdrWorkspace`): `herdr tab create --workspace <id> --cwd <dir>
|
|
289
|
+
[--label <title>] --no-focus`, then in its root pane `herdr agent start <title-slug>-<id-tail> --kind
|
|
290
|
+
opencode --pane <pane> --timeout 20000 -- --session <sessionID>`. The hub waits up to 45 s for this op.
|
|
291
|
+
|
|
292
|
+
## Conversation mode (OpenCode side)
|
|
293
|
+
|
|
294
|
+
In conversation mode the session's main agent becomes a voice orchestrator: it answers in one or two
|
|
295
|
+
spoken sentences, handles quick answers and up to two low-risk tool calls directly, and hands multi-step
|
|
296
|
+
work to a background subagent. Routine workers inherit the fast voice model; coding, debugging,
|
|
297
|
+
architecture and risky work use the configured full work model. Successful workers finish with a short
|
|
298
|
+
speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You
|
|
299
|
+
can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start
|
|
300
|
+
a fresh one.
|
|
301
|
+
|
|
302
|
+
The phone composer exposes separate **Voice** and **Worker** model controls for each session. Each picker
|
|
303
|
+
lists the connected OpenCode process's available models and variants. Choices are saved per user and
|
|
304
|
+
session, apply to the next turn or newly started worker, and fall back to the machine's `voiceModel` and
|
|
305
|
+
`workModel` when unset.
|
|
306
|
+
|
|
307
|
+
The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear
|
|
308
|
+
between messages as compact expandable rows. Reopening a session pages its projected message history from
|
|
309
|
+
OpenCode. Expanding a tool fetches its complete input/output from the owning OpenCode process; the hub does
|
|
310
|
+
not persist those potentially sensitive details.
|
|
311
|
+
|
|
312
|
+
**Spoke side** (`src/spoke/conversation.ts`, `src/spoke/spoke.ts`):
|
|
313
|
+
|
|
314
|
+
- The plugin registers a primary agent **`voice`** through `ctx.agent.transform` (the agent editor's
|
|
315
|
+
`update()` creates missing agents). Its system prompt is the orchestrator prompt; it inherits the
|
|
316
|
+
default `build` agent's permissions so your rules still apply. A `voice` agent you define yourself
|
|
317
|
+
(e.g. `~/.config/opencode/agents/voice.md`) takes precedence and is left untouched.
|
|
318
|
+
- `conversation on` remembers the session's current agent and switches it to `voice`; `off` switches
|
|
319
|
+
back (or to the default agent), unless you picked another agent in the meantime. If no `voice`
|
|
320
|
+
agent exists, the session keeps its agent and the `context` hook adds the orchestrator prompt instead.
|
|
321
|
+
- The delegate agent (`subagent` in the config) is added per request by the `context` hook, so changing
|
|
322
|
+
it needs no restart.
|
|
323
|
+
- Child text and execution events are paired by the hub. The latest assistant message of a successful
|
|
324
|
+
child is emitted as root-session speech; failures, interruptions and stale partial results are not.
|
|
325
|
+
- Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
|
|
326
|
+
"conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
|
|
327
|
+
"opencode-hub"` and 0.4 and earlier `source: "opencode-voice"`, and readers accept all three) and returns the inbox ID at once; `interrupt`
|
|
328
|
+
interrupts the session; `conversation` turns the mode on/off and returns the agent now in effect, and
|
|
329
|
+
is refused for sessions not shared with Voice. Replies reach the hub as forwarded
|
|
330
|
+
`session.text.ended`, `session.inbox.delivered` and `session.agent.selected` events.
|
|
331
|
+
- State is per process and in memory; the hub re-sends `conversation on` after a reconnect.
|
|
332
|
+
- From the terminal: `/voice` opens the current session in the browser with Voice mode starting; `/hub →
|
|
333
|
+
Conversation mode` toggles conversation mode only (same code path as the phone), then offers to send the
|
|
334
|
+
session to your phone.
|
|
335
|
+
|
|
336
|
+
## Central server
|
|
337
|
+
|
|
338
|
+
The hub (`bin/hub.ts`, `src/central/`) serves the web app, pairs devices, enrolls machines, routes turns
|
|
339
|
+
to the right OpenCode process, does speech for Voice, and sends web push notifications.
|
|
340
|
+
|
|
341
|
+
See [Running the hub](#running-the-hub) for the binaries, the Docker image and bunx. `src/central/main.ts`
|
|
342
|
+
holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries embed it through
|
|
343
|
+
`src/central/assets.ts`, generated entry `scripts/gen-web-entry.ts`).
|
|
344
|
+
|
|
345
|
+
| Env | Default | |
|
|
346
|
+
|---|---|---|
|
|
347
|
+
| `HUB_CONFIG` | `~/.config/hub/hub.json` | optional config file ([Local only](#local-only)); env vars win over it |
|
|
348
|
+
| `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
349
|
+
| `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
|
|
350
|
+
| `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 |
|
|
351
|
+
| `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) |
|
|
352
|
+
| `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
|
|
353
|
+
| `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
|
|
354
|
+
| `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
|
|
355
|
+
| `HUB_STT_MODEL`, `HUB_TTS_MODEL`, `HUB_VOICE`, `HUB_MAX_*` | see `src/central/config.ts` | speech defaults; the phone's speech settings override them |
|
|
356
|
+
|
|
357
|
+
The Docker image installs only the hub's runtime packages (web-push and its dependencies, pinned by
|
|
358
|
+
`package-lock.json`; see `docker/hub-deps.ts`).
|
|
359
|
+
|
|
360
|
+
**First device**: while nothing is paired the hub logs a 6-digit code; open `<public URL>/pair` and enter
|
|
361
|
+
it. Later devices get codes from `/hub → Pair a phone`. **Machines**: `/hub → Connect to a hub…`
|
|
362
|
+
shows a code; approve it on the phone (`/approve?code=…`, or Settings → Machines waiting). A local hub
|
|
363
|
+
approves the very first machine by itself (see [Local only](#local-only)).
|
|
364
|
+
|
|
365
|
+
**Turn routing**: a process that owns the session (viewed or ran it last) → a service process that reports
|
|
366
|
+
it → the service process of the reporting machine → otherwise the reporting process with the most
|
|
367
|
+
recent activity.
|
|
368
|
+
|
|
369
|
+
### Phone app (PWA)
|
|
370
|
+
|
|
371
|
+
`web/` is the Hub console, a Solid + Vite + TypeScript PWA built to `web/dist` (`npm run build:web`;
|
|
372
|
+
the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
|
|
373
|
+
|
|
374
|
+
- **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
|
|
375
|
+
terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
|
|
376
|
+
title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
|
|
377
|
+
(pin, hide, stop, Allow/Disallow Voice).
|
|
378
|
+
- **`/`**: All sessions as a sortable, filterable table (a list on phones); filters and sort are kept per device.
|
|
379
|
+
There is no Voice column or Voice filter: the live Voice session gets the green dot before its title, and the
|
|
380
|
+
row's `⋯` menu has **Allow Voice** / **Disallow Voice**.
|
|
381
|
+
- **`/s/<id>`**: the transcript (markdown replies, "Thought for Ns", "Used N tools" with full details on demand,
|
|
382
|
+
subagent rows linking to their sessions), permission cards, and the composer: Send while idle, Steer while the
|
|
383
|
+
session tree runs, Stop for the whole tree, Dictate (into this session's draft; leaving the session while
|
|
384
|
+
recording cancels it; each session keeps its own unsent draft), Voice mode (hands-free, see below; a toggle that
|
|
385
|
+
turns Voice on first when needed; one session at a time, so turning it on elsewhere turns the first one off; it
|
|
386
|
+
keeps running while you look at other sessions, which show a "Voice: <session>" pill with End) with a
|
|
387
|
+
microphone picker, and the Voice/Worker model menus. The right rail (a bottom sheet on phones) has
|
|
388
|
+
cards that collapse from their header (remembered per device): **Environment** (machine with
|
|
389
|
+
online/offline/Connecting, project folder with the full path as tooltip, branch, and where it is open:
|
|
390
|
+
TUI, Herdr or background), Context, **Connectors · N connected** (MCP servers) and Changes with a diff
|
|
391
|
+
viewer. Every session works with Voice
|
|
392
|
+
off; Voice only adds speech.
|
|
393
|
+
- **`/new`**, **`/settings`** (account, notifications, Voice, Speech, machines and approvals, devices, admin),
|
|
394
|
+
**`/pair`**, **`/approve`**.
|
|
395
|
+
|
|
396
|
+
Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.test.ts`).
|
|
397
|
+
|
|
398
|
+
**Accounts** (multi-user hub, contract in [docs/multi-user.md](docs/multi-user.md); pure logic in
|
|
399
|
+
`web/account-core.js`, tested with bun):
|
|
400
|
+
|
|
401
|
+
- **Signing in**: with `HUB_AUTH=oidc` the sign-in screen shows **Sign in with Google**
|
|
402
|
+
(`/auth/login?next=<page>`), with the pairing code behind "Have a pairing code?" (a code signs the browser
|
|
403
|
+
in as the owner of the computer that made it). In pairing mode the pair screen is unchanged.
|
|
404
|
+
- **Account menu** (avatar in the header): name and email (or "This hub" in pairing mode), an Admin badge,
|
|
405
|
+
My devices, Speech, Admin (admins only) and Sign out (`POST /api/logout`, then the sign-in screen).
|
|
406
|
+
- **Speech** is per user: each field you have not set shows "Using server default: …" (keys: "Key: server
|
|
407
|
+
default"), and each card has **Reset to server default** (sends `null` for every field and the key).
|
|
408
|
+
Admins also get **Server default** (`/api/settings/speech/default`), used by everyone for what they have
|
|
409
|
+
not set themselves.
|
|
410
|
+
- **Admin** (in Settings, admins only): **Users** (search, filter, sort; make/remove admin and
|
|
411
|
+
disable/enable with a confirmation; the hub refuses to demote or disable the last active admin and the
|
|
412
|
+
page shows its message; not shown in pairing mode), **Machines** (every machine with its owner; Revoke),
|
|
413
|
+
**Approvals** (pending machines; approving makes the machine yours) and the **Audit log** (newest
|
|
414
|
+
first, e.g. "Approved a machine", "Changed a role").
|
|
415
|
+
- **Approving a machine** (`/approve?code=`) says "This machine will belong to you (<email>)". Only admins
|
|
416
|
+
see the list of pending requests; everyone else approves by the code OpenCode shows.
|
|
417
|
+
- A session link that is not yours (or is gone) shows **Session not found** with a link home.
|
|
418
|
+
|
|
419
|
+
**Session browser** (`/`; contract in [docs/session-view.md](docs/session-view.md); pure logic in
|
|
420
|
+
`web/src/core/view-core.ts` and `web/src/core/browser-core.ts`, tested with bun). Three tabs with counts:
|
|
421
|
+
|
|
422
|
+
- **Open** (default): what is open in your terminals right now, like Herdr's sidebar. A **Needs you**
|
|
423
|
+
strip first (blocked sessions from every machine, open or not, with the blocker and an Open button),
|
|
424
|
+
then one group per Herdr workspace (in Herdr's order), then one per project folder for sessions open in
|
|
425
|
+
an OpenCode TUI. Rows are compact: status dot (Herdr's agent status combined with the session state:
|
|
426
|
+
blocked > working > error > idle), **workspace** · title, and `opencode · folder` or what it is doing
|
|
427
|
+
now (`shell · bun test`, `Thinking…`). Within a group: pinned, then blocked, working, idle, then most
|
|
428
|
+
recent; pinned groups come first. Sessions that are not open but working or in conversation mode are
|
|
429
|
+
listed under **Not in a terminal**. With nothing open: "No sessions open in a terminal" with links to
|
|
430
|
+
Recent and New session. Machine headers appear only when several machines are online; offline machines
|
|
431
|
+
are one line at the end.
|
|
432
|
+
- **Recent**: everything else the machine reported (its history window), grouped by workspace with
|
|
433
|
+
nested worktrees and subfolders, with search (title, project name, directory; case- and
|
|
434
|
+
diacritic-insensitive, all words must match, matches highlighted; opens every group) and filter chips
|
|
435
|
+
All · Active · Needs you · Today · Enabled. Groups that are pinned, active, blocked or changed
|
|
436
|
+
within a day start open; only open groups render rows, 50 at a time ("Show more"). A **Standalone**
|
|
437
|
+
section lists standalone processes.
|
|
438
|
+
- **Hidden**: hidden workspaces (with how many sessions they hide) and hidden sessions, each with Unhide.
|
|
439
|
+
- **Menus**: ⋯ on a row (or long-press / right-click it): Open, Pin to top / Unpin, Hide / Unhide. ⋯ on a
|
|
440
|
+
group: New session here, Pin / Unpin workspace, Hide / Unhide workspace (and Collapse / Expand in
|
|
441
|
+
Recent). Hiding a workspace hides the sessions in it: a Herdr workspace (`herdr:<id>`), a project
|
|
442
|
+
folder, or a nested folder such as a worktree.
|
|
443
|
+
- **Synced view settings**: pins and hides are per user on the hub (`GET`/`PUT /api/prefs/view`), shown at
|
|
444
|
+
once, reverted with a message if the hub refuses, with Undo after a hide. Other devices update live
|
|
445
|
+
(SSE `prefs`). Pins from 0.3 (stored on the device) are moved into them once. With an older hub
|
|
446
|
+
without `/api/prefs/view`, they stay on the device. Open/closed groups and the filter stay per device.
|
|
447
|
+
- **Voice off** sessions (`exposed: false`, mode `session`) open, show their status and live activity, and
|
|
448
|
+
take Send, Steer, Stop and permission replies like any other session. Only Voice is off: the row's ⋯
|
|
449
|
+
menu has **Allow Voice** (`POST /api/sessions/:id/expose {on:true}`; shown at once, reverted on
|
|
450
|
+
error), and Voice mode on such a session turns Voice on first.
|
|
451
|
+
- **New session** (`/new`; + in the header, + on any group, or a group's ⋯ menu), composer first: "Describe
|
|
452
|
+
a task or ask a question" (images can be attached), and under it where it runs: **Environment** (your
|
|
453
|
+
machines on this hub: online first with their process and OpenCode version, offline ones listed but not
|
|
454
|
+
selectable; ✓ on the chosen one), **Project** (a popover with search: Herdr workspaces with their pane
|
|
455
|
+
folders, then known folders), the folder's **branch** (from the summary of the latest session in that
|
|
456
|
+
folder, when there is one) and **Open in Herdr tab** (a Herdr workspace on a machine that runs Herdr; on
|
|
457
|
+
by default). Submitting sends `POST /api/machines/:id/sessions {directory, herdrWorkspace?}`, opens the
|
|
458
|
+
new session and sends the prompt (and images) as its first turn (`POST /api/sessions/:id/turn`). The last
|
|
459
|
+
environment and folder are remembered on the device (`localStorage` `hub.newSession`); the group "+"
|
|
460
|
+
preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
|
|
461
|
+
failed, the session still exists and the hub's `warning` is shown. 409 (background service not
|
|
462
|
+
connected) is shown under the composer.
|
|
463
|
+
|
|
464
|
+
**Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks what
|
|
465
|
+
it wants (Settings → Notifications): **a session asks a question**, **needs permission**, **failed**,
|
|
466
|
+
**finished** (a root session's run succeeded, except voice turns started from the page), **hand-offs from the
|
|
467
|
+
terminal**, and **Voice replies when you're not watching** (pref `say`: spoken replies while no device shows
|
|
468
|
+
the session). Questions, permissions, hand-offs and Voice replies are on by default; finished and failed are
|
|
469
|
+
opt-in (0.5.0's single "blocked" toggle became question + permission, and its "finished" became finished +
|
|
470
|
+
failed, migrated on start). Optional **quiet hours** per device (local start/end time in the browser's time
|
|
471
|
+
zone) hold everything except hand-offs; **Mute notifications** in a session's ⋯ menu silences one session
|
|
472
|
+
for you on every device (hand-offs still come through). At most one per session and kind every 30 s
|
|
473
|
+
(Voice replies: 10 s); expired subscriptions (404/410) are pruned.
|
|
474
|
+
|
|
475
|
+
- **Status per device**: Settings → Notifications shows the browser permission, whether the service worker
|
|
476
|
+
is registered and whether this hub holds this browser's subscription. "Allowed, but this device is not
|
|
477
|
+
subscribed on this hub" means nothing will arrive (typical after the hub moved to a new address): **Enable
|
|
478
|
+
notifications on this device** fixes it. When permission is granted, the app re-subscribes by itself on
|
|
479
|
+
load if the hub lost the subscription, and the service worker re-subscribes when the browser rotates it
|
|
480
|
+
(`pushsubscriptionchange`). **Send test notification** sends one to this device only and shows the result
|
|
481
|
+
(sent, or the push service's status).
|
|
482
|
+
- **Delivery**: questions, permissions and failures are sent with `urgency: high` and a TTL of one hour so a
|
|
483
|
+
phone in doze gets them; they stay on screen until handled (`requireInteraction`), vibrate, and replace the
|
|
484
|
+
previous one of the same session and kind. A permission notification asks the hub (with the device's
|
|
485
|
+
cookie, from the service worker) what the request is, shows it, and offers **Allow once** / **Reject**;
|
|
486
|
+
the payload itself never carries it. Payloads are `{kind, sessionID, title, body, requestID?}`.
|
|
487
|
+
- **Android (Chrome)**, for notifications that arrive with the screen off: open the public URL, sign in or
|
|
488
|
+
pair, then Chrome's menu → **Install app** (or Add to Home screen) and use the installed app; Settings →
|
|
489
|
+
Notifications → **Enable notifications on this device** and **Send test notification**. In Android
|
|
490
|
+
settings → Apps → Chrome (and the installed app, if listed): Notifications allowed (all categories), and
|
|
491
|
+
**Battery → Unrestricted** (not "Optimized"), so Chrome may receive pushes while dozing. Notifications of
|
|
492
|
+
an app you have not opened for a long time can be delayed by Android's adaptive battery.
|
|
493
|
+
- **iPhone/iPad**: web push needs iOS/iPadOS 16.4+ and the app added to the Home Screen
|
|
494
|
+
(Share → Add to Home Screen); open it from the icon, then enable notifications. Audio replies need a
|
|
495
|
+
tap first (the mic or Send button unlocks playback).
|
|
496
|
+
- Microphone and push need a secure context: HTTPS, or `http://localhost` / `127.0.0.1` for testing.
|
|
497
|
+
|
|
498
|
+
## Multi-user (sign in with Google)
|
|
499
|
+
|
|
500
|
+
One hub can serve several people. Each person signs in with Google (or any OpenID Connect provider)
|
|
501
|
+
and sees and controls only their own machines, sessions, phones and speech settings. Without it
|
|
502
|
+
(`HUB_AUTH=pairing`, the default) the hub has one owner, exactly as before. The full contract is in
|
|
503
|
+
[docs/multi-user.md](docs/multi-user.md).
|
|
504
|
+
|
|
505
|
+
**Google Cloud console**
|
|
506
|
+
|
|
507
|
+
1. APIs & Services → OAuth consent screen: set it up (External or Internal; scopes `openid`, `email`,
|
|
508
|
+
`profile` are enough).
|
|
509
|
+
2. APIs & Services → Credentials → Create credentials → OAuth client ID → **Web application**.
|
|
510
|
+
3. Authorized redirect URI: `<public URL>/auth/callback`, e.g. `https://hub.example.com/auth/callback`
|
|
511
|
+
(Google wants https, except for `localhost`).
|
|
512
|
+
4. Copy the client ID and client secret into the hub's environment or `hub.json`.
|
|
513
|
+
|
|
514
|
+
**Configuration** (env vars win over `hub.json`'s `auth` object):
|
|
515
|
+
|
|
516
|
+
| Env | hub.json | |
|
|
517
|
+
|---|---|---|
|
|
518
|
+
| `HUB_AUTH` | `auth.mode` | `pairing` (default) or `oidc` |
|
|
519
|
+
| `HUB_OIDC_ISSUER` | `auth.issuer` | default `https://accounts.google.com`; must be https |
|
|
520
|
+
| `HUB_OIDC_CLIENT_ID` | `auth.clientId` | required |
|
|
521
|
+
| `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` |
|
|
522
|
+
| `HUB_OIDC_ALLOWED` | `auth.allowed` | required: who may sign in, emails and/or `@domain` entries (comma list, or an array in hub.json) |
|
|
523
|
+
| `HUB_ADMIN_EMAILS` | `auth.admins` | at least one admin email (admins are always allowed) |
|
|
524
|
+
|
|
525
|
+
```sh
|
|
526
|
+
HUB_PUBLIC_URL=https://hub.example.com \
|
|
527
|
+
HUB_AUTH=oidc \
|
|
528
|
+
HUB_OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
|
|
529
|
+
HUB_OIDC_CLIENT_SECRET=GOCSPX-… \
|
|
530
|
+
HUB_OIDC_ALLOWED=alice@example.com,bob@example.com,@example.org \
|
|
531
|
+
HUB_ADMIN_EMAILS=alice@example.com \
|
|
532
|
+
hub-server
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```json
|
|
536
|
+
{
|
|
537
|
+
"publicURL": "https://hub.example.com",
|
|
538
|
+
"auth": {
|
|
539
|
+
"mode": "oidc",
|
|
540
|
+
"clientId": "1234567890-abc.apps.googleusercontent.com",
|
|
541
|
+
"clientSecret": "GOCSPX-…",
|
|
542
|
+
"allowed": ["bob@example.com", "@example.org"],
|
|
543
|
+
"admins": ["alice@example.com"]
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
An invalid or incomplete `oidc` setup stops the hub with the reason. The startup log shows the issuer,
|
|
549
|
+
the redirect URI to register and how many allow-list entries and admins there are.
|
|
550
|
+
|
|
551
|
+
**Who may sign in**: an email on the allow-list, any address of an `@domain` entry (exactly that domain,
|
|
552
|
+
not its subdomains), or an admin. The provider must report the email as verified. There is no open
|
|
553
|
+
sign-up. Others get a "Not allowed" page (and an audit entry).
|
|
554
|
+
|
|
555
|
+
**Admins** manage users (make admin, disable), everyone's machines and devices, the server default
|
|
556
|
+
speech settings and pending machine enrollments, and read the audit log (Account → Admin). They do
|
|
557
|
+
**not** see other users' sessions. Emails in `HUB_ADMIN_EMAILS` become admins when they sign in; the
|
|
558
|
+
last active admin cannot be demoted or disabled. Disabling a user signs out all their devices and
|
|
559
|
+
disconnects their machines at once.
|
|
560
|
+
|
|
561
|
+
**Ownership**: a machine belongs to whoever approves its enrollment code; pairing codes and one-time
|
|
562
|
+
login links from `/hub` belong to the machine's owner. The bootstrap pairing code and the local
|
|
563
|
+
first-machine auto-approve exist only in `pairing` mode.
|
|
564
|
+
|
|
565
|
+
**Speech**: each user can set their own provider, models, voice and key (Settings → Speech); fields they
|
|
566
|
+
leave empty come from the server default (admins: Settings → Speech → Server default), then `hub.json`,
|
|
567
|
+
then the env. A user's key is only ever shown (masked) to that user.
|
|
568
|
+
|
|
569
|
+
**Migrating a single-owner hub**: existing machines and devices belong to the built-in owner `local`.
|
|
570
|
+
The first time an admin signs in, all of them move to that admin (audit `migrate.local`) and `local` is
|
|
571
|
+
disabled. Nothing else changes; a running spoke stays connected.
|
|
572
|
+
|
|
573
|
+
**Security notes**
|
|
574
|
+
|
|
575
|
+
- `/auth/login` and `/auth/callback` must be reachable from browsers (an auth proxy in front of the hub
|
|
576
|
+
is still optional, but must let `/auth/*`, `/spoke/*` and the app through).
|
|
577
|
+
- Sign-in uses the authorization code flow with PKCE (S256), `state` and `nonce`; the ID token comes
|
|
578
|
+
straight from the provider's token endpoint over TLS (client_secret_post) and its claims are checked
|
|
579
|
+
(`iss`, `aud`/`azp`, `exp`, `nonce`, `email_verified`).
|
|
580
|
+
- Another user's sessions, machines and devices answer 404 (like unknown ones), so IDs don't leak; SSE
|
|
581
|
+
events and push notifications only go to the owner.
|
|
582
|
+
- `HUB_OIDC_INSECURE_ISSUER=1` allows a plain-http issuer on loopback. It exists for local
|
|
583
|
+
development and tests with a fake provider only; never set it in production.
|
|
584
|
+
|
|
585
|
+
## Session browser (hub)
|
|
586
|
+
|
|
587
|
+
Catalog sessions (`GET /api/machines` → `machines[].processes[].sessions[]`, SSE `catalog`) carry, besides
|
|
588
|
+
the reported `id, title, directory, parentID?, updated, owned, status, busy, conversation`:
|
|
589
|
+
`exposed` (any process exposes it), `project?`, `projectName?`, `processID`, `machineID`, `state` (whole
|
|
590
|
+
subtree: `blocked` if anything waits for the user, else `error` (own), `working`, `idle`), `blockers[]`
|
|
591
|
+
(`{kind, id, summary, sessionID, sessionTitle}` for the subtree) and `now?` (`{kind: "tool" | "thinking",
|
|
592
|
+
tool?, summary?}`, the latest running activity item), and `open?` / `terminal?` as the process reported them
|
|
593
|
+
(`GET /api/sessions/:id`: open from any reporting process, Herdr first). Machines carry `terminals?:
|
|
594
|
+
{herdr?: {workspaces: [{id, label, directories}]}}`, the latest `terminals` frame of the machine, while
|
|
595
|
+
the process that sent it is connected. `now` and `state` are snapshots: the phone follows
|
|
596
|
+
SSE `activity` and `status` for live changes. Every reported session has a status board, Voice on or
|
|
597
|
+
off: unexposed sessions report `status`, `state`, `blockers`, `now` and activity like exposed ones. An
|
|
598
|
+
unchanged sessions frame does not re-broadcast the catalog.
|
|
599
|
+
|
|
600
|
+
| Route | Spoke op | Result |
|
|
601
|
+
|---|---|---|
|
|
602
|
+
| `POST /api/sessions/:id/expose {on}` | `expose` | `200 {exposed}` from a reporting process (service first). 400 bad body, 404 not reported, 502 spoke error |
|
|
603
|
+
| `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 |
|
|
604
|
+
| `GET /api/prefs/view` | – | `{prefs: {hiddenSessions, hiddenWorkspaces, pinnedSessions, pinnedWorkspaces}}`, the caller's view preferences (pairing mode: the single user) |
|
|
605
|
+
| `PUT /api/prefs/view {any of the four lists}` | – | Replaces the lists given (each ≤ 500 non-empty strings 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) |
|
|
606
|
+
|
|
607
|
+
For a session reported only with `exposed: false`, `utterance` and `conversation` answer
|
|
608
|
+
`403 {error: "Voice is not enabled for this session", code: "not_exposed"}`; inspecting and controlling it
|
|
609
|
+
(timeline, turn, stop, interrupt, permission and form replies, models, context) works as for any other
|
|
610
|
+
session.
|
|
611
|
+
|
|
612
|
+
## Conversation mode (hub and phone)
|
|
613
|
+
|
|
614
|
+
### Hub API
|
|
615
|
+
|
|
616
|
+
All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Every
|
|
617
|
+
`/api/sessions/:id…` route answers `404 {"error": "Not found"}` for a session that none of the caller's
|
|
618
|
+
machines reports (since 0.3.0; before, a session nobody reported was 409), so another user's session looks
|
|
619
|
+
exactly like an unknown one. For a loopback
|
|
620
|
+
public URL, `localhost`, `127.0.0.1` and `[::1]` with the same scheme and port count as the same origin.
|
|
621
|
+
|
|
622
|
+
| Route | Spoke op | Result |
|
|
623
|
+
|---|---|---|
|
|
624
|
+
| `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 |
|
|
625
|
+
| `POST /api/sessions/:id/interrupt` | `interrupt` | `204` |
|
|
626
|
+
| `POST /api/sessions/:id/conversation {on}` | `conversation` | `200 {on, agent}`. 404 when no machine of yours reports the session |
|
|
627
|
+
| `GET /api/sessions/:id` | | `{id, title, directory, parentID?, project?, projectName?, updated, exposed, state, blockers, now?, busy, conversation, status, activity}`; catalog sessions also carry `conversation` |
|
|
628
|
+
| `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 |
|
|
629
|
+
| `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 |
|
|
630
|
+
| `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 |
|
|
631
|
+
|
|
632
|
+
The hub keeps the conversation flag per session in memory. When a process sends a sessions snapshot,
|
|
633
|
+
owns a session in conversation mode, and was not told yet (it reconnected or took over ownership), the
|
|
634
|
+
hub sends it `{op: "conversation", on: true}` once. Deleting the session clears the flag.
|
|
635
|
+
|
|
636
|
+
SSE (`/api/events`) adds:
|
|
637
|
+
|
|
638
|
+
- `{type: "conversation", sessionID, on}` when the flag changes.
|
|
639
|
+
- `{type: "say", sessionID, messageID, ordinal, text}` for each `session.text.ended` of a **root** session
|
|
640
|
+
in conversation mode (subagent text is not spoken); deduped by `(messageID, ordinal)` across
|
|
641
|
+
processes; blank text is skipped.
|
|
642
|
+
- `{type: "heard", sessionID, inboxID}` when OpenCode delivers an utterance's inbox item (also when the
|
|
643
|
+
delivery event arrives before the steer result).
|
|
644
|
+
- `{type: "activity", sessionID, rootID, item}` when an activity item is created or changes state, for
|
|
645
|
+
every session (all of the owner's devices see it, like `status`). `item` is `{id, sessionID, kind: "tool" | "thinking",
|
|
646
|
+
tool?, summary?, background?, state: "running" | "done" | "failed", error?, at}`; `sessionID` is where it
|
|
647
|
+
happened (a subagent session maps to its root `rootID`). The hub keeps the 30 most recent items per
|
|
648
|
+
root (in memory) and returns them, newest last, as `activity` in `GET /api/sessions/:id`. Spokes forward
|
|
649
|
+
tool and reasoning events in compact form (`compactActivity`): tool name, a one-line summary of the
|
|
650
|
+
input (command, path, pattern…), ok/error; never file contents or outputs. Thinking items close on
|
|
651
|
+
`session.reasoning.ended`, when the next activity in that session starts, or when its run ends.
|
|
652
|
+
- `{type: "delta", sessionID, rootID, messageID?, text}` (assistant text) and `{type: "reasoning", sessionID,
|
|
653
|
+
rootID, messageID?, delta}` (reasoning text; spokes cut each delta to 2000 chars and never forward the full
|
|
654
|
+
text of `session.reasoning.ended`) for root **and** subagent sessions. Per session they are coalesced to at
|
|
655
|
+
most one send per 100 ms (the first delta goes out at once, later ones in the window are joined); anything
|
|
656
|
+
else in that session (e.g. the `say` for that text) is sent only after its held text.
|
|
657
|
+
- `status` entries of working sessions carry `since` (ms epoch the current run started), also in the catalog
|
|
658
|
+
and `GET /api/sessions/:id`.
|
|
659
|
+
|
|
660
|
+
Pushes in conversation mode depend on presence: while the session is watched, "finished" is not sent
|
|
661
|
+
(the phone is open and speaking). When nobody watches, "finished" is sent as usual and each spoken text
|
|
662
|
+
of the root session is pushed as kind `say` (title = session title, body = the text, max 180 chars; at
|
|
663
|
+
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
|
|
664
|
+
`HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks.
|
|
665
|
+
|
|
666
|
+
### Speech settings
|
|
667
|
+
|
|
668
|
+
STT and TTS each use a provider configured from the phone (Settings → Speech) and stored in
|
|
669
|
+
`state.json`. Precedence, per direction and field: phone settings > `hub.json` `speech` > the environment
|
|
670
|
+
(`OPENROUTER_API_KEY`, `HUB_STT_MODEL`, `HUB_TTS_MODEL`, `HUB_VOICE`). When the phone picks another
|
|
671
|
+
provider than `hub.json`, the file's section is ignored for that direction; when it picks another base
|
|
672
|
+
URL, the file's key is not sent there. Providers:
|
|
673
|
+
|
|
674
|
+
- `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is JSON with base64 `input_audio`,
|
|
675
|
+
TTS is `POST /audio/speech`.
|
|
676
|
+
- `openai-compatible` (`baseURL` required: `https://…`, or `http://localhost` / `127.0.0.1` for local
|
|
677
|
+
servers): STT is multipart `POST {baseURL}/audio/transcriptions` (`file`, `model`, `language?`), TTS is
|
|
678
|
+
`POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}`. No env key.
|
|
679
|
+
|
|
680
|
+
A TTS `speed` (0.5–2.0) is sent when set; a provider that answers 400 gets one retry without it.
|
|
681
|
+
|
|
682
|
+
| Route | Result |
|
|
683
|
+
|---|---|
|
|
684
|
+
| `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 |
|
|
685
|
+
| `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` |
|
|
686
|
+
| `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 |
|
|
687
|
+
| `POST /api/settings/speech/test {kind: "tts", text?}` | `audio/mpeg` preview with the current settings (text capped at 300 chars). `kind: "stt"` is 501 |
|
|
688
|
+
|
|
689
|
+
Writes need the same-origin `Origin`. Provider failures are 502 with the provider's message.
|
|
690
|
+
|
|
691
|
+
### Conversation mode (phone)
|
|
692
|
+
|
|
693
|
+
On `/s/<id>`, the **Conversation** button next to the mic starts hands-free mode. It turns on the mic
|
|
694
|
+
(echo cancellation, noise suppression and auto gain on), then calls `POST …/conversation {on:true}`, keeps
|
|
695
|
+
the screen awake (Screen Wake Lock, taken again when the page becomes visible) and shows a level ring
|
|
696
|
+
around the mic with the current state: Listening, Hearing you, Transcribing, Sent ✓ and Speaking. Tap
|
|
697
|
+
it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
|
|
698
|
+
`{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
|
|
699
|
+
|
|
700
|
+
- **VAD** (`web/conversation-core.js`, frames from `web/vad-worklet.js`, with a ScriptProcessor fallback):
|
|
701
|
+
energy-based detection on about 20 ms frames. The noise floor falls fast and rises slowly, and the
|
|
702
|
+
threshold is 10 dB above it with 4 dB of hysteresis. Speech must last 250 ms to count, and 300 ms of
|
|
703
|
+
audio before it is kept. The threshold rises by 8 dB while the phone is speaking.
|
|
704
|
+
- **Endpointing**: a segment ends after 550 ms of silence. It is encoded to 16 kHz mono 16-bit WAV
|
|
705
|
+
and sent to `/api/transcribe` at once. A transcript that reads as complete is sent right away:
|
|
706
|
+
it ends in `. ? !`, or has 3+ words and doesn't end in "and", "but", "so", "um", "the" and the like.
|
|
707
|
+
Otherwise it waits up to 1.5 s. If you speak again during the wait, the next segment's transcript
|
|
708
|
+
is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
|
|
709
|
+
- **Commands**: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on"
|
|
710
|
+
calls `POST …/interrupt` and stops speech. "Pause listening" turns the mode off.
|
|
711
|
+
- **Speech out**: each SSE `say` is split into sentences. Abbreviations, decimals, file names and
|
|
712
|
+
list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to `/api/speak`,
|
|
713
|
+
one ahead of playback, and plays through one `<audio>` element. Speech that lasts 250 ms or more
|
|
714
|
+
(barge-in) pauses playback and clears the queue. The transcript shows your utterances with ✓ (sent)
|
|
715
|
+
and ✓✓ (heard). Agent text is merged with the bubble already streamed from deltas. Subagents
|
|
716
|
+
starting, finishing or failing appear as small notes. Typed text is sent as an utterance while the
|
|
717
|
+
mode is on.
|
|
718
|
+
- **Android Chrome** is the main target. Install the app ("Add to Home screen") so it runs in a
|
|
719
|
+
standalone window. By default the screen stays on while listening; Settings → Voice → "Keep
|
|
720
|
+
listening when screen is off" is a best-effort alternative (see below).
|
|
721
|
+
- **iOS caveats**: Safari needs a tap before audio plays (the Conversation tap unlocks it). The Screen
|
|
722
|
+
Wake Lock API needs iOS 16.4+ and, before iOS 18.4, works only in Safari tabs, not in Home Screen
|
|
723
|
+
apps. Locking the screen or switching apps suspends the mic and the AudioContext, so turn the mode
|
|
724
|
+
on again when you come back. Echo cancellation is weaker with the speaker at full volume, so use
|
|
725
|
+
headphones or lower the volume if the phone keeps interrupting itself.
|
|
726
|
+
- Local UI checks without OpenCode: `bun test/web-mock-hub.ts` serves `web/` with a fake hub API
|
|
727
|
+
(queue transcripts with `POST /mock/transcripts`, push SSE messages with `POST /mock/emit`, set the
|
|
728
|
+
status tree with `POST /mock/status {sessions}`, add activity with `POST /mock/activity {sessionID?, item}`,
|
|
729
|
+
read calls from `GET /mock/log`). It also fakes presence, push prefs and the speech settings API, and
|
|
730
|
+
the session browser: ~55 sessions in 8 workspaces (worktrees, blocked, working, voice-off ones, a
|
|
731
|
+
standalone process, an offline machine), 14 of them open (Herdr workspaces general, api-server, research,
|
|
732
|
+
ops; one in a TUI), expose, create (with `herdrWorkspace`) and 403 `not_exposed`, and the view prefs
|
|
733
|
+
(`GET`/`PUT /api/prefs/view`, SSE `prefs`). `POST /mock/prefs {…lists}` acts as another device changing
|
|
734
|
+
prefs, `POST /mock/herdr {on?, fail?}` removes Herdr or makes opening in Herdr fail (201 with a `warning`).
|
|
735
|
+
`POST /mock/session {id, ...patch}` changes a session live, `POST /mock/fail {expose?, create?, models?,
|
|
736
|
+
prefs?}` makes those calls fail,
|
|
737
|
+
`POST /mock/service {connected}` disconnects the service (create answers 409). `POST /mock/stream {sessionID?,
|
|
738
|
+
kind: "text" | "reasoning", messageID, text}` streams text in chunks; `POST /mock/scenario` plays a run of
|
|
739
|
+
`ses_mock` (reasoning, a shell tool, two subagents streaming output, reply text with `say` in conversation
|
|
740
|
+
mode, then idle).
|
|
741
|
+
|
|
742
|
+
### Voice feedback (phone)
|
|
743
|
+
|
|
744
|
+
Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/core/cues.ts` plays cues.
|
|
745
|
+
|
|
746
|
+
- **Voice speed**: 0.75×–2.0× in 0.05 steps, next to "Read replies aloud" and in Settings → Voice
|
|
747
|
+
(default 1.0×, stored per device in `localStorage`). It is applied on the phone as the audio
|
|
748
|
+
element's `playbackRate` with `preservesPitch`, so it changes the reply that is playing right away.
|
|
749
|
+
It is separate from the optional server-side TTS speed in Settings → Speech.
|
|
750
|
+
- **Now header**: a sticky line at the top of the session view. While the session or any subagent works it
|
|
751
|
+
shows a spinner, "Working… 12s" (since the run started: status `since`, else when the phone first saw it
|
|
752
|
+
busy) and what is happening: "Thinking", "Running shell · bun test", "Writing reply", "2 subagents
|
|
753
|
+
working" (combined, e.g. "Writing reply · 2 subagents working"). Otherwise "Needs you" or "Idle · last
|
|
754
|
+
activity 2m ago". Screen readers hear only changes of what is happening, not the timer. This works for
|
|
755
|
+
models without reasoning too (e.g. Claude Haiku): status, tools and streamed text drive it.
|
|
756
|
+
- **Activity feed**: a collapsible panel below it: a live "Thinking…" block streaming the reasoning text
|
|
757
|
+
(last ~4 lines; More/Less expands it; finished ones stay as "Thought"), tool calls (`▶ shell · bun test` → ✓,
|
|
758
|
+
or ✗ with a short error), and subagents ("Subagent: <title> — working/done") with their latest streamed
|
|
759
|
+
output line and their own steps indented below. It is seeded from `GET /api/sessions/:id` (`activity`), then
|
|
760
|
+
follows SSE `activity`, `reasoning`, `delta` and status trees. Items with the same id update in place; the
|
|
761
|
+
newest are at the bottom, and it keeps about 30. The agent's reply streams into its bubble (one bubble per
|
|
762
|
+
assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
|
|
763
|
+
The home list shows the same "now" text with a spinner for working sessions.
|
|
764
|
+
- **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default): a soft
|
|
765
|
+
tone when the agent hears an utterance, then "Working on it." if the session is working and the
|
|
766
|
+
agent has not spoken within 5 s. "Started a subagent." plays when a subagent starts, unless the agent
|
|
767
|
+
spoke in the last 3 s. "Still working." plays after 40 s without speech while work continues, and
|
|
768
|
+
a double tone plays when a subagent finishes. Failed tools are not spoken (they show in the feed). Cues never
|
|
769
|
+
play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent
|
|
770
|
+
speaks. Each phrase is synthesized once per page load and cached in memory.
|
|
771
|
+
- **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
|
|
772
|
+
{visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
|
|
773
|
+
when leaving the view. The hub uses this to decide when to push.
|
|
774
|
+
- **Screen off** (Settings → Voice → "Keep listening when screen is off", off by default). Off: the
|
|
775
|
+
Screen Wake Lock keeps the screen on while listening. On: no wake lock. The mic and VAD keep running
|
|
776
|
+
when the page is hidden. A silent looping audio element plays to make suspension less likely. **This is best-effort:** whether
|
|
777
|
+
listening survives a locked screen depends on Android Chrome (battery saver, OS version), and iOS
|
|
778
|
+
suspends it. The OpenCode session keeps running on the laptop either way. When the phone is not
|
|
779
|
+
listening, turn on the `say` notification to get replies as push notifications.
|
|
780
|
+
- **"Voice mode on" notification**: while Voice mode is on, the page shows a silent, persistent system
|
|
781
|
+
notification through the service worker (tag `voice-mode`): "<session title> · listening" (thinking,
|
|
782
|
+
speaking, muted; updated at most every 3 s) with **End** and **Open**. End reaches the page (which ends
|
|
783
|
+
Voice mode); with no page open, the service worker turns conversation mode off at the hub itself with the
|
|
784
|
+
device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
|
|
785
|
+
without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
|
|
786
|
+
session title on the lock screen while Voice audio plays; its pause and stop end Voice mode (logic in
|
|
787
|
+
`web/src/core/voice-notifier.ts`).
|
|
788
|
+
- **Settings → Speech** (your own settings; admins also edit the server default): cards for Speech-to-text and Text-to-speech, with provider
|
|
789
|
+
(OpenRouter / OpenAI-compatible), base URL (OpenAI-compatible only), model, voice (TTS), language (STT,
|
|
790
|
+
optional), server-side speed (TTS, optional, 0.25–4) and API key. Model and voice are buttons that open a
|
|
791
|
+
full-height picker (logic in `web/picker-core.js`): a search box (every word must match the ID or name,
|
|
792
|
+
case- and accent-insensitive; exact ID, then ID prefix, then other matches), the options with the current
|
|
793
|
+
one marked, and "Use “<typed text>”" for any other value. Models come from `GET
|
|
794
|
+
/api/settings/speech/models` (loading, error with Retry, and empty states); voices from `voiceHints` for the
|
|
795
|
+
chosen model's prefix. Arrow keys, Home/End and Enter work in the search box; Escape closes and focus
|
|
796
|
+
returns to the field. (A `<datalist>` was used before; Android Chrome only suggests entries matching the
|
|
797
|
+
current value, so nothing appeared.)
|
|
798
|
+
The key is never shown or prefilled: the card says "Key set ••••last4 (from settings|env)", with
|
|
799
|
+
Replace, and Clear for keys stored in settings. Save sends `PUT /api/settings/speech` with only the
|
|
800
|
+
changed fields: cleared optional fields are `null`, the key is a string to replace it or `null` to
|
|
801
|
+
clear it. Errors show next to each field. "Preview voice" plays `POST /api/settings/speech/test` (the
|
|
802
|
+
saved settings).
|
|
803
|
+
|
|
804
|
+
## Local only
|
|
805
|
+
|
|
806
|
+
You don't need a hosted hub: the hub runs fine on the laptop, and speech goes straight from the
|
|
807
|
+
laptop to the providers in your config.
|
|
808
|
+
|
|
809
|
+
1. `/hub` → **On this computer**. If no hub is running, the plugin starts one in the background; the
|
|
810
|
+
first machine is approved by itself, the mode is set, and the hub opens.
|
|
811
|
+
2. Speech: on the page (Settings → Speech), or in `~/.config/hub/hub.json` (`chmod 600`;
|
|
812
|
+
examples below).
|
|
813
|
+
3. `/voice` / `/hub` → **Open hub in browser** open the page with a one-time sign-in link; no
|
|
814
|
+
pairing needed. To use a phone on the same network, the hub must listen beyond loopback (see notes).
|
|
815
|
+
|
|
816
|
+
Or run the hub yourself: `scripts/hub-local.sh` (foreground, reads `hub.json`), or keep it running with
|
|
817
|
+
the launchd template `scripts/io.github.huynle.hub.plist` (not installed by anything; install steps are
|
|
818
|
+
in the file).
|
|
819
|
+
|
|
820
|
+
### hub.json
|
|
821
|
+
|
|
822
|
+
`~/.config/hub/hub.json` (or `$HUB_CONFIG`). All fields are optional.
|
|
823
|
+
|
|
824
|
+
| Field | |
|
|
825
|
+
|---|---|
|
|
826
|
+
| `host`, `port` | listen address (default `127.0.0.1`, `4097`) |
|
|
827
|
+
| `publicURL` | origin used in links (default `http://<host>:<port>`) |
|
|
828
|
+
| `stateDir` | hub state (default `~/.local/state/hub`; `~/` is expanded) |
|
|
829
|
+
| `speech.stt` | `{provider, model, baseURL?, language?, apiKey?}` |
|
|
830
|
+
| `speech.tts` | `{provider, model, voice, baseURL?, speed?, apiKey?}` |
|
|
831
|
+
| `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
832
|
+
|
|
833
|
+
Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
|
|
834
|
+
defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
|
|
835
|
+
settings (providers `openrouter` / `openai-compatible`; base URLs `https://…`, or `http://` for
|
|
836
|
+
loopback and private-network hosts; speed 0.5–2.0); an invalid file stops the hub with the reason, and
|
|
837
|
+
unknown keys are logged. The file must be private: if group or others can read it, its API keys are
|
|
838
|
+
ignored and the hub logs `chmod 600 <file>`.
|
|
839
|
+
|
|
840
|
+
OpenRouter for both directions:
|
|
841
|
+
|
|
842
|
+
```json
|
|
843
|
+
{
|
|
844
|
+
"speech": {
|
|
845
|
+
"stt": { "provider": "openrouter", "model": "openai/gpt-4o-mini-transcribe", "apiKey": "sk-or-v1-…" },
|
|
846
|
+
"tts": { "provider": "openrouter", "model": "openai/gpt-4o-mini-tts", "voice": "nova", "apiKey": "sk-or-v1-…" }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
Local servers with an OpenAI-compatible API (e.g. a Whisper server on :8000 and Kokoro-FastAPI on :8880;
|
|
852
|
+
no key needed unless the server asks for one):
|
|
853
|
+
|
|
854
|
+
```json
|
|
855
|
+
{
|
|
856
|
+
"port": 4097,
|
|
857
|
+
"speech": {
|
|
858
|
+
"stt": { "provider": "openai-compatible", "baseURL": "http://127.0.0.1:8000/v1", "model": "whisper-1", "language": "en" },
|
|
859
|
+
"tts": { "provider": "openai-compatible", "baseURL": "http://127.0.0.1:8880/v1", "model": "kokoro", "voice": "af_heart" }
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### Auto-start
|
|
865
|
+
|
|
866
|
+
When the configured server is loopback (`localhost`, `127.0.0.1`, `[::1]`, http only) and nothing answers
|
|
867
|
+
on it, the plugin starts `<this repo>/bin/hub.ts` as a detached background process bound to that
|
|
868
|
+
address and port. The runtime (`resolveHubRuntime` in `src/spoke/localhub.ts`): this process when it is
|
|
869
|
+
`bun`, else `bun` on `PATH`, else `~/.bun/bin/bun`, else the current executable with `BUN_BE_BUN=1`
|
|
870
|
+
(inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `~/.local/state/hub/hub.log` (`$HUB_STATE_DIR`
|
|
871
|
+
if set; a legacy `~/.local/state/opencode-hub`, else `~/.local/state/opencode-voice`, is copied there first). It happens on setup (**On this
|
|
872
|
+
computer**, any process), on **Open hub** (even with auto-start
|
|
873
|
+
off), and when an enrolled connection is refused:
|
|
874
|
+
the shared service process (`--service`) tries at once, other processes after 8 s so the service goes
|
|
875
|
+
first. A lock file (`hub.pid`, `{pid, port, started}`) lets only one process start it; a lock whose pid is
|
|
876
|
+
gone, or that has not answered for a minute, is replaced. If a hub from the lock answers on another
|
|
877
|
+
port, nothing is started (two hubs would share one state). Failed starts are retried after 30 s, then
|
|
878
|
+
doubling up to 15 min, and `/hub` shows the reason. Something else answering on the port (any HTTP
|
|
879
|
+
reply) is left alone. Servers that are not loopback are never started. Turn it off with
|
|
880
|
+
`"autoStartLocalHub": false` in `~/.config/hub/config.json`.
|
|
881
|
+
|
|
882
|
+
### First machine
|
|
883
|
+
|
|
884
|
+
A local hub approves the first machine without the approval step only when all of these hold: it listens
|
|
885
|
+
on loopback (`host` is `127.0.0.1`, `localhost` or `::1`), its public URL is loopback, the enrollment
|
|
886
|
+
request comes from a loopback address without proxy headers (`Forwarded`, `X-Forwarded-*`, `X-Real-IP`),
|
|
887
|
+
no machine was ever enrolled and no device paired, and it has not done so before in this run. The hub logs
|
|
888
|
+
it. Everything else stays authenticated: loopback is never trusted for the API, devices, or later
|
|
889
|
+
machines (a reverse proxy on the same host makes every request look local).
|
|
890
|
+
|
|
891
|
+
### Notes
|
|
892
|
+
|
|
893
|
+
- **Switching** between the local hub and a hosted hub: `/hub → Server`. Machine tokens belong to
|
|
894
|
+
one hub, so switching drops the token; `/hub` → **Server** → **Set up again…** reconnects (each hub keeps its own
|
|
895
|
+
machines, devices, and speech settings).
|
|
896
|
+
- **One hub per machine at a time** (port and state). Stop a background hub with `kill $(jq .pid
|
|
897
|
+
~/.local/state/hub/hub.pid)` or `pkill -f bin/hub.ts`.
|
|
898
|
+
- The microphone works on `http://localhost` / `127.0.0.1` without https (browsers treat loopback as a
|
|
899
|
+
secure context); web push works on `127.0.0.1` / `localhost` in Chrome.
|
|
900
|
+
- Cookies are host-only, so `localhost` and `127.0.0.1` each need their own sign-in; both are accepted
|
|
901
|
+
as origins. Over http no `Secure` flag is set.
|
|
902
|
+
- A phone needs the hub on a reachable address (`"host": "0.0.0.0"` plus a `publicURL` the phone can
|
|
903
|
+
open, ideally https, e.g. through Tailscale). Then the first-machine shortcut is off, and phone mic and
|
|
904
|
+
push need https.
|
|
905
|
+
|
|
906
|
+
## Migrating from opencode-voice or opencode-hub
|
|
907
|
+
|
|
908
|
+
The project was **opencode-voice** (≤ 0.4), then **opencode-hub** (0.5, `@huynle/opencode-hub` was never
|
|
909
|
+
published to npm), and is now **Hub** (0.6): same features, harness-agnostic name. Nothing needs to
|
|
910
|
+
change on day one; everything below keeps working, with a one-time warning where noted.
|
|
911
|
+
|
|
912
|
+
1. Replace the plugin: `opencode plugin add @huynle/hub` (a `github:huynle/hub` install has no built web
|
|
913
|
+
app, so it only suits connecting to a hub that runs elsewhere), remove `@huynle/opencode-voice` or a
|
|
914
|
+
`github:huynle/hub` / `github:huynle/opencode-voice` entry from your OpenCode plugins, and
|
|
915
|
+
`opencode service restart`. `@huynle/opencode-voice` 0.6.0 only re-exports `@huynle/hub` and prints a
|
|
916
|
+
rename notice. There is no `@huynle/opencode-hub` package to replace.
|
|
917
|
+
2. Start OpenCode or the hub once: on first start, when the new location does not exist yet, the most
|
|
918
|
+
recent legacy copy is **copied** there: the `opencode-hub` one first, else the `opencode-voice` one
|
|
919
|
+
(file modes such as 0600 kept; the old files are never deleted; one log line each). Explicit paths
|
|
920
|
+
(`HUB_CONFIG`, `HUB_SPOKE_CONFIG`, `HUB_STATE_DIR`, `stateDir` in hub.json, or their legacy names) are
|
|
921
|
+
used as they are, without copying. Stop a running old hub first so its state is copied in a
|
|
922
|
+
consistent state; after checking the new hub, you may remove the old directories yourself.
|
|
923
|
+
3. Rename environment variables when convenient. The new name wins when both are set, then the most
|
|
924
|
+
recent legacy name; a legacy name logs `VOICE_X is deprecated; rename it to HUB_X` once (or `… is
|
|
925
|
+
ignored because HUB_X is set`).
|
|
926
|
+
|
|
927
|
+
| Before (opencode-voice ≤ 0.4) | Before (opencode-hub 0.5) | Now (Hub 0.6) |
|
|
928
|
+
|---|---|---|
|
|
929
|
+
| npm `@huynle/opencode-voice` | (not published) | `@huynle/hub` |
|
|
930
|
+
| `github:huynle/opencode-voice` | `github:huynle/hub` | `github:huynle/hub` |
|
|
931
|
+
| plugin ids `opencode-voice` / `opencode-voice.tui` | `opencode-hub` / `opencode-hub.tui` | `hub` / `hub.tui` |
|
|
932
|
+
| `/voice` (connection and Voice) | `/hub` (connection), `/voice` (Voice) | `/hub` (connection), `/voice` (Voice) |
|
|
933
|
+
| `opencode-voice-hub` command | `opencode-hub` | `hub-server` (`opencode-hub` and `opencode-voice-hub` kept as aliases) |
|
|
934
|
+
| binaries `opencode-voice-hub-<os>-<arch>` | `hub-<os>-<arch>` | `hub-<os>-<arch>` |
|
|
935
|
+
| image `ghcr.io/huynle/opencode-voice-hub` | `ghcr.io/huynle/hub` | `ghcr.io/huynle/hub` |
|
|
936
|
+
| `~/.config/opencode-voice/config.json` (plugin) | `~/.config/hub/config.json` | `~/.config/hub/config.json` (copied) |
|
|
937
|
+
| `~/.config/opencode-voice/hub.json` | `~/.config/hub/hub.json` | `~/.config/hub/hub.json` (copied) |
|
|
938
|
+
| `~/.local/state/opencode-voice/` (hub state, `hub.log`) | `~/.local/state/hub/` | `~/.local/state/hub/` (copied, without the `hub.pid` lock) |
|
|
939
|
+
| `OPENCODE_VOICE_CONFIG` | `OPENCODE_HUB_CONFIG` | `HUB_SPOKE_CONFIG` |
|
|
940
|
+
| `VOICE_HUB_CONFIG` | `HUB_CONFIG` | `HUB_CONFIG` |
|
|
941
|
+
| `VOICE_HOST`, `VOICE_PORT`, `VOICE_PUBLIC_URL`, `VOICE_STATE_DIR` | `HUB_*` | `HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR` |
|
|
942
|
+
| `VOICE_AUTH`, `VOICE_OIDC_ISSUER`, `VOICE_OIDC_CLIENT_ID`, `VOICE_OIDC_CLIENT_SECRET`, `VOICE_OIDC_ALLOWED`, `VOICE_ADMIN_EMAILS`, `VOICE_OIDC_INSECURE_ISSUER` | `HUB_*` | `HUB_AUTH`, `HUB_OIDC_ISSUER`, `HUB_OIDC_CLIENT_ID`, `HUB_OIDC_CLIENT_SECRET`, `HUB_OIDC_ALLOWED`, `HUB_ADMIN_EMAILS`, `HUB_OIDC_INSECURE_ISSUER` |
|
|
943
|
+
| `VOICE_STT_MODEL`, `VOICE_TTS_MODEL`, `VOICE_VOICE`, `VOICE_MAX_AUDIO_BYTES`, `VOICE_MAX_SPEECH_CHARS`, `VOICE_TURN_TIMEOUT_MS`, `VOICE_VAPID_SUBJECT` | `HUB_*` | `HUB_STT_MODEL`, `HUB_TTS_MODEL`, `HUB_VOICE`, `HUB_MAX_AUDIO_BYTES`, `HUB_MAX_SPEECH_CHARS`, `HUB_TURN_TIMEOUT_MS`, `HUB_VAPID_SUBJECT` |
|
|
944
|
+
| dev scripts: `VOICE_SERVE_LOG`, `VOICE_SERVE_PORT`, `VOICE_LOG_LEVEL` | `HUB_*` | `HUB_SERVE_LOG`, `HUB_SERVE_PORT`, `HUB_LOG_LEVEL` |
|
|
945
|
+
| launchd `com.opencode-voice.hub` | `com.opencode-hub.hub` | `io.github.huynle.hub` (bootout the old agent first; steps in the plist) |
|
|
946
|
+
| RPC id `voice` | `hub` | `hub` (the server also registers `voice`) |
|
|
947
|
+
| prompt metadata `source: "opencode-voice"` | `"opencode-hub"` | `"hub"` (readers accept all three) |
|
|
948
|
+
| default VAPID subject `mailto:opencode-voice@localhost` | `mailto:hub@localhost` | `mailto:hub@localhost` (existing subscriptions keep working) |
|
|
949
|
+
|
|
950
|
+
Unchanged: the `voice` agent and conversation mode, the hub's HTTP API and spoke protocol (old and new
|
|
951
|
+
spokes and hubs interoperate), device cookies (`voice_device`, `voice_oidc`: nobody is signed out),
|
|
952
|
+
`state.json` and `hub.json` contents.
|
|
953
|
+
|
|
954
|
+
**Docker**: the image sets `HUB_HOST`, `HUB_PORT` and `HUB_STATE_DIR`, which win over `VOICE_HOST`,
|
|
955
|
+
`VOICE_PORT` and `VOICE_STATE_DIR`: if you override those on the container, rename them to `HUB_*`. Other
|
|
956
|
+
`VOICE_*` variables (e.g. `VOICE_PUBLIC_URL`, OIDC settings) keep working. The state lives in the `/data`
|
|
957
|
+
volume, so only the image name changes.
|
|
958
|
+
|
|
959
|
+
**A new domain** (e.g. moving from `voice.example.com` to `hub.example.com`):
|
|
960
|
+
|
|
961
|
+
- Set `HUB_PUBLIC_URL` (or `publicURL` in hub.json) to the new origin.
|
|
962
|
+
- Sign-in with Google: add `<public URL>/auth/callback` (e.g. `https://hub.example.com/auth/callback`)
|
|
963
|
+
as an authorized redirect URI of the OAuth client (and the origin, if listed). Keep the old URI until
|
|
964
|
+
the move is done.
|
|
965
|
+
- Point each machine's plugin at the new URL (`/hub` → **Server**, or `"server"` in
|
|
966
|
+
`~/.config/hub/config.json`). Changing the URL drops the machine token, so approve the machine
|
|
967
|
+
again; keeping the old hostname as a redirect avoids that only for browsers, not for spokes.
|
|
968
|
+
- Device cookies belong to a hostname: browsers and phones sign in (or pair) once more on the new domain.
|
|
969
|
+
|
|
970
|
+
## Development
|
|
971
|
+
|
|
972
|
+
```sh
|
|
973
|
+
git clone https://github.com/huynle/hub && cd hub
|
|
974
|
+
npm install # bun install works too
|
|
975
|
+
npm test # bun test (scripts/test-ci.sh runs each file alone and builds the web app first)
|
|
976
|
+
npm run typecheck # hub and web app
|
|
977
|
+
npm run build:web # web/dist, served by `bun bin/hub.ts`
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
|
|
981
|
+
`npm run dev:web` serves the console with hot reload, proxying `/api` to it (`HUB_URL` for a real hub).
|
|
982
|
+
`npm run test:e2e` runs a headless Chromium smoke test against the mock hub at desktop and phone sizes
|
|
983
|
+
(`CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to the temp dir).
|
|
984
|
+
|
|
985
|
+
Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
|
|
986
|
+
`{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
|
|
987
|
+
OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
|
|
988
|
+
your config without the global entry.
|
|
989
|
+
|
|
990
|
+
Headless loop without a TUI (used for the end-to-end checks):
|
|
991
|
+
|
|
992
|
+
```sh
|
|
993
|
+
HUB_SERVE_LOG=$TMPDIR/hub-serve.log scripts/serve.sh & # private `opencode serve` on :4199
|
|
994
|
+
scripts/oc.sh POST /api/session '{"location":{"directory":"'$PWD'/test-project"}}'
|
|
995
|
+
D=$(python3 -c 'import urllib.parse,os;print(urllib.parse.quote(os.path.abspath("test-project"),safe=""))')
|
|
996
|
+
scripts/oc.sh POST "/api/rpc/hub/status?directory=$D" '{"input":{}}'
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
Set `HUB_SPOKE_CONFIG` to a temp file for such checks so the real machine config is untouched.
|
|
1000
|
+
|
|
1001
|
+
Releasing: bump `version` in `package.json`, add a `CHANGELOG.md` entry, and push a tag `vX.Y.Z`.
|
|
1002
|
+
`.github/workflows/release.yml` tests, then publishes the binaries with `SHA256SUMS` to the GitHub
|
|
1003
|
+
Release, `ghcr.io/huynle/hub:<version>` and `:latest`, and `@huynle/hub` to npm through
|
|
1004
|
+
trusted publishing (OIDC, no token; the trusted publisher is configured for the package on npmjs.com).
|
|
1005
|
+
`scripts/build-binaries.sh` and `docker build .` do the same locally.
|
|
1006
|
+
|
|
1007
|
+
The legacy package `@huynle/opencode-voice` lives in `packages/opencode-voice-shim/` (a re-export of
|
|
1008
|
+
`@huynle/hub` with a rename notice; not part of the main package and not published by the
|
|
1009
|
+
workflow). Publish it by hand after `@huynle/hub` of the same version is on npm:
|
|
1010
|
+
`cd packages/opencode-voice-shim && npm publish --access public`, then
|
|
1011
|
+
`npm deprecate @huynle/opencode-voice@"<0.6.0" "Renamed to @huynle/hub"`. The interim name
|
|
1012
|
+
`@huynle/opencode-hub` was never published, so it has no shim. A new package name needs one manual first
|
|
1013
|
+
publish before trusted publishing can take over (see the comment in `release.yml`).
|
|
1014
|
+
|
|
1015
|
+
## Known limits
|
|
1016
|
+
|
|
1017
|
+
- Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
|
|
1018
|
+
streaming STT provider yet.
|
|
1019
|
+
- Voice detection is a simple energy detector; noisy rooms may need tuning.
|
|
1020
|
+
- Questions (forms) of a headless standalone server (`opencode serve --stdio` without its TUI) cannot be
|
|
1021
|
+
answered from the hub; the background service, a foreground `opencode serve` and TUI-launched
|
|
1022
|
+
standalone servers can.
|
|
1023
|
+
- Stop on a subagent session's page stops its whole tree (as on the root's page).
|
|
1024
|
+
- Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
|
|
1025
|
+
possible on iOS). Replies and alerts still arrive as push notifications.
|
|
1026
|
+
- The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
|
|
1027
|
+
|
|
1028
|
+
## License
|
|
1029
|
+
|
|
1030
|
+
MIT
|