@huynle/hub 0.6.0 → 0.7.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 +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/README.md
CHANGED
|
@@ -9,7 +9,18 @@ from your phone or any browser.
|
|
|
9
9
|
|
|
10
10
|
Hub is harness-agnostic by design: the hub's API, web console and spoke protocol are not tied to one
|
|
11
11
|
agent. [OpenCode](https://opencode.ai) is the first supported harness, through the `@huynle/hub` OpenCode
|
|
12
|
-
plugin
|
|
12
|
+
plugin; Claude Code, Codex and terminal agents described by a preset connect through spokes of their own
|
|
13
|
+
([docs/harnesses.md](docs/harnesses.md)). More harnesses can connect by speaking the same spoke protocol
|
|
14
|
+
(`packages/protocol`), most easily on the spoke SDK ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
15
|
+
|
|
16
|
+
| Harness | Connect it with | Evidence | What the hub can do |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| OpenCode | `opencode plugin add @huynle/hub` | native | everything below |
|
|
19
|
+
| Claude Code | `hub claude [claude args…]` | hooks | follow, message, answer permissions, stop; launches |
|
|
20
|
+
| Codex | `hub codex [codex args…]` | rpc | follow, message, turns, steer, interrupt, answer approvals, stop; launches |
|
|
21
|
+
| Presets (Aider, goose, your own) | `hub preset run <name>` | screen | paste prompts and messages, Ctrl-C |
|
|
22
|
+
|
|
23
|
+
The full per-harness matrix, setup, limits and threat model: [docs/harnesses.md](docs/harnesses.md).
|
|
13
24
|
|
|
14
25
|
Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
15
26
|
([what changed](#migrating-from-opencode-voice-or-opencode-hub)).
|
|
@@ -20,13 +31,35 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
|
20
31
|
(tool calls, thinking, subagents, streamed replies), read the conversation, send a turn (it steers the
|
|
21
32
|
run while the session is busy), stop a whole session tree, answer permissions, switch models, and
|
|
22
33
|
start new sessions, from the phone.
|
|
23
|
-
- **Notifications**: web push when a session asks a question, needs permission, fails or
|
|
24
|
-
|
|
34
|
+
- **Notifications**: web push when a session asks a question, needs permission, fails, or is **all done** (the
|
|
35
|
+
session and all its subagents and scripts settled, once per run), plus opt-in finer kinds (every turn,
|
|
36
|
+
stopped, retrying, each subagent, each script, long tools) and **Activity updates** (one quiet, self-replacing
|
|
37
|
+
notification per session); one toggle per kind and device, quiet hours and per-session mute
|
|
38
|
+
([docs/notifications.md](docs/notifications.md)).
|
|
39
|
+
- **Live without waste**: the console's stream resumes where it left off after a lock or app switch (event
|
|
40
|
+
ids + replay), pauses while the page is hidden, and carries streamed text only for the session on screen.
|
|
41
|
+
- **Running detection**: a session counts as working while OpenCode reports it busy or retrying, or while a
|
|
42
|
+
shell or terminal it started still runs (e.g. a background `sleep 60 &` or build script), also when it was
|
|
43
|
+
already running before the plugin started.
|
|
25
44
|
- **Voice** (optional feature, `/voice`): dictation and a hands-free conversation mode with a voice agent
|
|
26
45
|
that answers in short spoken sentences on a fast model and hands real work to background subagents.
|
|
27
46
|
Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
|
|
28
47
|
- **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
|
|
48
|
+
Herdr's sidebar, with the rest under Recent; rename, pin and archive in sync across devices.
|
|
49
|
+
- **Agent identity**: every session's shells carry its own hub identity, so an agent can run `hub status`, name
|
|
50
|
+
itself (`@scout`), claim a role (`@@reviewer`) and read other sessions, with app tokens for scripts and device
|
|
51
|
+
tokens for apps ([Agent identity](#agent-identity-agent-api)).
|
|
52
|
+
- **Agent messaging**: sessions, scripts and you message each other through the hub (`hub msg @reviewer …`,
|
|
53
|
+
`hub ask`, the OpenCode `hub` tool, `/msg`, `hub mcp`); asks to `@me` become Decisions you answer from the
|
|
54
|
+
console ([Agent messaging](#agent-messaging)).
|
|
55
|
+
- **Launches**: you, a script or an app start a session on one of your machines (`hub launch --branch feat/x
|
|
56
|
+
--prompt "…"`): a `hub node` daemon prepares a git worktree, the session gets its name, roles, labels and first
|
|
57
|
+
prompt, and ends by itself when it is done, with signed webhooks for apps ([docs/launches.md](docs/launches.md)).
|
|
58
|
+
- **More harnesses**: Claude Code (`hub claude`, through its hooks), Codex (`hub codex`, through its app-server)
|
|
59
|
+
and any terminal agent described by a data-only preset (`hub preset run`); the console shows only the controls
|
|
60
|
+
each harness's spoke serves, with an evidence badge and honest delivery labels
|
|
61
|
+
([docs/harnesses.md](docs/harnesses.md)). Write a spoke for another harness on the spoke SDK and check it with the
|
|
62
|
+
conformance suite ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
30
63
|
- **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
|
|
31
64
|
marks synced per user.
|
|
32
65
|
- **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
|
|
@@ -34,10 +67,11 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
|
34
67
|
several people.
|
|
35
68
|
|
|
36
69
|
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.
|
|
70
|
+
`/<command>`: today `/voice`, `/who` and `/msg`; more features will be added the same way.
|
|
38
71
|
|
|
39
|
-
Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness
|
|
40
|
-
|
|
72
|
+
Requires OpenCode v2 (tested with 2.0.25) for the OpenCode harness; the Claude Code spoke is tested with Claude
|
|
73
|
+
Code 2.1.296 and the Codex spoke with Codex 0.160.0. The hub runs on OpenCode's own runtime, a standalone binary,
|
|
74
|
+
Docker, or [Bun](https://bun.sh) ≥ 1.3.
|
|
41
75
|
|
|
42
76
|
## Quick start (OpenCode)
|
|
43
77
|
|
|
@@ -62,6 +96,8 @@ Then, in OpenCode:
|
|
|
62
96
|
|---|---|
|
|
63
97
|
| **`/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
98
|
| **`/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. |
|
|
99
|
+
| **`/who [project \| @@role]`** | Lists the hub's named and role-holding sessions (name, project, state, online, roles), asked as the session on screen. |
|
|
100
|
+
| **`/msg @name text`** | Sends the text word for word to another session (`@name`, `@@role`, `@name.project`, `ses_…`, or `@me` for you) as the session on screen. Needs a shared, connected session. |
|
|
65
101
|
|
|
66
102
|
### Local: everything on this computer
|
|
67
103
|
|
|
@@ -107,6 +143,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
|
|
|
107
143
|
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
|
|
108
144
|
| **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
|
|
109
145
|
| **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
|
|
146
|
+
| **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
|
|
110
147
|
|
|
111
148
|
Every way reads the same configuration (env vars over `hub.json`, see [Configuration](#configuration)) and
|
|
112
149
|
answers `--help` and `--version`. While no device is paired, the hub logs a pairing code for the first one.
|
|
@@ -160,8 +197,8 @@ foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd te
|
|
|
160
197
|
**Security:** the hub can drive your OpenCode sessions, which can run tools. Every API call needs a
|
|
161
198
|
paired device or a signed-in user; spokes need machine tokens; nothing trusts the client IP. For a public
|
|
162
199
|
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
|
|
164
|
-
spokes, sign-in and the PWA.
|
|
200
|
+
front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known/hub`, the manifest and
|
|
201
|
+
icons reachable for spokes, sign-in, app discovery and the PWA.
|
|
165
202
|
|
|
166
203
|
## Configuration
|
|
167
204
|
|
|
@@ -189,18 +226,549 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
|
|
|
189
226
|
|
|
190
227
|
| Route | Result |
|
|
191
228
|
|---|---|
|
|
192
|
-
| `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead
|
|
193
|
-
| `GET /api/sessions/:id/timeline
|
|
229
|
+
| `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead (202 `{mode: "steer", inboxID}`). An idle turn answers its `TurnResult` (`{outcome, reply, message?}`) when it ends; with `Prefer: respond-async` (the console sends it) a turn still running after 1.5 s answers 202 `{mode: "turn"}` (`Preference-Applied: respond-async`) and its result follows as an SSE `turn` event `{sessionID, outcome, reply, message?}`, so no proxy cuts a long request. The session stays busy until the turn ends either way. `images` is up to 4 `{mime, data, name?}` (PNG, JPEG, WebP or GIF; canonical base64 whose bytes match the type; each at most 512 KB decoded, together at most 750,000 base64 characters); `text` may be empty when images are attached. They reach OpenCode as inline file attachments. 400 empty, over 100,000 characters or invalid images (with the reason); 409 "A turn is already running for this session" |
|
|
230
|
+
| `GET /api/sessions/:id/timeline?limit=N&order=desc` · `?limit=N&before=<older>` | the conversation as timeline rows, newest first: the latest `limit` rows (1–200, default 50) in chronological order, plus `older` (the cursor of the rows before them) while there are more. `before=<older>` returns the page before that cursor. Cursors are row keys, so they stay valid while new messages arrive. A page starts at a message boundary when it can (it may be up to twice `limit` then). 400 for an unknown cursor (history reverted or compacted: start again from the newest page). The forward mode `?limit=N&cursor=` (oldest rows first, `cursor` for the next page) still works; it cannot be combined with `before` or `order=desc`. The spoke reads OpenCode's history for a first page and serves the following pages from a short-lived per-session cache (30 s, dropped when the session changes) |
|
|
231
|
+
| `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). Every timeline row carries `pos` (ordering position: session message order, then part order; derived from the message's creation time, kept increasing, so it does not change as pages move); tool rows `callID`, subagent tool rows `childSessionID` and `title` (the task description), assistant text rows `ordinal`. 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) |
|
|
232
|
+
| `POST /api/sessions/:id/rename` `{auto: true}` | Auto-name: OpenCode regenerates the title with its Title agent (`session.update` with an empty title on the process's own server). 200 `{title}` (or `{title, unchanged: true}` when OpenCode kept it), 202 `{pending: true}` when it is still generating after 20 s (the catalog updates when it lands), 409 `plugin_outdated` when no process reporting the session supports it, 502 when the process cannot reach its server or the title model failed |
|
|
233
|
+
| `POST /api/sessions/:id/rename` | `{title}` → `{title}`: renames the session in OpenCode (`session.update`), through the process that owns it, else any other process that reports it. The title is trimmed and must be 1–200 characters (400 otherwise). Works with Voice off; the new title shows in every console at once (the spoke reports it before answering) |
|
|
194
234
|
| `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
235
|
| `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
236
|
| `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
237
|
| `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
|
|
198
|
-
| `GET /api/
|
|
199
|
-
| `
|
|
238
|
+
| `GET /api/events?lite=1&session=<id>&last=<id>` | the live stream (SSE). Every message has an `id:`; reconnecting with `Last-Event-ID` (EventSource does it) or `?last=` replays what was missed when the hub still has it (last 2000 events or 5 minutes per user; one fresh `catalog` stands for any catalog changes), else sends the catalog then `{type: "resync"}` (the page refetches what it shows). Ids are `<epoch>.<n>`; a hub restart starts a new epoch. `lite=1` leaves out `delta`/`reasoning` except of `session`'s tree (status, activity, catalog, turns and the rest still come). Heartbeat: `event: ping` every 20 s |
|
|
239
|
+
| `GET /api/push/prefs?endpoint=` | `{prefs, quietHours, activityMinutes, muted, subscriptions, subscribed?}`: this device's toggles (`handoff, question, permission, done, failed, turn, stopped, retry, subagentDone, subagentFailed, jobDone, jobFailed, longTool, say, activity`; `PUT` also accepts the legacy `finished` → `done` and `blocked` → question + permission), activity update interval and quiet hours, the user's muted sessions, how many subscriptions the hub holds for this device, and whether it holds `endpoint` |
|
|
240
|
+
| `PUT /api/push/prefs {toggles…, quietHours?, activityMinutes?}` | boolean toggles (a 0.5.0 `blocked` sets question and permission); `quietHours` `{start: "HH:MM", end: "HH:MM", timeZone}` or `null`; `activityMinutes` a whole number 1–60 (400 when invalid) |
|
|
200
241
|
| `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 |
|
|
242
|
+
| `POST /api/push/test {kind?}` | a test notification to the caller's device only, shaped like `kind` when given (400 for an unknown kind): `{sent, failed: [{host, status?}], subscriptions}`; 429 with `retry-after` within 10 s of the last one |
|
|
202
243
|
| `PUT /api/push/mute {sessionID, muted}` | mutes or unmutes a session's notifications for the user: `{muted}` |
|
|
203
244
|
|
|
245
|
+
## Agent identity (agent API)
|
|
246
|
+
|
|
247
|
+
Agents, scripts and apps talk to the hub through `/api/agent/*`, a small API with its own credentials, separate
|
|
248
|
+
from the console's routes. Every request resolves to exactly one **actor**:
|
|
249
|
+
|
|
250
|
+
| Actor | Credential | Who holds it |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| user | the device cookie (browsers) or the same secret as `Authorization: Bearer <device token>` (apps, CLIs; [linked with a code](#multi-user-sign-in-with-google)) | you, on one of your devices |
|
|
253
|
+
| session | a **session token** `hst1.…` the hub mints for each session a spoke reports | that session's shells (`HUB_SESSION_TOKEN`) |
|
|
254
|
+
| app | an **app token** `hat_…` you create (scopes `sessions:read`, `messages:send`, `messages:read`, `sessions:act`) | a script or tool outside a session (`hub login`) |
|
|
255
|
+
|
|
256
|
+
**Session tokens in shells.** The hub mints a 24 h token for every session (subagents included) its spokes report
|
|
257
|
+
for you and pushes it to the spoke, which keeps it in memory only. Every shell an agent starts through OpenCode's
|
|
258
|
+
shell tool gets that session's `HUB_SESSION_TOKEN` and `HUB_URL` (the spoke's hub), with the hub bin dir first on
|
|
259
|
+
PATH, so [`hub`](#hub-cli) inside a session acts as that session: `hub whoami` prints `session ses_… on machine
|
|
260
|
+
mch_…`. Tokens are renewed automatically before they expire; a shell started earlier keeps its old token, so a
|
|
261
|
+
`token_expired` answer means: start a new shell. A token stops working as soon as the session is no longer
|
|
262
|
+
reported by that machine, the machine is revoked or the user is disabled (see
|
|
263
|
+
[docs/multi-user.md](docs/multi-user.md#agent-actors-and-tokens)). Spokes of other harnesses get tokens only
|
|
264
|
+
when they announce the `sessionTokens` feature. Each session also has a separate **act token**, which never goes
|
|
265
|
+
into a shell; see [Agent actions and grants](#agent-actions-and-grants).
|
|
266
|
+
|
|
267
|
+
**Addressing.** Wherever a route or command takes a session, it takes an address, resolved among the caller's
|
|
268
|
+
own sessions only:
|
|
269
|
+
|
|
270
|
+
| Address | Means |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `ses_…` | a session ID, or an unambiguous prefix of one |
|
|
273
|
+
| `@name` | a named root session. Names are `[a-z0-9-]`, 2–40 characters; each root session gets one from its first real title: its first few words, at most 4 and 24 characters, with ID-like tokens such as `ses_…` dropped (`-2`, `-3` on a clash; later title changes keep it) and can be renamed (`hub name @scout`); the old name stays an alias for 24 h. `me` is reserved (`@me` is always the user): a title like "Me" derives `me-2` |
|
|
274
|
+
| `@me` | the user (messages and Decisions; never a session) |
|
|
275
|
+
| `@@role` | the session(s) holding a role (`hub role claim reviewer`); `--exclusive` claims fail while another live holder exists |
|
|
276
|
+
| `@name.project`, `@@role.project` | scoped to a project (its name, else the folder name) |
|
|
277
|
+
|
|
278
|
+
An address several sessions match answers `ambiguous` with the candidates; another user's sessions, names and
|
|
279
|
+
roles do not exist (`not_found`). A name is released 7 days after its session is archived or deleted; a role
|
|
280
|
+
holder offline for 30 minutes loses the role. Some role names (such as `orchestrator…`) are reserved to the user.
|
|
281
|
+
|
|
282
|
+
In an OpenCode prompt, an address is a reference, not routing: the prompt still goes to the session you typed it
|
|
283
|
+
in, word for word, and the plugin attaches a short `hub-references.txt` note naming the session(s) it means (state,
|
|
284
|
+
machine, project, your open asks to it) so the agent can contact them with the `hub` tool. OpenCode's own `@file`
|
|
285
|
+
and `@agent` mentions win; unknown names add nothing; if the hub does not answer within 1.5 s the prompt goes on
|
|
286
|
+
without the note.
|
|
287
|
+
|
|
288
|
+
**Routes.** Errors are `{error: {code, message, hint?, …}}` with the codes the [CLI](#hub-cli) maps to exit
|
|
289
|
+
codes. Console routes refuse session and app tokens (403); `/api/agent` refuses a request carrying both the
|
|
290
|
+
cookie and a hub bearer token (400).
|
|
291
|
+
|
|
292
|
+
| Route | User | Session | App (`sessions:read`) | Result |
|
|
293
|
+
|---|---|---|---|---|
|
|
294
|
+
| `GET /api/agent/whoami` | ✓ | ✓ | ✓ (any scope) | `{actor, user}` |
|
|
295
|
+
| `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
|
|
296
|
+
| `GET /api/agent/sessions?project=&state=&machine=&label=k=v` | ✓ | ✓ | ✓ | the same sessions as a flat list |
|
|
297
|
+
| `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
|
|
298
|
+
| `GET /api/agent/sessions/:addr/log?n=20` | ✓ | ✓ | ✓ | newest timeline rows (1–200); `422 unsupported {feature, harness}` when the spoke has no timeline, `503 offline` |
|
|
299
|
+
| `GET /api/agent/watch?blocked=1&session=<addr>` | ✓ | ✓ | ✓ | SSE of compact events (`ready`, `state`, `blocker.opened/closed`, `session.added/removed`, `message`; no text) |
|
|
300
|
+
| `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
|
|
301
|
+
| `POST /api/agent/name {name, session?}` | ✓ | own session | – | names a session (`409 name_taken`) |
|
|
302
|
+
| `POST /api/agent/roles/claim {role, exclusive?, project?, session?}` · `…/roles/release` | ✓ | own session | – | claims (with a new `generation`) or releases a role |
|
|
303
|
+
| `POST /api/agent/msg {to, text, wake?, urgent?, all?}` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
|
|
304
|
+
| `POST /api/agent/ask {to, text, expiresIn?, wait?}` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
|
|
305
|
+
| `POST /api/agent/ask {to: "@me", text, options?, recommendation?, urgency?, tier?, deadline?, onTimeout?, about?, wait?}` | – | ✓ | `messages:send` | opens a **Decision** for the user (below): `{message, decision, answer?}` |
|
|
306
|
+
| `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
|
|
307
|
+
| `POST /api/agent/reply {replyTo, text}` · `…/decline {replyTo, why}` | ✓ | ✓ | `messages:send` | answers a message you received (to its real sender); not a Decision's ask |
|
|
308
|
+
| `GET /api/agent/inbox` · `GET /api/agent/messages/:id?wait=` | ✓ | ✓ | `messages:read` | open asks, your waiting asks, recent messages, mutes · one message and its answer |
|
|
309
|
+
| `POST /api/agent/mute {session}` · `…/unmute` | ✓ | ✓ | `messages:send` | refuses (or allows again) messages from a session |
|
|
310
|
+
| `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
|
|
311
|
+
| `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
|
|
312
|
+
|
|
313
|
+
The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
|
|
314
|
+
|
|
315
|
+
**Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
|
|
316
|
+
`ask @me` opens a Decision `{id: "dec_…", from, about, question, options: [{id, label, safe?}], recommendation:
|
|
317
|
+
{option, why?}, urgency: low|normal|high, tier: confirm|tap, deadline, onTimeout, state, answer?}` (push kind
|
|
318
|
+
`decision`, generic body, opens `about[0]`, default the asking session). Only the user answers, on the console:
|
|
319
|
+
`GET /api/decisions?state=`, `GET /api/decisions/:id`, `POST /api/decisions/:id/answer {option, text?}` (`text` alone
|
|
320
|
+
for a Decision without options; same-origin cookie or a device token; `tap` Decisions only with the console
|
|
321
|
+
cookie, else `403 forbidden`). The first answer wins, a later one gets `409 {code: "gone"}`; the answer reaches the
|
|
322
|
+
asker as the user's reply to its ask (`Decision dec_…: Hold [hold]`). At the deadline, `onTimeout` (only an option
|
|
323
|
+
marked `safe`) answers it (`answeredBy: "timeout"`); without one the Decision stays open and is pushed again once.
|
|
324
|
+
`deadline` is in seconds and must fall before the ask expires (default: a day past the deadline, at most 7 days).
|
|
325
|
+
The console shows open Decisions as cards on Home and in the sessions they concern (SSE `{type: "decision",
|
|
326
|
+
decision}` on every open, answer, timeout, escalation and expiry); a `decision` push opens its card
|
|
327
|
+
(`?decision=<id>`).
|
|
328
|
+
|
|
329
|
+
Reads come from the hub's catalog; only `show` (summary) and `log` (timeline) ask the session's spoke, so they
|
|
330
|
+
depend on what its harness supports. A session token reads all of its user's sessions but names and claims roles
|
|
331
|
+
only for itself; an app token reads, and messages only with the `messages:*` scopes. `GET /.well-known/hub` (public) answers `{publicURL, version,
|
|
332
|
+
minClientVersion, auth, features}` for apps and CLIs.
|
|
333
|
+
|
|
334
|
+
**Apps.** A native app links with a code from Settings → Devices → **Add app** (`POST /api/devices/link`, then
|
|
335
|
+
`POST /api/devices/redeem` from the app; see [Multi-user](#multi-user-sign-in-with-google)) and uses the device
|
|
336
|
+
token as a user actor, on console routes and `/api/agent` alike. Revoking the device ends it.
|
|
337
|
+
|
|
338
|
+
## Agent messaging
|
|
339
|
+
|
|
340
|
+
Sessions, scripts, apps and you can message each other through the hub. Everything goes through the
|
|
341
|
+
[`/api/agent` routes](#agent-identity-agent-api) above; the sender is always the credential that made the request
|
|
342
|
+
(a session token, an app token or your device), never a field of the request.
|
|
343
|
+
|
|
344
|
+
**Addresses.** `@name` (a session's name), `@@role` (the session holding a role: the idle holder, else the most
|
|
345
|
+
recently active one; `--all` sends to every holder), `@name.project` / `@@role.project`, `ses_…`, and `@me` for
|
|
346
|
+
you. Addresses resolve among your own sessions only; another user's sessions do not exist (`not_found`).
|
|
347
|
+
|
|
348
|
+
**Verbs.** The same verbs exist in the CLI (`hub …`), the OpenCode `hub` tool, `hub mcp` and the SDK:
|
|
349
|
+
|
|
350
|
+
| Verb | What it does |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `msg <addr> "text"` | tells a session something (fire and forget) |
|
|
353
|
+
| `ask <addr> "question"` | an ask, open until answered, declined or expired (1 minute to 7 days, default 24 h); `--wait` blocks for the answer |
|
|
354
|
+
| `reply <msgID> "text"` · `decline <msgID> --why "…"` | answers a message you received; it goes to the message's real sender, never through the role |
|
|
355
|
+
| `inbox` | asks waiting for your answer, your own open asks, recent messages, mutes |
|
|
356
|
+
| `mute <addr>` · `unmute <addr>` | refuses (or accepts again) messages from a session (`forbidden`, exit 5) |
|
|
357
|
+
| `who` | named and role-holding sessions |
|
|
358
|
+
|
|
359
|
+
`--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
|
|
360
|
+
most 10,000 characters (a decline's reason 1,000).
|
|
361
|
+
|
|
362
|
+
**Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
|
|
363
|
+
OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
|
|
364
|
+
turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it; subagent sessions and
|
|
365
|
+
`--no-wake` messages wait for the next turn. A session that is offline gets it when it comes back (pending, for 24
|
|
366
|
+
hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
|
|
367
|
+
`unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
|
|
368
|
+
after waits of 5, 15 and 45 minutes in turn, then you are notified. Messages to you and to apps are stored in an inbox (`hub inbox`).
|
|
369
|
+
|
|
370
|
+
**Envelope.** The recipient sees the message framed so its body cannot pose as anything else:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
[hub] ask from @api (laptop) to @@reviewer → you · 10-24 08:15Z · msg_7f3a…
|
|
374
|
+
----- msg_7f3a… -----
|
|
375
|
+
please review the login fix
|
|
376
|
+
----- msg_7f3a… -----
|
|
377
|
+
↩ hub reply msg_7f3a… "…" · hub decline msg_7f3a… --why "…" · expires 10-25 08:15Z
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The boundary carries the message's own id; body lines that look like a header, a boundary or a reply hint are
|
|
381
|
+
escaped with a leading backslash; control characters, ANSI escapes, bidi overrides and zero-width characters are
|
|
382
|
+
removed from the text and from every interpolated name.
|
|
383
|
+
|
|
384
|
+
**Primer.** Once per session, the first time it is named explicitly or gets its first message, the hub adds a short
|
|
385
|
+
note without starting a turn: who the session is (`@name`, its roles), how to message others (`hub msg`, `hub who`),
|
|
386
|
+
and that messages headed `[hub]` come from other sessions or apps, not from the user. When that first message
|
|
387
|
+
wakes an idle session, the note and the message reach the model together, in one model call.
|
|
388
|
+
|
|
389
|
+
**Decisions.** `ask @me` from a session or app opens a Decision (question, options, a recommendation, urgency, a
|
|
390
|
+
deadline with an optional safe `onTimeout`). You answer it from the console (cards on Home and in the sessions it
|
|
391
|
+
concerns, a `decision` push); the answer goes back to the asker as your reply. Only users answer Decisions, never
|
|
392
|
+
sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the routes.
|
|
393
|
+
|
|
394
|
+
**Where agents send from.**
|
|
395
|
+
- In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
|
|
396
|
+
- The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
|
|
397
|
+
`whoami`, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
|
|
398
|
+
- You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
|
|
399
|
+
- Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with.
|
|
400
|
+
- Scripts: `hub login` with an app token that has the `messages:send` (and `messages:read`) scopes.
|
|
401
|
+
|
|
402
|
+
**Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
|
|
403
|
+
`retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
|
|
404
|
+
replies between agents may run 8 hops without a human turn; past that, messages are delivered without waking and
|
|
405
|
+
you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. You can switch
|
|
406
|
+
messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
|
|
407
|
+
reading still works.
|
|
408
|
+
|
|
409
|
+
## Agent actions and grants
|
|
410
|
+
|
|
411
|
+
Agents can do more than message: a session or a script may **approve** or **deny** a permission request, **answer**
|
|
412
|
+
a form, **prompt** a session or **stop** it, in your sessions and only as far as you allow. You always may (your
|
|
413
|
+
own sessions, any of your credentials); an agent acts only under a **grant** you created, and anything outside it
|
|
414
|
+
comes back to you as a Decision.
|
|
415
|
+
|
|
416
|
+
**Who can act.**
|
|
417
|
+
|
|
418
|
+
| Credential | Acts? |
|
|
419
|
+
|---|---|
|
|
420
|
+
| you (device cookie, device token) | always, on your own sessions; no grant, no kill switch |
|
|
421
|
+
| a session's **act token** (held by the OpenCode `hub` tool) | under a grant held by that session (by its ID, its `@name`, or a reserved `@@orchestrator…` role it holds) |
|
|
422
|
+
| an app token with scope `sessions:act` | under a grant held by `app:<token id>` |
|
|
423
|
+
| a session's **shell token** (`HUB_SESSION_TOKEN`) | never: `403 forbidden` |
|
|
424
|
+
|
|
425
|
+
**Act tokens vs shell tokens.** Every session has two hub tokens. The shell token goes into the shells the session
|
|
426
|
+
starts, so `hub msg` and the other read and message verbs work from any script it runs. The act token (`typ act`)
|
|
427
|
+
is pushed only to spokes that announce `actTokens`, kept apart from the shell tokens, never put in any
|
|
428
|
+
environment and never shown in tool output; only the plugin's `hub` tool uses it, for the act verbs. A command an
|
|
429
|
+
agent runs in a shell therefore cannot act, even with the session's own credential; the model has to call the tool,
|
|
430
|
+
where the call is visible and audited.
|
|
431
|
+
|
|
432
|
+
**Grants.** A grant says who may do what, where, until when:
|
|
433
|
+
|
|
434
|
+
```json
|
|
435
|
+
{
|
|
436
|
+
"holder": "@@orchestrator",
|
|
437
|
+
"scope": { "projects": ["/home/you/projects/api"] },
|
|
438
|
+
"allow": ["approve", "deny", "prompt", "stop"],
|
|
439
|
+
"permissions": { "allow": ["shell:bun test*", "edit:src/*"], "never": ["shell:git push*"] },
|
|
440
|
+
"expires": "8h",
|
|
441
|
+
"maxActionsPerHour": 60,
|
|
442
|
+
"allowAlways": false
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
- **holder**: `ses_…` or `@name` (pinned to that session when the grant is created; the grant never follows the name
|
|
447
|
+
to another session), a reserved `@@orchestrator…` role (follows whoever holds it when it is used; only you assign
|
|
448
|
+
reserved roles), or `app:<token id>` (one of your app tokens).
|
|
449
|
+
- **scope.projects**: 1–20 absolute project directories; a session is in scope when its directory is one of them or
|
|
450
|
+
inside one (on a path boundary, after resolving `.`/`..`; worktrees inside the project count).
|
|
451
|
+
- **allow**: the verbs (`approve`, `deny`, `answer`, `prompt`, `stop`; `roles` is accepted for role management).
|
|
452
|
+
`deny`, `answer`, `prompt` and `stop` need only the verb; `approve` also needs the request to pass the patterns.
|
|
453
|
+
- **permissions**: patterns `action[:glob]` in OpenCode's wildcard syntax (`*` any run, `?` one character, a trailing
|
|
454
|
+
` *` optional; `bash` is OpenCode's `shell`; the action alone means every resource). A request is allowed only
|
|
455
|
+
when **every** resource it names matches an allow pattern; any never pattern wins over every allow. In shell allow
|
|
456
|
+
patterns, `$(`, backticks and `<`/`>` must be written literally (a wildcard never matches them), so
|
|
457
|
+
`shell:bun test*` does not allow `bun test $(rm -rf ~)`. Never patterns also see the command without leading
|
|
458
|
+
`VAR=…` assignments and wrappers (`sudo`, `env`, `command`, `exec`, `nohup`, `time`), so `sudo git push` is still
|
|
459
|
+
a `git push`.
|
|
460
|
+
- **expires**: 1 minute to 30 days (seconds, or `30m`, `8h`, `2d`; default 8 h). **maxActionsPerHour**: 1–1000
|
|
461
|
+
(default 60), per clock hour; past it `429 rate_limited`. **allowAlways**: lets the holder reply `always` (OpenCode
|
|
462
|
+
then saves the request's rules), and only when every rule it would save lies inside the allow patterns.
|
|
463
|
+
- At most 50 active grants per user. Editing replaces a grant (its holder stays, its expiry restarts); revoking ends
|
|
464
|
+
it at once and keeps it listed for the audit.
|
|
465
|
+
|
|
466
|
+
**hub never rules.** `agents.never` in [hub.json](#hubjson) lists patterns no agent may approve for any user,
|
|
467
|
+
checked before every grant (`{"agents": {"never": ["shell:git push*", "shell:rm -rf*", "external_directory"]}}`).
|
|
468
|
+
The user can still approve such a request themselves.
|
|
469
|
+
|
|
470
|
+
**The check order** for an agent: the session belongs to you (else `not_found`, like a session that does not
|
|
471
|
+
exist) → kill switches → a usable grant for the verb and the session's project → not its own session tree (a
|
|
472
|
+
session never answers a request or form in its own tree) → the hub's never rules → the grant's never rules → the
|
|
473
|
+
allow patterns (else `needs_user`) → the hourly cap → the kill switches again → the spoke.
|
|
474
|
+
|
|
475
|
+
| Answer | HTTP | CLI exit | Means |
|
|
476
|
+
|---|---|---|---|
|
|
477
|
+
| ok | 200 | 0 | done (the result names the grant used) |
|
|
478
|
+
| `needs_user` | 403 | 4 | outside the grant: the user decides (a Decision is open) |
|
|
479
|
+
| `forbidden` | 403 | 5 | no grant, a never rule, its own tree, or a shell token |
|
|
480
|
+
| `paused` | 423 | 5 | a kill switch is on |
|
|
481
|
+
| `rate_limited` | 429 | 6 | the grant's hourly cap |
|
|
482
|
+
| `not_found` | 404 | 3 | not your session, or no such pending request or form |
|
|
483
|
+
| `gone` | 410 | 8 | answered elsewhere first, expired or ended |
|
|
484
|
+
| `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
|
|
485
|
+
| `offline` | 503 | 7 | no spoke reports the session now |
|
|
486
|
+
|
|
487
|
+
**Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
|
|
488
|
+
grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
|
|
489
|
+
reason), with Approve once (or Approve always, when asked), Deny (safe), and the agent's `--why` as its
|
|
490
|
+
recommendation. The hub sets the tier, never the agent: `tap` (answered by a tap in the console, not from a
|
|
491
|
+
notification) for shell, `external_directory`, MCP and unknown tools and for anything near a never rule; `confirm`
|
|
492
|
+
otherwise. Your answer replies to the permission **as you** (no grant or kill switch applies to you); if the request
|
|
493
|
+
was answered elsewhere or ended first, the Decision closes and the agent's next try is `not_found`. A request's
|
|
494
|
+
blocker in the console shows the recommendation ("@orchestrator recommends Approve once: …"). Limits: 20 new
|
|
495
|
+
escalations per agent an hour and 20 open at once per user; escalations within a minute of the last push open
|
|
496
|
+
quietly.
|
|
497
|
+
|
|
498
|
+
**Grant requests.** Agents never create grants. A session (any of its tokens) or an app token with `messages:send`
|
|
499
|
+
may **request** one: `POST /api/agent/grants/requests {grant, why}` (`hub grant request …`). The holder defaults to
|
|
500
|
+
the requester and may only be the requester itself or a reserved role it holds. It opens a tap Decision summarising
|
|
501
|
+
the grant; approving creates it as yours, and the requester is told the grant id in a reply. One open request per
|
|
502
|
+
requester, 5 an hour.
|
|
503
|
+
|
|
504
|
+
**Prompts.** An agent's prompt reaches the session as the hub's, never as yours: the spoke wraps it in a provenance
|
|
505
|
+
envelope (`[hub] prompt from @lead · grant g_… · msg_…`, the body between boundary lines) and admits it as a hub
|
|
506
|
+
message, so it never resets the loop protection's hop chain the way your own typing does. Agents may prompt root
|
|
507
|
+
sessions only, never themselves, text only, always waking the session (`--delivery queue|steer` picks how it enters
|
|
508
|
+
a busy one).
|
|
509
|
+
|
|
510
|
+
**Deny reasons.** `deny --why "…"` passes the reason to the harness as the rejection's feedback (prefixed with who
|
|
511
|
+
denied it and under which grant), on spokes that announce `permissionMessage`; an older spoke takes the reject
|
|
512
|
+
without it, and the answer says so (`reasonSent: false`).
|
|
513
|
+
|
|
514
|
+
**Kill switches.** Settings → Agents (or `PUT /api/agent/acting {enabled: false}`) turns agent actions off for you;
|
|
515
|
+
an admin's `PUT /api/admin/agents/acting {enabled: false}` turns them off for the whole hub. Either one answers
|
|
516
|
+
every act verb `423 paused`; reading and messaging keep working (messaging has its own switch), and your own
|
|
517
|
+
actions and Decision answers still go through.
|
|
518
|
+
|
|
519
|
+
**Verbs.** The same act verbs are in the [CLI](#hub-cli) (`hub approve | deny | answer | prompt | stop`, `hub grant
|
|
520
|
+
list | show | create | revoke | request`), the OpenCode `hub` tool (verbs `approve`, `deny`, `answer`, `prompt`,
|
|
521
|
+
`stop`, with the session's act token), [`hub mcp`](#hub-mcp-mcp-server) (tools of the same names, as the app token)
|
|
522
|
+
and the SDK. Only you manage grants: the console (Settings → Agents) or the CLI with your device token
|
|
523
|
+
(`hub login --token <device token>`).
|
|
524
|
+
|
|
525
|
+
| Route | User | Session (act token) | App (`sessions:act`) |
|
|
526
|
+
|---|---|---|---|
|
|
527
|
+
| `POST /api/agent/sessions/:addr/approve {requestID, always?, why?}` | ✓ | under a grant | under a grant |
|
|
528
|
+
| `POST /api/agent/sessions/:addr/deny {requestID, why?}` | ✓ | under a grant | under a grant |
|
|
529
|
+
| `POST /api/agent/sessions/:addr/answer {formID, answer \| cancel: true, why?}` | ✓ | under a grant | under a grant |
|
|
530
|
+
| `POST /api/agent/sessions/:addr/prompt {text, delivery?, why?}` (users: `images?`) | ✓ | under a grant | under a grant |
|
|
531
|
+
| `POST /api/agent/sessions/:addr/stop {why?}` | ✓ | under a grant | under a grant |
|
|
532
|
+
| `POST /api/agent/grants` · `GET` · `GET`/`PUT`/`DELETE …/grants/:id` | ✓ | – | – |
|
|
533
|
+
| `POST /api/agent/grants/requests {grant, why}` | – | ✓ (any token) | `messages:send` |
|
|
534
|
+
| `GET` · `PUT /api/agent/acting {enabled}` | ✓ | – | – |
|
|
535
|
+
| `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
|
|
536
|
+
| `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
|
|
537
|
+
|
|
538
|
+
**Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
|
|
539
|
+
your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
|
|
540
|
+
`gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
|
|
541
|
+
requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
|
|
542
|
+
pages of up to 500; the last 2000 entries are kept); new entries stream to your console as SSE `{type: "agent",
|
|
543
|
+
entry}`. Admins see only a summary in the hub audit (the user, `agent.<verb>` and the actor kind; grant changes as
|
|
544
|
+
`grant.create`, `grant.update`, `grant.revoke` with the grant id), never sessions, requests, reasons, holders or
|
|
545
|
+
patterns.
|
|
546
|
+
|
|
547
|
+
## Launches
|
|
548
|
+
|
|
549
|
+
A **launch** starts a session for you on one of your machines and ends it when it is done. On each machine that
|
|
550
|
+
should take launches, enrol it (the OpenCode plugin's `/hub` dialog), list where its repositories live in
|
|
551
|
+
`~/.config/hub/node.json` and run the node daemon:
|
|
552
|
+
|
|
553
|
+
```sh
|
|
554
|
+
echo '{"workspaceRoots": ["/Users/you/projects"]}' > ~/.config/hub/node.json
|
|
555
|
+
hub node install # writes the launchd agent / systemd user unit and prints how to load it
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Then, from anywhere:
|
|
559
|
+
|
|
560
|
+
```sh
|
|
561
|
+
hub launch --repo ~/projects/app --branch feat/search --name search --prompt "Add full-text search" --cleanup remove
|
|
562
|
+
hub launches list # queued → placing → provisioning → running → ended (idle, stopped, …)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The node makes the git worktree `~/projects/app/.worktrees/feat/search`, the machine's OpenCode service starts the
|
|
566
|
+
session there as `@search`, the hub primes and prompts it, and once the session has worked and stays idle the
|
|
567
|
+
launch ends `idle` and the worktree is removed (the branch stays; nothing uncommitted is ever removed). Apps launch
|
|
568
|
+
with an app token (scope `sessions:launch`) under a grant with verb `launch`, retry safely with a `key`, tag
|
|
569
|
+
launches with labels and follow them with signed webhooks. `--harness claude-code` or `--harness codex` launches
|
|
570
|
+
those harnesses through their service spokes ([docs/launches.md](docs/launches.md#harnesses)). Everything, from the
|
|
571
|
+
spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
|
|
572
|
+
OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
|
|
573
|
+
|
|
574
|
+
## hub CLI
|
|
575
|
+
|
|
576
|
+
`hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
|
|
577
|
+
for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`$HUB_STATE_DIR/bin`, default
|
|
578
|
+
`~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
|
|
579
|
+
session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
|
|
580
|
+
with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
|
|
581
|
+
be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
|
|
582
|
+
|
|
583
|
+
**Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
|
|
584
|
+
elsewhere use `hubctl`, or call the bundle directly (`bun …/packages/cli/dist/hub-cli.js`).
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
hub status [--json] machines → projects → sessions, and what needs attention
|
|
588
|
+
hub sessions [--project P] [--state S[,S]] [--machine M] [--label k=v]
|
|
589
|
+
hub show <addr> state, blockers, activity, summary
|
|
590
|
+
hub log <addr> [-n 20] newest timeline rows (1–200), printed oldest first
|
|
591
|
+
hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pings skipped)
|
|
592
|
+
hub whoami who this credential is
|
|
593
|
+
hub who [--project P] [--role R | @@role] named and role-holding sessions
|
|
594
|
+
hub name <@name> [--session <addr>]
|
|
595
|
+
hub role claim <role[.project]> [--exclusive] [--session <addr>]
|
|
596
|
+
hub role release <role[.project]> [--session <addr>]
|
|
597
|
+
hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
|
|
598
|
+
hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
|
|
599
|
+
hub reply <msgID> "text" [--no-wake] to the message's real sender, never via the role
|
|
600
|
+
hub decline <msgID> --why "reason"
|
|
601
|
+
hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
|
|
602
|
+
[--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]... a Decision for the user
|
|
603
|
+
hub decisions list [--state open|answered|expired|closed] the Decisions you asked
|
|
604
|
+
hub inbox asks to answer, your open asks, recent messages, mutes
|
|
605
|
+
hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
|
|
606
|
+
hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
|
|
607
|
+
hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
|
|
608
|
+
hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
|
|
609
|
+
hub prompt <addr> "text" [--delivery queue|steer] [--why "…"]
|
|
610
|
+
hub stop <addr> [--why "…"] stop its running turn
|
|
611
|
+
hub grant list · hub grant show <id> · hub grant revoke <id>
|
|
612
|
+
hub grant create --holder <h> --project <path>... --allow approve,deny,… [--permit <pattern>]... [--never <pattern>]...
|
|
613
|
+
[--expires 8h] [--max-per-hour N] [--allow-always] | --json '{…}'
|
|
614
|
+
hub grant request --project <path>... --allow approve,deny,… [--permission-allow <pattern>]... [--never <pattern>]...
|
|
615
|
+
[--holder <@@role you hold>] --why "…" (agents) ask the user for a grant
|
|
616
|
+
hub launch [--repo <path>] [--mode worktree|checkout|clone] [--branch B] [--base B] [--remote URL]
|
|
617
|
+
[--name <@name>] [--role R]... [--harness H] [--agent A] [--model provider/model]
|
|
618
|
+
[--runtime host|openshell [--profile P]] [--machine M] [--node-label k=v]... [--prefer]
|
|
619
|
+
[--label k=v]... [--prompt "text" | --prompt -] [--end idle|asked|never] [--timeout 4h]
|
|
620
|
+
[--cleanup keep|remove] [--key K] start a session on a hub node
|
|
621
|
+
hub launches list [--state S[,S]] [--label k=v]... · hub launches show <id> · hub launches stop <id>
|
|
622
|
+
hub node run the node daemon on this machine, until Ctrl-C or SIGTERM
|
|
623
|
+
hub node install [--dry-run] · hub node uninstall [--dry-run] · hub node status
|
|
624
|
+
hub claude [claude args…] Claude Code with the hub plugin (--plugin-dir; ~/.claude untouched)
|
|
625
|
+
hub claude-code install|uninstall [--dry-run] · hub claude-code status the Claude Code daemon as a service
|
|
626
|
+
hub codex [codex args…] Codex with its shared app-server daemon on (~/.codex untouched)
|
|
627
|
+
hub codex-spoke install|uninstall [--dry-run] · hub codex-spoke status the Codex spoke as a service
|
|
628
|
+
hub preset list · hub preset check <name|file>
|
|
629
|
+
hub preset run <name|file> [--socket <name> | --current] [--cwd <dir>] [--resume <id>] [--no-hub] [-- args…]
|
|
630
|
+
hub mcp an MCP server on stdio (see below)
|
|
631
|
+
hub login [--url URL] [--token APP_TOKEN] · hub logout
|
|
632
|
+
hub help · hub --version
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Addresses are `ses_…` (or an unambiguous prefix), `@name`, `@@role`, `@name.project` and `@me` (the user). Output is short text
|
|
636
|
+
(one session per line: name and ID, state, harness, current activity, blockers); `--json` prints the hub's answer
|
|
637
|
+
unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on stdout as well). Errors print the
|
|
638
|
+
message, any candidates or retry time, a missing feature, and the hint on stderr.
|
|
639
|
+
|
|
640
|
+
**Messaging.** `msg` and `ask` print what was stored and how it reached the recipient (`delivered` into a new or
|
|
641
|
+
the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline, or
|
|
642
|
+
`stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
|
|
643
|
+
`--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
|
|
644
|
+
read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
|
|
645
|
+
`--wait` take seconds or `30s`, `5m`, `2h`, `1d` (asks expire after 1 minute to 7 days, default 24 h). `ask --wait N`
|
|
646
|
+
waits up to N seconds for the answer (the CLI loops on the hub's 60 s long poll); a bare `--wait` waits until the
|
|
647
|
+
ask expires. A waiting `ask` exits 0 with the answer, 1 when declined (with the reason) or still open when the
|
|
648
|
+
wait is spent, and 8 when it expired; `--wait` cannot be combined with `--all`. `hub inbox` shows each open ask
|
|
649
|
+
with its `hub reply` / `hub decline` line. A recipient whose spoke cannot receive messages exits 7 (`unsupported`,
|
|
650
|
+
feature `deliver`: update the plugin there), messaging switched off by the user exits 5 (`paused`), sending too
|
|
651
|
+
much exits 6 (`rate_limited`), and answering an ask that is already answered or expired exits 8 (`gone`). Sessions
|
|
652
|
+
message with their session token; an app token needs the `messages:send` (and `messages:read` for `inbox`) scopes.
|
|
653
|
+
|
|
654
|
+
**Acting.** `approve`, `deny`, `answer`, `prompt` and `stop` act on another session: the user's credential always
|
|
655
|
+
may (its own sessions); an app token needs scope `sessions:act` **and** a grant; a session's shell token
|
|
656
|
+
(`HUB_SESSION_TOKEN`) never acts — agents act through the OpenCode `hub` tool, which holds the session's separate
|
|
657
|
+
act token. Outside the grant the hub answers `needs_user`: the CLI exits 4 and prints the evaluation (verdict,
|
|
658
|
+
tier, reason, the grants checked) and the Decision opened for the user, if any. `answer` takes `key=value` pairs (a
|
|
659
|
+
repeated key becomes a list), or the whole answer as JSON (`--json '{…}'`, or `--json -` from stdin; a bare
|
|
660
|
+
`--json` still means raw output), or `--cancel`. `--why` is recorded in the agent audit; for `deny` it is passed to
|
|
661
|
+
the harness. An already-answered request is `gone` (8), an offline machine `offline` (7).
|
|
662
|
+
|
|
663
|
+
**Grants.** `hub grant list | show | create | revoke` manage what a holder (`ses_…`, `@name` — pinned to that
|
|
664
|
+
session at creation —, a reserved `@@orchestrator…` role, or `app:<token id>`) may do: which verbs,
|
|
665
|
+
in which projects, which permission patterns (`--permit`, `--never`; never wins), for how long and how often. Only
|
|
666
|
+
the user manages grants (the console, or `hub login` with a device token): from a session's shell `create` and
|
|
667
|
+
`revoke` exit 5 without asking the hub, and any credential the hub refuses exits 5. Agents `hub grant request` one
|
|
668
|
+
instead (a session, or an app token with `messages:send`): the same flags, the holder defaulting to the requester,
|
|
669
|
+
and a `--why` the user reads; it prints the Decision it opened, and the user's answer comes back as a reply. See
|
|
670
|
+
[Agent actions and grants](#agent-actions-and-grants).
|
|
671
|
+
|
|
672
|
+
**Launching.** `hub launch` starts a session on a machine running `hub node` ([docs/launches.md](docs/launches.md)): the flags become the
|
|
673
|
+
body of `POST /api/agent/launches`, with only the given fields (the hub fills in its defaults: harness `opencode`,
|
|
674
|
+
mode `worktree` — which needs `--branch` and uses `<repo>/.worktrees/<branch>` —, runtime `host`, end `idle`,
|
|
675
|
+
cleanup `keep`). `--repo` defaults to the working directory and relative paths resolve against it; the path is the
|
|
676
|
+
repository on the node and must be under that node's workspace roots. `--label k=v` tags the launch for apps
|
|
677
|
+
(`hub launches list --label brain.task=abc` finds it again); `--node-label k=v` and `--machine` pick nodes, required
|
|
678
|
+
unless `--prefer`. `--prompt -` reads the first prompt from stdin. `--key` makes retries safe: the same key and
|
|
679
|
+
flags answer the existing launch (`… was already launched with key …`), the same key with other flags is invalid
|
|
680
|
+
(exit 2). It prints the launch and its state (`queued`, `placing`, `provisioning`, `running` with the session,
|
|
681
|
+
`ended` with an outcome: `idle`, `needs_user`, `failed`, `timeout`, `stopped`, `lost`); `hub launches show` adds the
|
|
682
|
+
workspace, steps and artifacts (branch, head, commits). `hub launches stop` ends a launch (`stopped`); one that
|
|
683
|
+
already ended exits 8 (`gone`). The user may always launch; an app token needs scope `sessions:launch` **and** a
|
|
684
|
+
grant with verb `launch` covering the repo (exit 5 otherwise; the kill switch pauses it); sessions launch only
|
|
685
|
+
through the OpenCode `hub` tool's act token. Apps and sessions see and stop only the launches they started.
|
|
686
|
+
Too many launches exit 6 with the retry time. Apps that register a webhook verify its `Hub-Signature` with the
|
|
687
|
+
SDK's `verifyWebhook({body, headers, secret})` (HMAC-SHA256 of `<t>.<raw body>`, compared in constant time,
|
|
688
|
+
timestamps older than 5 minutes refused). The spec's fields and limits, states, placement, caps and webhooks are in
|
|
689
|
+
[docs/launches.md](docs/launches.md).
|
|
690
|
+
|
|
691
|
+
**hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
|
|
692
|
+
machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
|
|
693
|
+
least `{"workspaceRoots": ["/Users/you/projects"]}` (optional `labels`, `slots`, `memoryMB`); it exits 1 when the
|
|
694
|
+
machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. `hub node install` writes a
|
|
695
|
+
launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
|
|
696
|
+
`~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
|
|
697
|
+
the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
|
|
698
|
+
`HUB_SPOKE_CONFIG` / `XDG_CONFIG_HOME` when set, and **prints** the `launchctl` / `systemctl` commands that load it;
|
|
699
|
+
hub never runs them. `--dry-run` prints the file without writing it; a second install says it is up to date (or
|
|
700
|
+
updates it), and warns when the node would not run yet. `hub node uninstall` removes the file and prints the
|
|
701
|
+
commands that stop the service; `hub node status` shows the file (up to date or not), the enrolment (never the
|
|
702
|
+
token), node.json and the commands to inspect the service. What the node does to your repositories (worktrees under
|
|
703
|
+
`<repo>/.worktrees/<branch>`, cleanup that never forces) is in [docs/launches.md](docs/launches.md#hub-node).
|
|
704
|
+
|
|
705
|
+
**Other harnesses.** `hub claude` runs Claude Code with the hub's plugin loaded through `--plugin-dir` and starts the
|
|
706
|
+
per-user Claude Code daemon (`hub claude-code daemon`) when none runs; `hub codex` runs Codex with its shared
|
|
707
|
+
app-server daemon turned on for that run and starts the per-user Codex spoke (`hub codex-spoke serve`). Both pass
|
|
708
|
+
every other argument and the exit code through, never write `~/.claude` or `~/.codex`, and run the harness without
|
|
709
|
+
the hub (saying why) when the machine is not enrolled. `hub claude-code install` and `hub codex-spoke install` write
|
|
710
|
+
a launchd agent or systemd user unit for those processes, like `hub node install` (printed, never loaded), so
|
|
711
|
+
`claude-code` and `codex` launches can be placed without a wrapper run. `hub preset list | check | run` drives a
|
|
712
|
+
harness without a spoke through a pane on a private tmux server. Settings, the op matrix and the limits:
|
|
713
|
+
[docs/harnesses.md](docs/harnesses.md).
|
|
714
|
+
|
|
715
|
+
**Credentials.** `HUB_URL` + `HUB_SESSION_TOKEN` (the spoke sets them in each session's shells; the CLI then
|
|
716
|
+
acts as that session) win over the login file. Outside a session, `hub login --url https://hub.example` stores
|
|
717
|
+
an app token in `~/.config/hub/cli.json` (`$XDG_CONFIG_HOME` respected; mode 0600) after checking it with
|
|
718
|
+
`whoami`; the token is read from stdin (or prompted) unless `--token` is given — prefer stdin, since arguments are
|
|
719
|
+
visible to other local users in `ps`. App tokens are created by a signed-in user with
|
|
720
|
+
`POST /api/agent/app-tokens {label?, scopes?}` (scopes `sessions:read`, `messages:send`, `messages:read`; shown once) and revoked with
|
|
721
|
+
`DELETE /api/agent/app-tokens/:id`; `hub logout` only deletes the file. App tokens can read but not name or
|
|
722
|
+
claim roles (writes are for sessions and users). `whoami` and `status` warn on stderr when `OPENCODE_SESSION_ID`
|
|
723
|
+
is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from its own environment.
|
|
724
|
+
|
|
725
|
+
| Exit | When |
|
|
726
|
+
|---|---|
|
|
727
|
+
| 0 | ok |
|
|
728
|
+
| 1 | other errors, network failures, a watch stream the hub closed, an `ask --wait` declined or not yet answered |
|
|
729
|
+
| 2 | usage (unknown command or option, missing argument; also the hub's `invalid`) |
|
|
730
|
+
| 3 | `not_found`, `ambiguous` (candidates listed), `name_taken` |
|
|
731
|
+
| 4 | `needs_user` |
|
|
732
|
+
| 5 | `forbidden`, `paused` |
|
|
733
|
+
| 6 | `rate_limited` (with `retryAfter`) |
|
|
734
|
+
| 7 | `unsupported`, `offline` |
|
|
735
|
+
| 8 | `gone`; an `ask --wait` that expired |
|
|
736
|
+
| 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
|
|
737
|
+
|
|
738
|
+
### hub mcp (MCP server)
|
|
739
|
+
|
|
740
|
+
`hub mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio for MCP clients outside
|
|
741
|
+
OpenCode (Claude Desktop, Cursor, other agents). It speaks JSON-RPC 2.0, one message per line (MCP's stdio
|
|
742
|
+
transport; protocol revisions `2025-06-18`, `2025-03-26` and `2024-11-05`), and answers `initialize`, `ping`,
|
|
743
|
+
`tools/list` and `tools/call`; notifications (`notifications/initialized`, `notifications/cancelled`, which aborts
|
|
744
|
+
the call) get no answer, other methods answer `-32601`. Its tools are the CLI's verbs: `whoami`, `status`,
|
|
745
|
+
`sessions`, `show`, `log`, `who`, `msg`, `ask`, `reply`, `decline`, `inbox` and the act verbs `approve`, `deny`,
|
|
746
|
+
`answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
|
|
747
|
+
required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
|
|
748
|
+
`runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
|
|
749
|
+
`cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`), with the same arguments
|
|
750
|
+
(`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
|
|
751
|
+
tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
|
|
752
|
+
launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
|
|
753
|
+
most 60 s (one long poll; a longer wait is cut to 60) and `expires` in seconds or as `2h`. Answers are the CLI's
|
|
754
|
+
short text; messages and answers from other sessions come in the hub's envelope and are marked as not from the
|
|
755
|
+
user. Hub errors are tool results with `isError: true` and the hub's `code: message` (plus candidates and hint),
|
|
756
|
+
never protocol errors. Only protocol frames go to stdout; the server ends when stdin closes (after answering the
|
|
757
|
+
calls in flight) or on SIGINT/SIGTERM.
|
|
758
|
+
|
|
759
|
+
```json
|
|
760
|
+
{ "mcpServers": { "hub": { "command": "/home/you/.local/state/hub/bin/hubctl", "args": ["mcp"] } } }
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
(the launcher in the hub bin dir, `$HUB_STATE_DIR/bin`; just `hubctl` when that dir is on the client's PATH).
|
|
764
|
+
|
|
765
|
+
**Identity.** The server acts as the CLI's credential, read for each call: `HUB_URL` + `HUB_SESSION_TOKEN` when
|
|
766
|
+
set, else the `hub login` file. MCP clients start servers without a hub session's shell environment, so in practice
|
|
767
|
+
it acts as the **app token** you logged in with (needs `sessions:read`, `messages:send` and `messages:read`), not
|
|
768
|
+
as an OpenCode session: messages are sent from the app, and replies to them land in its inbox (the `inbox` tool),
|
|
769
|
+
not in a session. Naming and roles stay out (app tokens cannot use them). Inside OpenCode, agents use the plugin's
|
|
770
|
+
`hub` tool instead, which acts as the calling session.
|
|
771
|
+
|
|
204
772
|
## Spoke (OpenCode plugin)
|
|
205
773
|
|
|
206
774
|
The plugin is a thin client ("spoke") of the hub. Every OpenCode process that loads
|
|
@@ -208,6 +776,13 @@ it opens **one** outbound WebSocket (`/spoke/ws`, protocol in `src/protocol.ts`)
|
|
|
208
776
|
knows, forwards their events, and runs turns, stops and permission replies on the hub's request.
|
|
209
777
|
The web app, speech and phones live on the hub.
|
|
210
778
|
|
|
779
|
+
Besides the session, tool and permission events, a spoke forwards `session.status` (busy/retry/idle) and the
|
|
780
|
+
shells and terminals sessions start (`session.shell.started/ended`, `shell.created/exited/deleted`,
|
|
781
|
+
`pty.created/updated/exited/deleted`) in compact form: id, session, command (≤ 200 characters), status and exit
|
|
782
|
+
code; never output, files or environment. When it connects (and again after its hub connection or OpenCode
|
|
783
|
+
event stream comes back) it asks its own server what runs now (`session.active` and `shell.list`) and sends a
|
|
784
|
+
`session.seed` event per running session, so a session that was already running shows as working at once.
|
|
785
|
+
|
|
211
786
|
| Entry | File | Role |
|
|
212
787
|
|---|---|---|
|
|
213
788
|
| 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 |
|
|
@@ -297,7 +872,9 @@ work to a background subagent. Routine workers inherit the fast voice model; cod
|
|
|
297
872
|
architecture and risky work use the configured full work model. Successful workers finish with a short
|
|
298
873
|
speech-ready result that the hub reads directly, without waiting for another voice-agent summary. You
|
|
299
874
|
can keep talking while work runs; related follow-ups continue the active worker and unrelated tasks start
|
|
300
|
-
a fresh one.
|
|
875
|
+
a fresh one. Several independent requests in one breath ("check the deploy and update the changelog") start
|
|
876
|
+
one background worker each, in parallel, in the same turn; each worker gets a short name ("Deploy check") that
|
|
877
|
+
the task board and announcements use.
|
|
301
878
|
|
|
302
879
|
The phone composer exposes separate **Voice** and **Worker** model controls for each session. Each picker
|
|
303
880
|
lists the connected OpenCode process's available models and variants. Choices are saved per user and
|
|
@@ -305,7 +882,11 @@ session, apply to the next turn or newly started worker, and fall back to the ma
|
|
|
305
882
|
`workModel` when unset.
|
|
306
883
|
|
|
307
884
|
The conversation is an inline execution timeline: streamed thinking, tools and nested subagents appear
|
|
308
|
-
between messages as compact expandable rows.
|
|
885
|
+
between messages as compact expandable rows. Rows keep the session's order, never wall-clock order: history
|
|
886
|
+
rows carry the spoke's `pos` (message order, then part order), live rows go after the history loaded when they
|
|
887
|
+
arrived (in arrival order) and are replaced in place by their stored row, and a subagent row stands where the
|
|
888
|
+
tool call that spawned it is (timeline tool rows of `subagent`/`task` carry `childSessionID`; a subagent whose
|
|
889
|
+
spawn is not loaded yet shows at the top of the loaded history). Reopening a session pages its projected message history from
|
|
309
890
|
OpenCode. Expanding a tool fetches its complete input/output from the owning OpenCode process; the hub does
|
|
310
891
|
not persist those potentially sensitive details.
|
|
311
892
|
|
|
@@ -320,8 +901,31 @@ not persist those potentially sensitive details.
|
|
|
320
901
|
agent exists, the session keeps its agent and the `context` hook adds the orchestrator prompt instead.
|
|
321
902
|
- The delegate agent (`subagent` in the config) is added per request by the `context` hook, so changing
|
|
322
903
|
it needs no restart.
|
|
904
|
+
- **Task board**: the `context` hook also gives the voice agent (and a conversation on another agent) a live
|
|
905
|
+
list of the workers it started (`src/spoke/taskboard.ts`, from this process's events): title, sessionID,
|
|
906
|
+
state (working, blocked with what it waits for, done with its latest text, failed with the reason, stopped)
|
|
907
|
+
and for how long, at most 8 (live ones first) of at most 200 characters each. So "what's running?" or "how's
|
|
908
|
+
the deploy going?" needs no tool call. A session without workers gets no board.
|
|
909
|
+
- **Stopping one task by voice**: the plugin adds a tool `hub_task_stop {sessionID} | {all: true}`. The voice
|
|
910
|
+
agent calls it for "cancel the deploy one" (that worker and whatever runs below it are interrupted, deepest
|
|
911
|
+
first) or "stop all the workers" (every running worker of the session). It only reaches sessions below the
|
|
912
|
+
calling session (never the caller itself, its siblings or another session's workers), so the voice agent
|
|
913
|
+
keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
|
|
914
|
+
interrupt the root turn at once.
|
|
915
|
+
- **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
|
|
916
|
+
`decline`, `inbox`, `who`, `status` and `whoami` (`src/spoke/hub-tool.ts`). It acts as the calling session
|
|
917
|
+
with that session's own hub token (the token never appears in the tool's output). `ask` with `wait` (seconds,
|
|
918
|
+
at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
|
|
919
|
+
message. Messages from other sessions are shown in the hub's envelope and are not from the user.
|
|
323
920
|
- 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;
|
|
921
|
+
child is emitted as root-session speech; interruptions and stale partial results are not. **Announcements**
|
|
922
|
+
(hub, `src/central/registry.ts`): while another worker of the session runs or waits, or one was announced in
|
|
923
|
+
the last minute, a result starts with the task's title ("Deploy check finished: …"); a result the voice agent
|
|
924
|
+
already said in the last two minutes is not read again. A failed worker is announced once with its reason
|
|
925
|
+
("Lint failed: rate limit exceeded."), a worker's permission request or question once per request ("Cleanup
|
|
926
|
+
needs your permission.", "… has a question."); a stopped worker is not. Announcements are ordinary `say`s
|
|
927
|
+
without `seq` (`messageID` `announce:failed:<session>`, `announce:permission:<id>`, `announce:form:<id>`),
|
|
928
|
+
so the phone queues them after the sentence playing and never cuts one off.
|
|
325
929
|
- Server ops: `steer` sends the text with `delivery: "steer"` (metadata `{source: "hub", mode:
|
|
326
930
|
"conversation"}`, or `mode: "remote"` for a session not shared with Voice; 0.5 wrote `source:
|
|
327
931
|
"opencode-hub"` and 0.4 and earlier `source: "opencode-voice"`, and readers accept all three) and returns the inbox ID at once; `interrupt`
|
|
@@ -348,7 +952,10 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
|
|
|
348
952
|
| `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
349
953
|
| `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
|
|
350
954
|
| `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) |
|
|
955
|
+
| `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); `token-keys.json` (0600): the HMAC keys that sign session tokens (created on first use, older keys kept so their tokens still verify); `agent/` (0700, files 0600, created on first use): agent data such as messages and name claims, one JSON file per user and collection, written shortly after each change and on shutdown |
|
|
956
|
+
| `HUB_TOKEN_KEYS` | | overrides `token-keys.json` with the same JSON, `{"active": "<kid>", "keys": [{"kid": "<kid>", "secret": "<base64 of ≥ 32 random bytes>", "created": <ms>}]}`, e.g. a secret shared by several hub instances; the file is then never written |
|
|
957
|
+
| `HUB_DATABASE_URL` / `HUB_VALKEY_URL` | | cluster mode: several hub pods sharing Postgres and Valkey (both or neither; requires `HUB_AUTH=oidc` and `HUB_TOKEN_KEYS`); see [docs/cluster.md](docs/cluster.md) |
|
|
958
|
+
| `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
|
|
352
959
|
| `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
|
|
353
960
|
| `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
|
|
354
961
|
| `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
|
|
@@ -374,7 +981,29 @@ the Docker image, npm package and binaries ship the build). One store fed by the
|
|
|
374
981
|
- **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
|
|
375
982
|
terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
|
|
376
983
|
title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
|
|
377
|
-
(pin,
|
|
984
|
+
(rename, pin, archive, stop, Allow/Disallow Voice).
|
|
985
|
+
- **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
|
|
986
|
+
Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
|
|
987
|
+
archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
|
|
988
|
+
will map to it). The **Archived** chip on `/` lists archived sessions with **Unarchive**. One exception: an
|
|
989
|
+
archived session that needs you (a permission or question) also shows under **Needs you** (sidebar and the
|
|
990
|
+
Needs you chip), so a question is never missed. In the table, tick rows (or the header box for all shown
|
|
991
|
+
rows) and press **Archive** (or **Unarchive** under Archived); **Archive inactive…** archives every session
|
|
992
|
+
without activity for more than N days (30 by default; not running, pinned or open in a terminal) after a
|
|
993
|
+
confirmation with the count. Sessions archived in OpenCode itself (`time.archived`) never reach the hub.
|
|
994
|
+
Sessions hidden with an earlier version are archived now (same stored list, `hiddenSessions`).
|
|
995
|
+
- **Rename**: **Rename…** in a session's `⋯` menu (table row, sidebar row, phone list, the session's title
|
|
996
|
+
menu), or a click on the title in the session's top bar, turns the title into a text field with the title
|
|
997
|
+
selected: Enter (or clicking away) saves, Escape cancels. The new title shows at once and is set in
|
|
998
|
+
OpenCode itself (`POST /api/sessions/:id/rename`), so terminals see it too; if that fails the old title
|
|
999
|
+
comes back with a message.
|
|
1000
|
+
- **Auto-name**: **Auto-name** in the same `⋯` menus, or the ✨ button next to the rename field, has OpenCode
|
|
1001
|
+
name the session with its Title agent (what `/rename` without a title does in the terminal; the model is
|
|
1002
|
+
`agents.title.model`), from the first request and the recent conversation. The title shows "Naming…" until
|
|
1003
|
+
OpenCode answers, then a toast says "Renamed to …" or "OpenCode kept the title". Needs this plugin version
|
|
1004
|
+
in the session's OpenCode process (older ones answer "Update the Hub plugin to use Auto-name") and a process
|
|
1005
|
+
that can reach its own server: the background service, or `opencode serve --port …` with
|
|
1006
|
+
`OPENCODE_SERVER_PASSWORD` (a TUI's own `serve --stdio` server cannot; use `/rename` there).
|
|
378
1007
|
- **`/`**: All sessions as a sortable, filterable table (a list on phones); filters and sort are kept per device.
|
|
379
1008
|
There is no Voice column or Voice filter: the live Voice session gets the green dot before its title, and the
|
|
380
1009
|
row's `⋯` menu has **Allow Voice** / **Disallow Voice**.
|
|
@@ -440,8 +1069,8 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
|
|
|
440
1069
|
group: New session here, Pin / Unpin workspace, Hide / Unhide workspace (and Collapse / Expand in
|
|
441
1070
|
Recent). Hiding a workspace hides the sessions in it: a Herdr workspace (`herdr:<id>`), a project
|
|
442
1071
|
folder, or a nested folder such as a worktree.
|
|
443
|
-
- **Synced view settings**: pins and
|
|
444
|
-
once, reverted with a message if the hub refuses, with Undo after
|
|
1072
|
+
- **Synced view settings**: pins, archived sessions and hidden workspaces are per user on the hub (`GET`/`PUT /api/prefs/view`), shown at
|
|
1073
|
+
once, reverted with a message if the hub refuses, with Undo after an archive. Other devices update live
|
|
445
1074
|
(SSE `prefs`). Pins from 0.3 (stored on the device) are moved into them once. With an older hub
|
|
446
1075
|
without `/api/prefs/view`, they stay on the device. Open/closed groups and the filter stay per device.
|
|
447
1076
|
- **Voice off** sessions (`exposed: false`, mode `session`) open, show their status and live activity, and
|
|
@@ -461,16 +1090,40 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
|
|
|
461
1090
|
failed, the session still exists and the hub's `warning` is shown. 409 (background service not
|
|
462
1091
|
connected) is shown under the composer.
|
|
463
1092
|
|
|
464
|
-
**Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks
|
|
465
|
-
it wants (Settings → Notifications
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1093
|
+
**Notifications** (web push, VAPID keys generated once and stored in the state file). Each device picks each
|
|
1094
|
+
kind it wants (Settings → Notifications, grouped, with a one-line description each and a "You'll get: …"
|
|
1095
|
+
summary); [docs/notifications.md](docs/notifications.md) lists every kind, its trigger and what is not notified:
|
|
1096
|
+
|
|
1097
|
+
- **Needs you**: a **question**, a **permission** (on by default).
|
|
1098
|
+
- **Session**: **All done** (on): the root session's last turn succeeded and the whole tree is settled (no
|
|
1099
|
+
subagent or shell job running, nothing waiting for an answer, no hub turn) for 15 s, once per run;
|
|
1100
|
+
**Failed** (on, with the reason); **Turn finished** (every root turn, 0.7's "finished": off, noisy because
|
|
1101
|
+
each background subagent that completes starts a short continuation turn); **Stopped** (shutdown,
|
|
1102
|
+
inactivity) and **Retrying** (provider retries), off.
|
|
1103
|
+
- **Subagents**: each subagent **finished** / **failed** (off). **Scripts and tools**: a background shell or
|
|
1104
|
+
terminal **exited 0** / **failed** (non-zero, killed, timed out), a **long tool** (≥ 30 s) ended (off).
|
|
1105
|
+
- **Voice**: spoken replies while no device shows the session (`say`) and **hand-offs** from the terminal (on).
|
|
1106
|
+
- **Activity digest** (off, below).
|
|
1107
|
+
|
|
1108
|
+
Titles read "*session* · *kind*" ("KVM Rewrite · All done", "KVM Rewrite · Subagent S3 macros finished",
|
|
1109
|
+
"KVM Rewrite · Script exited 1"). Nothing but questions, permissions, hand-offs and spoken replies is sent
|
|
1110
|
+
while a device shows the session. Stored 0.7 prefs migrate on start: `finished` becomes All done (not every
|
|
1111
|
+
turn), the rest is kept and the new kinds start off (0.5.0's single "blocked" toggle still becomes question +
|
|
1112
|
+
permission). Optional **quiet hours** per device (local start/end time in the browser's time
|
|
471
1113
|
zone) hold everything except hand-offs; **Mute notifications** in a session's ⋯ menu silences one session
|
|
472
1114
|
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
|
|
1115
|
+
(Voice replies: 10 s; subagents, scripts and long tools: per subagent, job or call); expired subscriptions
|
|
1116
|
+
(404/410) are pruned.
|
|
1117
|
+
|
|
1118
|
+
**Activity updates** (pref `activity`, off by default on every device; Settings → Notifications shows a hint
|
|
1119
|
+
until it is on): to know what happens without keeping the screen on. Steps of interest in a session tree — a
|
|
1120
|
+
subagent started or ended, a tool that took 30 s or more, a background shell or terminal that ended (with its
|
|
1121
|
+
exit code: "bun run build exited 0", "was killed", "timed out"), the session going idle — become one
|
|
1122
|
+
notification per session, tagged per session so each replaces the last quietly (no new sound, normal
|
|
1123
|
+
urgency, 5-minute TTL). The first step after a quiet spell goes out at once; later ones are collected until the
|
|
1124
|
+
device's interval ends (Settings: 1–60 minutes, default 5) and sent together ("3 tools · explore finished ·
|
|
1125
|
+
Finished"). Short tools are only counted. Quiet hours and muted sessions apply; nothing is sent while a
|
|
1126
|
+
device shows the session.
|
|
474
1127
|
|
|
475
1128
|
- **Status per device**: Settings → Notifications shows the browser permission, whether the service worker
|
|
476
1129
|
is registered and whether this hub holds this browser's subscription. "Allowed, but this device is not
|
|
@@ -478,12 +1131,14 @@ for you on every device (hand-offs still come through). At most one per session
|
|
|
478
1131
|
notifications on this device** fixes it. When permission is granted, the app re-subscribes by itself on
|
|
479
1132
|
load if the hub lost the subscription, and the service worker re-subscribes when the browser rotates it
|
|
480
1133
|
(`pushsubscriptionchange`). **Send test notification** sends one to this device only and shows the result
|
|
481
|
-
(sent, or the push service's status).
|
|
1134
|
+
(sent, or the push service's status); pick a kind next to it to see how that kind looks.
|
|
482
1135
|
- **Delivery**: questions, permissions and failures are sent with `urgency: high` and a TTL of one hour so a
|
|
483
1136
|
phone in doze gets them; they stay on screen until handled (`requireInteraction`), vibrate, and replace the
|
|
484
1137
|
previous one of the same session and kind. A permission notification asks the hub (with the device's
|
|
485
1138
|
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?}`.
|
|
1139
|
+
the payload itself never carries it. Payloads are `{kind, sessionID, title, body, requestID?, test?}`.
|
|
1140
|
+
Failures (and failed subagents and scripts) vibrate; progress kinds (turn, retrying, subagent and script
|
|
1141
|
+
finished, long tool, activity) replace the previous one of the session quietly.
|
|
487
1142
|
- **Android (Chrome)**, for notifications that arrive with the screen off: open the public URL, sign in or
|
|
488
1143
|
pair, then Chrome's menu → **Install app** (or Add to Home screen) and use the installed app; Settings →
|
|
489
1144
|
Notifications → **Enable notifications on this device** and **Send test notification**. In Android
|
|
@@ -562,6 +1217,16 @@ disconnects their machines at once.
|
|
|
562
1217
|
login links from `/hub` belong to the machine's owner. The bootstrap pairing code and the local
|
|
563
1218
|
first-machine auto-approve exist only in `pairing` mode.
|
|
564
1219
|
|
|
1220
|
+
**Native apps** link from Settings → Devices → **Add app**: the dialog shows a QR code of
|
|
1221
|
+
`hubapp://link?hub=<public URL>&code=<code>` (and the link itself), with a 5-minute countdown. The code is
|
|
1222
|
+
single use, 256-bit, belongs to the user who opened the dialog (who must still be active when it is redeemed)
|
|
1223
|
+
and works only for linking: `/login` and `/api/pair` refuse it. The app calls `POST /api/devices/redeem
|
|
1224
|
+
{code, deviceName?, publicKey?}` (no cookie, and no `Origin` needed: the code is the only credential) and gets
|
|
1225
|
+
`{device, token}`; it keeps `token` and sends it as `Authorization: Bearer <device token>`. The device appears
|
|
1226
|
+
in the list (audit `device.pair`, "app link"); revoking it invalidates the token. `deviceName` is cleaned
|
|
1227
|
+
(control characters dropped, at most 64 characters, "App" when empty); `publicKey` (PEM or base64, at most
|
|
1228
|
+
4096 characters) is stored with the device for later strong approvals.
|
|
1229
|
+
|
|
565
1230
|
**Speech**: each user can set their own provider, models, voice and key (Settings → Speech); fields they
|
|
566
1231
|
leave empty come from the server default (admins: Settings → Speech → Server default), then `hub.json`,
|
|
567
1232
|
then the env. A user's key is only ever shown (masked) to that user.
|
|
@@ -601,8 +1266,8 @@ unchanged sessions frame does not re-broadcast the catalog.
|
|
|
601
1266
|
|---|---|---|
|
|
602
1267
|
| `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
1268
|
| `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) |
|
|
1269
|
+
| `GET /api/prefs/view` | – | `{prefs: {hiddenSessions, hiddenWorkspaces, pinnedSessions, pinnedWorkspaces}}`, the caller's view preferences (pairing mode: the single user). `hiddenSessions` is the user's **archived** sessions (the name is from before Archive was called Hide) |
|
|
1270
|
+
| `PUT /api/prefs/view {any of the four lists}` | – | Replaces the lists given (each ≤ 500 non-empty strings, `hiddenSessions` ≤ 5000, of ≤ 4096 characters, duplicates dropped; other keys 400). Stored on the user record; SSE `{type: "prefs", prefs}` goes to the user's other devices (never to other users, not to the device that changed them) |
|
|
606
1271
|
|
|
607
1272
|
For a session reported only with `exposed: false`, `utterance` and `conversation` answer
|
|
608
1273
|
`403 {error: "Voice is not enabled for this session", code: "not_exposed"}`; inspecting and controlling it
|
|
@@ -613,7 +1278,12 @@ session.
|
|
|
613
1278
|
|
|
614
1279
|
### Hub API
|
|
615
1280
|
|
|
616
|
-
All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`).
|
|
1281
|
+
All routes need the device cookie; POSTs also need a same-origin `Origin` (or `Referer`). Native apps and
|
|
1282
|
+
other non-browser clients send the same device secret as `Authorization: Bearer <device token>` instead: same
|
|
1283
|
+
powers, no same-origin check (a request with both a cookie and a bearer token is refused with
|
|
1284
|
+
`400 {"error": …, "code": "invalid"}`; an unknown bearer token is `401 {"code": "unauthorized"}`). Other bearer
|
|
1285
|
+
tokens (session and app tokens) are refused on these routes (`403 {"code": "forbidden"}`); they are for
|
|
1286
|
+
`/api/agent/*` only. Every
|
|
617
1287
|
`/api/sessions/:id…` route answers `404 {"error": "Not found"}` for a session that none of the caller's
|
|
618
1288
|
machines reports (since 0.3.0; before, a session nobody reported was 409), so another user's session looks
|
|
619
1289
|
exactly like an unknown one. For a loopback
|
|
@@ -635,21 +1305,39 @@ hub sends it `{op: "conversation", on: true}` once. Deleting the session clears
|
|
|
635
1305
|
|
|
636
1306
|
SSE (`/api/events`) adds:
|
|
637
1307
|
|
|
1308
|
+
- Each session's status tree entry may carry `jobs` (`[{id, kind: "shell" | "pty", command?, status:
|
|
1309
|
+
"running" | "exited" | "timeout" | "killed", exit?, background?, started?, ended?}]`: running ones plus the
|
|
1310
|
+
last 5 that ended) and `retry` (`{attempt?, message?}`, with `detail` "Retrying: …"). A session is
|
|
1311
|
+
`working` while its run, a busy/retry `session.status` or a running job lasts. The console shows a job count
|
|
1312
|
+
next to the status (table, sidebar), "Retrying …" or "· 1 job" in the session header, and a **Jobs** card in
|
|
1313
|
+
the rail (command, running time, exit code for a minute after it ends).
|
|
1314
|
+
|
|
638
1315
|
- `{type: "conversation", sessionID, on}` when the flag changes.
|
|
639
|
-
- `{type: "say", sessionID, messageID, ordinal, text}
|
|
640
|
-
|
|
641
|
-
|
|
1316
|
+
- `{type: "say", sessionID, messageID, ordinal, seq, text}`: **root** text in conversation mode is spoken
|
|
1317
|
+
while it is written. The hub cuts the streamed `session.text.delta`s of each text part (`messageID`,
|
|
1318
|
+
`ordinal`) into sentences (`src/central/say-stream.ts`: sentence ends followed by the next sentence, line
|
|
1319
|
+
breaks, whole fenced code blocks, abbreviations/decimals/file names kept together, runs over 300 chars cut
|
|
1320
|
+
at a comma) and sends one `say` per completed sentence right after its delta, `seq` counting from 0 per text
|
|
1321
|
+
part; `session.text.ended` (authoritative: missed deltas are recovered) flushes the rest. Sentences are
|
|
1322
|
+
raw slices of the text: `/api/speak` makes each one speakable. One process's deltas are used per text part
|
|
1323
|
+
and the end is handled once, so each sentence is sent once; an interrupted or failed run, or conversation
|
|
1324
|
+
mode turning off, drops the unspoken rest. A successful subagent's final message is spoken through its
|
|
1325
|
+
root as before: one `say` per text part without `seq`. Blank text is skipped.
|
|
642
1326
|
- `{type: "heard", sessionID, inboxID}` when OpenCode delivers an utterance's inbox item (also when the
|
|
643
1327
|
delivery event arrives before the steer result).
|
|
644
1328
|
- `{type: "activity", sessionID, rootID, item}` when an activity item is created or changes state, for
|
|
645
1329
|
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}
|
|
1330
|
+
tool?, summary?, background?, childSessionID?, state: "running" | "done" | "failed", error?, at}`
|
|
1331
|
+
(`childSessionID`: the session a subagent tool started, once the tool reports it); `sessionID` is where it
|
|
647
1332
|
happened (a subagent session maps to its root `rootID`). The hub keeps the 30 most recent items per
|
|
648
1333
|
root (in memory) and returns them, newest last, as `activity` in `GET /api/sessions/:id`. Spokes forward
|
|
649
1334
|
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.
|
|
1335
|
+
input (command, path, pattern…), ok/error; never file contents or outputs. A subagent tool's
|
|
1336
|
+
`session.tool.progress` goes out only as `session.tool.called {id, childSessionID}` (a type every hub
|
|
1337
|
+
accepts); other progress is dropped. Thinking items close on
|
|
651
1338
|
`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
|
|
1339
|
+
- `{type: "delta", sessionID, rootID, messageID?, ordinal?, text}` (assistant text; `ordinal`: which text part of
|
|
1340
|
+
the message, as OpenCode numbers them) and `{type: "reasoning", sessionID,
|
|
653
1341
|
rootID, messageID?, delta}` (reasoning text; spokes cut each delta to 2000 chars and never forward the full
|
|
654
1342
|
text of `session.reasoning.ended`) for root **and** subagent sessions. Per session they are coalesced to at
|
|
655
1343
|
most one send per 100 ms (the first delta goes out at once, later ones in the window are joined); anything
|
|
@@ -657,11 +1345,13 @@ SSE (`/api/events`) adds:
|
|
|
657
1345
|
- `status` entries of working sessions carry `since` (ms epoch the current run started), also in the catalog
|
|
658
1346
|
and `GET /api/sessions/:id`.
|
|
659
1347
|
|
|
660
|
-
Pushes
|
|
661
|
-
(the
|
|
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`).
|
|
664
|
-
`HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks.
|
|
1348
|
+
Pushes depend on presence: while the session is watched, run notifications (all done, turn, failed, …) are
|
|
1349
|
+
not sent (the page is open, and in conversation mode speaking). When nobody watches, they are sent as usual and in conversation mode each spoken text
|
|
1350
|
+
of the root session (the whole text part, when it ends) is pushed as kind `say` (title = session title, body = the text, max 180 chars; at
|
|
1351
|
+
most one every 10 s per session; on by default, pref `say`). Questions and permissions are always sent. `/api/transcribe` and `/api/speak` have no rate limit; `/api/speak` accepts up to
|
|
1352
|
+
`HUB_MAX_SPEECH_CHARS` per call, so the phone sends sentence-sized chunks. `/api/speak` streams the provider's MP3 through as it
|
|
1353
|
+
arrives (chunked; nothing is buffered on the hub, and a phone that cancels aborts the upstream request), so playback
|
|
1354
|
+
can start before synthesis ends; provider errors still answer 502 before any audio.
|
|
665
1355
|
|
|
666
1356
|
### Speech settings
|
|
667
1357
|
|
|
@@ -671,8 +1361,8 @@ STT and TTS each use a provider configured from the phone (Settings → Speech)
|
|
|
671
1361
|
provider than `hub.json`, the file's section is ignored for that direction; when it picks another base
|
|
672
1362
|
URL, the file's key is not sent there. Providers:
|
|
673
1363
|
|
|
674
|
-
- `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is
|
|
675
|
-
TTS is `POST /audio/speech`.
|
|
1364
|
+
- `openrouter` (default, base `https://openrouter.ai/api/v1`): STT is an OpenAI-style multipart upload
|
|
1365
|
+
(`file`, `model`, `language?`: the audio goes up as binary, not base64 JSON), TTS is `POST /audio/speech`.
|
|
676
1366
|
- `openai-compatible` (`baseURL` required: `https://…`, or `http://localhost` / `127.0.0.1` for local
|
|
677
1367
|
servers): STT is multipart `POST {baseURL}/audio/transcriptions` (`file`, `model`, `language?`), TTS is
|
|
678
1368
|
`POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}`. No env key.
|
|
@@ -691,27 +1381,62 @@ Writes need the same-origin `Origin`. Provider failures are 502 with the provide
|
|
|
691
1381
|
### Conversation mode (phone)
|
|
692
1382
|
|
|
693
1383
|
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
|
|
1384
|
+
(mono, with the browser's echo cancellation, noise suppression and auto gain on), then calls `POST …/conversation {on:true}`, keeps
|
|
695
1385
|
the screen awake (Screen Wake Lock, taken again when the page becomes visible) and shows a level ring
|
|
696
1386
|
around the mic with the current state: Listening, Hearing you, Transcribing, Sent ✓ and Speaking. Tap
|
|
697
1387
|
it again, say "pause listening", or leave the page to turn it off. Leaving the page calls
|
|
698
1388
|
`{on:false}` with `keepalive`. While conversation mode is off, tap-to-talk works as before.
|
|
699
1389
|
|
|
700
|
-
- **
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1390
|
+
- **Speech detection** (`web/src/core/speech-vad.ts`; ~20 ms frames from `web/public/vad-worklet.js`, with a
|
|
1391
|
+
ScriptProcessor fallback): on-device [Silero VAD](https://github.com/snakers4/silero-vad) v5 (MIT) through
|
|
1392
|
+
onnxruntime-web (MIT, wasm, single-threaded, so no COOP/COEP headers are needed) in a Web Worker
|
|
1393
|
+
(`web/src/voice/silero-worker.ts`). The mic audio is resampled to 16 kHz and cut into 32 ms chunks; each
|
|
1394
|
+
chunk's speech probability starts speech at ≥ 0.6 sustained for 250 ms (most of the onset voiced, so
|
|
1395
|
+
isolated clicks never add up) and ends it after 350 ms below 0.35. Typing, a fan, clatter, beeps and hum
|
|
1396
|
+
score low; speech scores high at any loudness. An energy detector stays as a cheap pre-gate (chunks within
|
|
1397
|
+
3 dB of the adaptive noise floor skip the model) and as the fallback while the model loads, if it fails to
|
|
1398
|
+
load (logged once in the console) or stalls: noise floor that falls fast and rises slowly, threshold 10 dB
|
|
1399
|
+
above it with 4 dB of hysteresis, 300 ms to count. 300 ms of audio before the onset is kept. The model
|
|
1400
|
+
(2.3 MB) and the wasm (12 MB; 2.3 MB with Brotli) load when Voice mode first starts, as hashed static
|
|
1401
|
+
assets the hub serves with `cache-control: immutable` (and compressed when the browser accepts it), outside
|
|
1402
|
+
the main JS chunk; the Voice bar's tooltip says which detector runs and its time per chunk (about 1 ms).
|
|
1403
|
+
- **Mic sensitivity** (Settings → Voice, per device, Normal by default, applies at once): Low needs ≥ 0.75 for
|
|
1404
|
+
350 ms (ends below 0.45; pre-gate 6 dB; energy fallback 13 dB / 400 ms), Normal as above, High ≥ 0.45 for
|
|
1405
|
+
180 ms (ends below 0.25; no pre-gate margin; energy fallback 7 dB / 250 ms).
|
|
1406
|
+
- **Echo guard**: while a reply or a progress cue plays, and 400 ms after, a barge-in needs more: the model's
|
|
1407
|
+
probability ≥ 0.85 (Low 0.92, High 0.75) and 8 dB more energy. The browser's echo cancellation removes most
|
|
1408
|
+
of the reply from the mic first.
|
|
1409
|
+
- **Noise transcripts** (`web/src/core/junk-transcript.ts`): what speech-to-text makes of noise is dropped
|
|
1410
|
+
before it reaches the agent and shown dim in the Voice bar for 3 s ("ignored: “Thank you.”"): empty or
|
|
1411
|
+
punctuation-only text, sound tags ("[Music]", "(applause)", "♪"), Whisper's stock phrases ("Thank you.",
|
|
1412
|
+
"you", "Bye.", "Thanks for watching!", "Subtitles by …", "Amara.org" …), one word repeated, and one or two
|
|
1413
|
+
words from under 400 ms of speech unless they hold a command or answer word ("yes", "no", "okay", "stop",
|
|
1414
|
+
"cancel", "wait", "go ahead", "cancel that" …). With a Whisper model the hub also asks for `verbose_json` and
|
|
1415
|
+
leaves out segments Whisper rates `no_speech_prob` > 0.6 (OpenRouter and OpenAI-compatible providers; one
|
|
1416
|
+
that refuses `verbose_json` gets plain requests from then on).
|
|
1417
|
+
- **Endpointing**: a segment ends after 350 ms of silence. It is encoded to 16 kHz mono 16-bit WAV
|
|
705
1418
|
and sent to `/api/transcribe` at once. A transcript that reads as complete is sent right away:
|
|
706
1419
|
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
|
|
1420
|
+
Otherwise it waits up to 0.8 s. If you speak again during the wait, the next segment's transcript
|
|
708
1421
|
is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
|
|
709
1422
|
- **Commands**: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on"
|
|
710
1423
|
calls `POST …/interrupt` and stops speech. "Pause listening" turns the mode off.
|
|
711
|
-
- **Speech out**:
|
|
1424
|
+
- **Speech out**: root replies arrive one sentence at a time while they are written (`say` with `seq`);
|
|
1425
|
+
the phone plays them in `seq` order per text part and drops duplicates (`SayAssembler`). A `say` without
|
|
1426
|
+
`seq` (a subagent's result) is split into sentences. Abbreviations, decimals, file names and
|
|
712
1427
|
list items are handled, and fenced code becomes "(code omitted)". Each sentence goes to `/api/speak`,
|
|
713
|
-
|
|
714
|
-
|
|
1428
|
+
up to two ahead of playback, and plays through one `<audio>` element while its MP3 is still downloading:
|
|
1429
|
+
through MediaSource (`audio/mpeg` SourceBuffer; Chrome, Android, Firefox), appending bytes as they arrive
|
|
1430
|
+
(`web/src/core/audio-stream.ts`). Browsers without MP3 in MediaSource (some iOS Safari versions) play each
|
|
1431
|
+
sentence once it has fully downloaded, as before. Prefetched sentences keep downloading while they wait. Speech while a reply plays
|
|
1432
|
+
(barge-in, past the echo guard) pauses playback, clears the queue and mutes the rest of the replies that were speaking, also a reply still
|
|
1433
|
+
being written whose next sentence has not arrived yet (their later sentences still show in the transcript); a
|
|
1434
|
+
new reply speaks again. Barge-in never interrupts anything: what you say steers the agent, and background
|
|
1435
|
+
workers keep running (only a stop phrase interrupts the agent's turn).
|
|
1436
|
+
- **Turn timing**: each turn's steps are `performance.mark`s (`hub-voice:speech-end`, `…:transcript`,
|
|
1437
|
+
`…:accepted`, `…:first-say`, `…:first-byte` (the reply's first audio byte arrived), `…:first-audio` (it plays); visible in the browser's performance tools), and the Voice bar's
|
|
1438
|
+
state has the last turn as its tooltip: "last turn: 1.8 s to first audio" with the steps (transcript,
|
|
1439
|
+
accepted, first reply, first byte, audio). A typed turn counts from when the hub accepted it. The transcript shows your utterances with ✓ (sent)
|
|
715
1440
|
and ✓✓ (heard). Agent text is merged with the bubble already streamed from deltas. Subagents
|
|
716
1441
|
starting, finishing or failing appear as small notes. Typed text is sent as an utterance while the
|
|
717
1442
|
mode is on.
|
|
@@ -762,12 +1487,31 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
|
|
|
762
1487
|
assistant message); in conversation mode the spoken `say` text is merged into the bubble of the same message.
|
|
763
1488
|
The home list shows the same "now" text with a spinner for working sessions.
|
|
764
1489
|
- **Progress cues** (conversation mode; Settings → Voice → "Progress cues", on by default): a soft
|
|
765
|
-
tone
|
|
766
|
-
agent
|
|
767
|
-
|
|
768
|
-
|
|
1490
|
+
tone as soon as you stop speaking (the moment the segment goes to transcription; typed text gets it when
|
|
1491
|
+
the agent receives it), then a short acknowledgement ("On it.", "One moment.", "Let me check." …) if the
|
|
1492
|
+
session is working and the agent has not spoken within 2.5 s of the end of your speech. A hand-off phrase
|
|
1493
|
+
("I'll hand that off.", "Getting a helper on it." …) plays when a subagent starts, unless the agent
|
|
1494
|
+
spoke in the last 3 s. A still-working phrase ("Still on it.", "Almost there." …) plays after 40 s without
|
|
1495
|
+
speech while work continues, and a double tone plays when a subagent finishes. Failed tools are not spoken (they show in the feed). Cues never
|
|
769
1496
|
play while you are talking or the agent's speech is playing; they wait, and are dropped once the agent
|
|
770
|
-
speaks.
|
|
1497
|
+
speaks. Cues are event-driven: one timer for the next deadline (no polling), re-armed on speech, status and
|
|
1498
|
+
playback changes. Each cue has several variants (`CUE_VARIANTS` in `web/src/core/feedback-core.ts`), chosen
|
|
1499
|
+
from a shuffle-bag: every variant once before any repeats, never the same phrase twice in a row, and a variant
|
|
1500
|
+
whose audio is already loaded wins. All variants are synthesized, 2 at a time, when Voice mode starts (ending Voice mode
|
|
1501
|
+
cancels what is left) and kept in memory and in the browser's Cache Storage (`hub-cues-v2`, keyed by TTS provider, model, voice, speed and server, so a later
|
|
1502
|
+
session starts with them ready and a voice change fetches them again); a ready phrase starts playing at
|
|
1503
|
+
once. A phrase that is not ready yet when its cue fires plays a short tone instead.
|
|
1504
|
+
- **Cue loudness**: short TTS phrases come out several dB louder or quieter than the spoken replies, so each
|
|
1505
|
+
phrase is normalised while it is prefetched (never when it plays): decoded (`OfflineAudioContext`), measured
|
|
1506
|
+
(ITU-R BS.1770 K-weighted, gated loudness), trimmed to at most 80 ms of leading/trailing silence, gained to the
|
|
1507
|
+
replies' loudness with a look-ahead limiter keeping peaks at or below −1 dBFS, and stored as a 16-bit mono WAV
|
|
1508
|
+
(with the target it was normalised to; a target that moved by more than 1 dB re-normalises the cached audio
|
|
1509
|
+
without new synthesis). The target is −19.5 LUFS until replies are measured: each completed reply sentence of
|
|
1510
|
+
at least 1 s is decoded when the page is idle, and the median of the last 21 per TTS voice
|
|
1511
|
+
(`localStorage` `voice.replyLoudness`, clamped to −26…−14 LUFS) becomes the target. Audio that cannot be decoded
|
|
1512
|
+
plays as synthesized. Cues and replies keep their plain `<audio>` elements (no Web Audio playback graph, which
|
|
1513
|
+
would break background and iOS playback), both at volume 1 and the chosen speed. Pure parts:
|
|
1514
|
+
`web/src/core/loudness.ts`; browser decoding: `web/src/voice/cue-loudness.ts`.
|
|
771
1515
|
- **Presence**: while a session view is open, the phone posts `POST /api/sessions/:id/presence
|
|
772
1516
|
{visible}` on visibility changes and every 30 s while visible, and `{visible:false}` on `pagehide` and
|
|
773
1517
|
when leaving the view. The hub uses this to decide when to push.
|
|
@@ -775,11 +1519,19 @@ Pure logic is in `web/src/core/feedback-core.ts` (tested with bun); `web/src/cor
|
|
|
775
1519
|
Screen Wake Lock keeps the screen on while listening. On: no wake lock. The mic and VAD keep running
|
|
776
1520
|
when the page is hidden. A silent looping audio element plays to make suspension less likely. **This is best-effort:** whether
|
|
777
1521
|
listening survives a locked screen depends on Android Chrome (battery saver, OS version), and iOS
|
|
778
|
-
suspends it.
|
|
779
|
-
|
|
1522
|
+
suspends it. Android Chrome usually stops the microphone when the screen turns off (the track is muted
|
|
1523
|
+
or ended, the AudioContext suspended, no audio frames). Voice mode watches for that: no frames for 1.5 s,
|
|
1524
|
+
a muted/ended track or a suspended AudioContext → the Voice bar says "Paused — mic stopped by the phone
|
|
1525
|
+
(screen off). Unlock to resume.", the notification becomes "Voice mode paused · unlock to talk", the lock
|
|
1526
|
+
screen controls show paused and a short falling tone plays. While hidden it keeps nudging the audio
|
|
1527
|
+
(resume every 3 s, the silent loop); it says "listening" again only once audio frames arrive. On unlock
|
|
1528
|
+
(visible, `pageshow`, focus, track unmute) it resumes the AudioContext, asks for the microphone again if
|
|
1529
|
+
the track ended (or shows "Tap to start Voice mode" when that needs a tap), retries with backoff while
|
|
1530
|
+
visible, and plays a rising tone. The OpenCode session keeps running on the laptop either way. When the
|
|
1531
|
+
phone is not listening, turn on the `say` notification to get replies as push notifications.
|
|
780
1532
|
- **"Voice mode on" notification**: while Voice mode is on, the page shows a silent, persistent system
|
|
781
1533
|
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
|
|
1534
|
+
speaking, muted; updated at most every 3 s, at once when the phone stops or gives back the microphone) with **End** and **Open**. End reaches the page (which ends
|
|
783
1535
|
Voice mode); with no page open, the service worker turns conversation mode off at the hub itself with the
|
|
784
1536
|
device cookie. It closes when Voice mode ends or the page goes away. It needs notification permission:
|
|
785
1537
|
without it the Voice bar shows **Allow notifications**. The Media Session shows "Voice mode on" with the
|
|
@@ -829,6 +1581,10 @@ in the file).
|
|
|
829
1581
|
| `speech.stt` | `{provider, model, baseURL?, language?, apiKey?}` |
|
|
830
1582
|
| `speech.tts` | `{provider, model, voice, baseURL?, speed?, apiKey?}` |
|
|
831
1583
|
| `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
1584
|
+
| `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
|
|
1585
|
+
| `agents.never` | permission patterns (`action[:glob]`) no agent may ever approve, for every user, checked before any grant; see [Agent actions and grants](#agent-actions-and-grants) |
|
|
1586
|
+
| `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
|
|
1587
|
+
| `agents.webhooks.allowPrivate` | `true` lets webhooks target `http://`, loopback and private hosts (self-hosted receivers); see [docs/launches.md](docs/launches.md#where-webhooks-may-go) |
|
|
832
1588
|
|
|
833
1589
|
Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
|
|
834
1590
|
defaults. Speech: phone settings > hub.json > env vars. Values are checked like the phone's speech
|
|
@@ -975,6 +1731,7 @@ npm install # bun install works too
|
|
|
975
1731
|
npm test # bun test (scripts/test-ci.sh runs each file alone and builds the web app first)
|
|
976
1732
|
npm run typecheck # hub and web app
|
|
977
1733
|
npm run build:web # web/dist, served by `bun bin/hub.ts`
|
|
1734
|
+
npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack builds both)
|
|
978
1735
|
```
|
|
979
1736
|
|
|
980
1737
|
Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
|
|
@@ -982,6 +1739,44 @@ Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (
|
|
|
982
1739
|
`npm run test:e2e` runs a headless Chromium smoke test against the mock hub at desktop and phone sizes
|
|
983
1740
|
(`CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to the temp dir).
|
|
984
1741
|
|
|
1742
|
+
`test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
|
|
1743
|
+
`hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
|
|
1744
|
+
`messages:*` scopes, `hub mcp` over pipes, a Decision answered on the console route, a second user who cannot see or
|
|
1745
|
+
answer any of it, the pair rate limit and the messaging switch. The opt-in `HUB_IT_OPENCODE=1 bun test
|
|
1746
|
+
test/e2e-phase2-live.it.test.ts` connects a private `opencode serve` to a real in-process hub: one session's (stub)
|
|
1747
|
+
model calls `hub msg` to another session, whose model is then handed the envelope.
|
|
1748
|
+
|
|
1749
|
+
`test/e2e-phase3.test.ts` does the same for agent actions: a spoke with `deliver`, `actTokens`, `hubPrompt` and
|
|
1750
|
+
`permissionMessage` and a raw spoke without them; a grant to `@@orchestrator` used through the `hub` tool's code
|
|
1751
|
+
path (act token) and an app token's grant through the CLI; shell tokens refused, never patterns (the grant's and
|
|
1752
|
+
hub.json's), the own-tree ban, `needs_user` escalated to a Decision answered on the console, the user answering
|
|
1753
|
+
first, both kill switches, the hourly cap, prompts with provenance, deny reasons, grant requests and the audit
|
|
1754
|
+
(admins see summaries only). `test/central-tenancy.test.ts` checks every phase-3 route and CLI verb across two
|
|
1755
|
+
users. The opt-in `HUB_IT_OPENCODE=1 bun test test/e2e-phase3-live.it.test.ts` connects a private `opencode serve`
|
|
1756
|
+
with shell permissions set to ask: session A's (stub) model runs a shell command, session B's model calls `hub
|
|
1757
|
+
approve` under its grant, and OpenCode runs A's command. `HUB_IT_OPENCODE=1 bun test test/spoke-acting.it.test.ts`
|
|
1758
|
+
checks hub prompts and deny reasons inside OpenCode.
|
|
1759
|
+
|
|
1760
|
+
`test/e2e-phase1.test.ts` (part of `npm test`) runs agent identity end to end in process: a hub, the OpenCode spoke
|
|
1761
|
+
and a second spoke of another harness, and the `hub` CLI as a session token, an app token, a linked device and a
|
|
1762
|
+
second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.test.ts` starts a private
|
|
1763
|
+
`opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
|
|
1764
|
+
`HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
|
|
1765
|
+
test/spoke-deliver.it.test.ts` does the same for hub messages: the model sees the delivered envelope, a repeated
|
|
1766
|
+
message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin. `HUB_IT_OPENCODE=1 bun test test/spoke-hub-tool.it.test.ts` has the stub model call the `hub` tool and checks
|
|
1767
|
+
that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
|
|
1768
|
+
test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
|
|
1769
|
+
reference note attached, and that hub-sourced prompts get none.
|
|
1770
|
+
|
|
1771
|
+
`test/e2e-phase7.test.ts` runs the other harnesses end to end in process (the Claude Code daemon fed fixture hook
|
|
1772
|
+
inputs, the Codex spoke on a fake shared app-server daemon, a preset on a private tmux server, and claude-code and
|
|
1773
|
+
codex launches in node worktrees), and `test/e2e-phase7-conformance.test.ts` runs the spoke conformance suite against every shipped spoke
|
|
1774
|
+
(OpenCode included). The opt-in `HUB_IT_CLAUDE=1 bun test ./packages/claude-code/test/live.it.test.ts` and
|
|
1775
|
+
`HUB_IT_CODEX=1 bun test ./packages/codex/test/live.it.test.ts` run the real CLIs against the testkit's stub model
|
|
1776
|
+
with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy. A new harness spoke starts from
|
|
1777
|
+
`bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
|
|
1778
|
+
<dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
1779
|
+
|
|
985
1780
|
Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
|
|
986
1781
|
`{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
|
|
987
1782
|
OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
|
|
@@ -1016,7 +1811,9 @@ publish before trusted publishing can take over (see the comment in `release.yml
|
|
|
1016
1811
|
|
|
1017
1812
|
- Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
|
|
1018
1813
|
streaming STT provider yet.
|
|
1019
|
-
- Voice detection is a
|
|
1814
|
+
- Voice detection tells speech from noise, not whose speech it is: a TV or people talking nearby still count
|
|
1815
|
+
(Mic sensitivity Low helps; mute or headphones are the sure way).
|
|
1816
|
+
- The compiled binaries and the Docker image grow by about 14 MB (the speech model and onnxruntime's wasm).
|
|
1020
1817
|
- Questions (forms) of a headless standalone server (`opencode serve --stdio` without its TUI) cannot be
|
|
1021
1818
|
answered from the hub; the background service, a foreground `opencode serve` and TUI-launched
|
|
1022
1819
|
standalone servers can.
|
|
@@ -1024,6 +1821,13 @@ publish before trusted publishing can take over (see the comment in `release.yml
|
|
|
1024
1821
|
- Listening with the phone screen off depends on the browser (best-effort on Android Chrome; not
|
|
1025
1822
|
possible on iOS). Replies and alerts still arrive as push notifications.
|
|
1026
1823
|
- The voice agent registers as a visible primary agent (`voice`) in OpenCode's agent list.
|
|
1824
|
+
- A catalog change still sends every machine's full catalog to each stream (about 1 KB per few sessions); the
|
|
1825
|
+
replay buffer is in memory (a hub restart means a full catalog + `resync`).
|
|
1826
|
+
- Running shells are seeded from the spoke's own server (`shell.list` for the 20 most recent session
|
|
1827
|
+
directories); shells in other directories show up once they emit an event.
|
|
1828
|
+
- Claude Code, Codex and presets serve fewer operations than OpenCode (no timeline or summary; Claude Code takes
|
|
1829
|
+
hub turns only in sessions the hub launched, and its permission requests race its own dialog); see
|
|
1830
|
+
[docs/harnesses.md](docs/harnesses.md#known-limits).
|
|
1027
1831
|
|
|
1028
1832
|
## License
|
|
1029
1833
|
|