@huynle/hub 0.7.3 → 0.8.0
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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
|
46
46
|
Bring your own speech: OpenRouter or any OpenAI-compatible STT/TTS service (including self-hosted).
|
|
47
47
|
- **What's open in your terminals first**: sessions open in Herdr or the OpenCode TUI, grouped like
|
|
48
48
|
Herdr's sidebar, with the rest under Recent; rename, pin and archive in sync across devices.
|
|
49
|
+
- **Fork and revert a message** (OpenCode): hover one of your messages for Copy, Fork (a new session with the
|
|
50
|
+
conversation before it, the message in its composer) and Revert (hide it and what follows, restore the files the
|
|
51
|
+
session changed, edit and resend; Restore undoes it) ([docs/session-view.md](docs/session-view.md#fork-and-revert-a-message)).
|
|
49
52
|
- **Agent identity**: every session's shells carry its own hub identity, so an agent can run `hub status`, name
|
|
50
53
|
itself (`@scout`), claim a role (`@@reviewer`) and read other sessions, with app tokens for scripts and device
|
|
51
54
|
tokens for apps ([Agent identity](#agent-identity-agent-api)).
|
|
@@ -60,6 +63,23 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
|
|
|
60
63
|
each harness's spoke serves, with an evidence badge and honest delivery labels
|
|
61
64
|
([docs/harnesses.md](docs/harnesses.md)). Write a spoke for another harness on the spoke SDK and check it with the
|
|
62
65
|
conformance suite ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
66
|
+
- **Workspaces and supervision**: group sessions from any machine into a durable workspace, drag them under a
|
|
67
|
+
master or supervisor (separate from native subagent ancestry), track explicit assignments on a situation board,
|
|
68
|
+
replace a master explicitly, and keep supervisors informed with batched digests and durable checkpoints
|
|
69
|
+
([docs/workspaces.md](docs/workspaces.md)).
|
|
70
|
+
- **Split panes and side chats**: up to 4 sessions or artifacts side by side, and side chats started from a bounded
|
|
71
|
+
copy of a session that never interrupt it ([docs/workspaces.md](docs/workspaces.md#side-chats)).
|
|
72
|
+
- **Agent profiles**: shared, versioned instructions, skills and commands, pinned to a session, launch or team role at
|
|
73
|
+
runtime, with an honest per-harness acknowledgment ([docs/profiles.md](docs/profiles.md)).
|
|
74
|
+
- **Artifacts with AI**: persistent, revisioned documents, tables, charts, dashboards and files in an **Artifacts**
|
|
75
|
+
library. AI jobs ask about or edit them through an agent you choose, and interactive apps run only in an isolated
|
|
76
|
+
sandbox host ([docs/artifacts.md](docs/artifacts.md), [docs/artifact-host.md](docs/artifact-host.md)).
|
|
77
|
+
- **Teams**: roles, an entry role that takes work, and enforced one-way or two-way communication paths (or open
|
|
78
|
+
mode), staffed with sessions from any machine. The console's **Teams** page lists them and shows each team's
|
|
79
|
+
members, work (live intake status) and communication ([docs/teams.md](docs/teams.md),
|
|
80
|
+
[intake](docs/teams-intake.md)).
|
|
81
|
+
- **Watchtower** (formerly Bird's-eye view): a 3D, 2D or list picture of machines, sessions, teams and live
|
|
82
|
+
message flow, with a team editor ([docs/watchtower.md](docs/watchtower.md)).
|
|
63
83
|
- **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
|
|
64
84
|
marks synced per user.
|
|
65
85
|
- **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
|
|
@@ -83,6 +103,29 @@ opencode plugin add @huynle/hub
|
|
|
83
103
|
opencode service restart
|
|
84
104
|
```
|
|
85
105
|
|
|
106
|
+
OpenCode 2.x loads plugins per project, on the first request that opens one, and has no hook at server start: a
|
|
107
|
+
freshly (re)started background service or `opencode serve` has no hub connection, so its machine shows **offline**
|
|
108
|
+
until a client (the TUI, the web app) opens a project. OpenCode also unloads a project after an hour without session
|
|
109
|
+
activity, and the hub plugin with it. `hub node` ([launches](docs/launches.md#hub-node)) keeps the background service
|
|
110
|
+
warm by itself (cheap location reads every 30 s), and any foreground `opencode serve` you list in node.json:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{ "workspaceRoots": ["/Users/you/projects"],
|
|
114
|
+
"opencode": { "servers": [{ "url": "http://127.0.0.1:4096", "passwordFile": "/Users/you/.config/hub/opencode-4096.pw" }] } }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
(the server's `OPENCODE_PASSWORD` in a `chmod 600` file; loopback URLs only). To stop OpenCode unloading projects at
|
|
118
|
+
all, set `"experimental": { "location_idle_timeout": false }` in your OpenCode config (or a longer duration such as
|
|
119
|
+
`"8 hours"`). Without `hub node`, open OpenCode once, or send the read yourself, e.g. for a foreground
|
|
120
|
+
`opencode serve --port 4096` with `OPENCODE_PASSWORD` set:
|
|
121
|
+
`curl -fsS -u "opencode:$OPENCODE_PASSWORD" http://127.0.0.1:4096/api/agent >/dev/null`.
|
|
122
|
+
|
|
123
|
+
Which sessions show **online**: the background service reports all of its recent sessions; a foreground
|
|
124
|
+
`opencode serve` with `OPENCODE_PASSWORD` (or `OPENCODE_SERVER_PASSWORD`) in its environment reports every recent
|
|
125
|
+
session of the projects it has loaded, idle ones included (so hub messages reach them at once); a TUI's own server,
|
|
126
|
+
or a foreground server started without a password in its environment (OpenCode then makes one up the plugin cannot
|
|
127
|
+
read), reports only the sessions that were active in that process.
|
|
128
|
+
|
|
86
129
|
Install from npm: the npm package ships the built web app, so the plugin can run a local hub. A GitHub
|
|
87
130
|
install (`opencode plugin add github:huynle/hub`) has no built web app (`web/dist` is not in the
|
|
88
131
|
repository): it works for connecting to a hub that runs elsewhere, but it does not start a local hub
|
|
@@ -145,12 +188,17 @@ shell, and the hub never gets a way to run commands here.
|
|
|
145
188
|
# 1. Download hubctl for your platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64) and the signed manifest
|
|
146
189
|
# from the release, then install the binary yourself (the first apply installs the release's copy as
|
|
147
190
|
# ~/.local/bin/hubctl, with `hub` linked to it):
|
|
148
|
-
curl -fsSLo hubctl https://github.com/huynle/hub/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
|
|
149
|
-
curl -fsSLO https://github.com/huynle/hub/releases/download/v<version>/hubctl-manifest.json
|
|
191
|
+
curl -fsSLo hubctl https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
|
|
192
|
+
curl -fsSLO https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-manifest.json
|
|
150
193
|
|
|
151
194
|
# 2. Enroll this machine (device code: approve it on the hub; Ctrl-C cancels). One identity for every spoke here.
|
|
152
195
|
./hubctl machine enroll --server https://hub.example.com
|
|
153
196
|
./hubctl machine status # verified | revoked (exit 9) | offline (exit 7) | insecure (plain-http hub)
|
|
197
|
+
# A plain-http hub on your LAN (a preview at http://10.x / 192.168.x): refused unless you opt in, every time, with a
|
|
198
|
+
# warning; only for private-network IP addresses, never a public address or a host name (`hub login` takes it too):
|
|
199
|
+
# ./hubctl machine enroll --server http://192.168.1.20:4097 --allow-insecure-http
|
|
200
|
+
# (the spoke and hub node then use the machine token over plain http; `hub login --allow-insecure-http` likewise
|
|
201
|
+
# stores an app token that every later `hub` command sends unencrypted, with a warning each time)
|
|
154
202
|
|
|
155
203
|
# 3. Preview: discover the host and print the exact plan and any manual edits. It writes nothing.
|
|
156
204
|
./hubctl setup preview --profile session-only --manifest hubctl-manifest.json --out preview.json
|
|
@@ -209,7 +257,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
|
|
|
209
257
|
| Way | For | How |
|
|
210
258
|
|---|---|---|
|
|
211
259
|
| **`/hub` → On this computer** | Local use; nothing to install (plugin from npm) | started by the plugin with OpenCode's own runtime (`BUN_BE_BUN=1 opencode bin/hub.ts`), or `bun` when installed |
|
|
212
|
-
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
|
|
260
|
+
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub-releases/releases) (the source repository is private; releases are mirrored to the public `huynle/hub-releases`) |
|
|
213
261
|
| **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
|
|
214
262
|
| **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
|
|
215
263
|
| **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
|
|
@@ -221,8 +269,8 @@ answers `--help` and `--version`. While no device is paired, the hub logs a pair
|
|
|
221
269
|
`hub-<darwin|linux>-<arm64|x64>` and `hub-windows-x64.exe`, plus `SHA256SUMS`.
|
|
222
270
|
|
|
223
271
|
```sh
|
|
224
|
-
curl -LO https://github.com/huynle/hub/releases/latest/download/hub-linux-x64
|
|
225
|
-
curl -LO https://github.com/huynle/hub/releases/latest/download/SHA256SUMS
|
|
272
|
+
curl -LO https://github.com/huynle/hub-releases/releases/latest/download/hub-linux-x64
|
|
273
|
+
curl -LO https://github.com/huynle/hub-releases/releases/latest/download/SHA256SUMS
|
|
226
274
|
sha256sum --check --ignore-missing SHA256SUMS && chmod +x hub-linux-x64
|
|
227
275
|
HUB_PUBLIC_URL=https://hub.example.com ./hub-linux-x64
|
|
228
276
|
```
|
|
@@ -258,7 +306,7 @@ volumes:
|
|
|
258
306
|
The image sets `HUB_HOST=0.0.0.0`, `HUB_PORT=4097` and `HUB_STATE_DIR=/data`. Build it locally with
|
|
259
307
|
`docker build -t hub .`.
|
|
260
308
|
|
|
261
|
-
**bunx / from source.** `bunx --package @huynle/hub hub-server` (needs Bun ≥ 1.
|
|
309
|
+
**bunx / from source.** `bunx --package @huynle/hub hub-server` (needs Bun ≥ 1.4.4), or in
|
|
262
310
|
a checkout: `npm install --no-audit --no-fund && bun bin/hub.ts`. `scripts/hub-local.sh` runs it in the
|
|
263
311
|
foreground with `hub.json`; `scripts/io.github.huynle.hub.plist` is a launchd template. The
|
|
264
312
|
`opencode-hub` and `opencode-voice-hub` commands still work as aliases of `hub-server`.
|
|
@@ -270,6 +318,11 @@ front of the web app, and leave `/spoke/*`, `/auth/*`, `/healthz`, `/.well-known
|
|
|
270
318
|
icons reachable for spokes, sign-in, app discovery and the PWA (and `/mcp`, bearer-authenticated, if MCP clients
|
|
271
319
|
or gateways use [MCP over HTTP](#over-http-mcp)).
|
|
272
320
|
|
|
321
|
+
**Behind a reverse proxy**, set a request-body limit of about 26 MiB (artifact blobs are up to 25 MiB) and keep
|
|
322
|
+
request streaming on: only the proxy closes a socket a client keeps sending on after the hub answered
|
|
323
|
+
([docs/operations.md](docs/operations.md#request-bodies-and-the-front-proxy)). Back up the whole `HUB_STATE_DIR`,
|
|
324
|
+
including `blobs/` (artifact bytes).
|
|
325
|
+
|
|
273
326
|
## Configuration
|
|
274
327
|
|
|
275
328
|
- **Hub**: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`, `OPENROUTER_API_KEY`, …)
|
|
@@ -301,11 +354,12 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
|
|
|
301
354
|
| `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) |
|
|
302
355
|
| `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 |
|
|
303
356
|
| `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) |
|
|
357
|
+
| `POST /api/sessions/:id/fork {messageID}` · `GET …/revert/preview?messageID=` · `POST …/revert {messageID, files?}` · `POST …/revert/restore` · `POST …/revert/commit` | fork before one of the session's user messages (201 `{sessionID, text, attachments}`), or stage, undo and commit a revert from it (409 `busy` while it runs); console only, audited; needs a spoke announcing `fork`/`revert` (409 `unsupported` otherwise). Contract: [docs/session-view.md](docs/session-view.md#fork-and-revert-a-message) |
|
|
304
358
|
| `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) |
|
|
305
359
|
| `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 |
|
|
306
360
|
| `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 |
|
|
307
361
|
| `POST /api/sessions/:id/read` · `GET /api/reads` | per-user read marks, synced to your other devices |
|
|
308
|
-
| `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 |
|
|
362
|
+
| `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). Split panes add `sessions=<id>,<id>` (at most 4 with `session`, else 400): each listed tree streams, each event once, and the stream starts with `{type: "scope", sessions}` naming the ones accepted (your own only); without `sessions` nothing changes. Heartbeat: `event: ping` every 20 s |
|
|
309
363
|
| `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` |
|
|
310
364
|
| `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) |
|
|
311
365
|
| `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 |
|
|
@@ -321,12 +375,31 @@ MCP exposes the same operations as `archive` and `unarchive`. Naming (`POST /api
|
|
|
321
375
|
token with `sessions:manage`; role claims and releases take one with `sessions:manage` **and** a grant allowing
|
|
322
376
|
`roles` whose projects cover the session's directory (the session must be online), under the kill switch and hourly
|
|
323
377
|
cap, with every outcome in the agent audit (verbs `roles` and `roles.release`). Reserved roles stay yours to assign.
|
|
324
|
-
Apps and users name the session (`session`); foreign and unknown sessions are both `404 not_found`.
|
|
378
|
+
Apps and users name the session (`session`); foreign and unknown sessions are both `404 not_found`. A session token
|
|
379
|
+
writes for its own session only (another one is `403 forbidden`), so an agent names itself and claims or releases its
|
|
380
|
+
own roles with `hub name` / `hub role claim|release` from its shell, the OpenCode `hub` tool (verbs `name`,
|
|
381
|
+
`role_claim`, `role_release`; no `session` argument) or, in Claude Code, the plugin's MCP tools. MCP: `name`,
|
|
325
382
|
`role_claim` and `role_release`. Each change is one `session.named`, `role.claimed` or `role.released` event.
|
|
326
383
|
|
|
384
|
+
**Intent.** Each session has an optional intent: one line (at most 280 characters) saying what it is for. It is
|
|
385
|
+
untrusted text, sanitised and never interpreted. `GET /api/agent/sessions/:addr/intent` answers `{intent}`: the
|
|
386
|
+
stored one, else the session's non-placeholder title (`source: "title"`), else `null` (users, `sessions:read`,
|
|
387
|
+
session tokens). `PUT /api/agent/sessions/:addr/intent {text | null, expectedRevision}` sets or clears it: users
|
|
388
|
+
and apps with `sessions:manage` write `source: "user"`, a session token its own session only (`source: "self"`;
|
|
389
|
+
another is `403 forbidden`). A launch's `intent` is stored as `source: "launch"`, and the first prompt the hub
|
|
390
|
+
carries (console, launch or acting) becomes `source: "prompt"` once. Precedence is user > launch > self > prompt >
|
|
391
|
+
title; `null` falls back to the next one. A stale `expectedRevision` is `409 conflict {currentRevision}`. Stored
|
|
392
|
+
intents also appear as `intent {text, source, revision}` in `GET /sessions`, `GET /sessions/:addr` and `who`.
|
|
393
|
+
Changes are `session.intent {source, revision}` events, never the text. CLI `hub intent <addr> [text… | --clear]`,
|
|
394
|
+
MCP `session_intent_get` / `session_intent_set`, SDK `intent(addr)` / `setIntent(addr, text, expectedRevision)`;
|
|
395
|
+
the console shows it in the session's Details rail, with Edit (live: the console stream carries
|
|
396
|
+
`{type: "intent", sessionID, source, revision}`, never the text). Reads fetch only the listed sessions' intents.
|
|
397
|
+
Retention: every 6 h one hub drops the intents of deleted sessions and of sessions neither seen nor written for 30
|
|
398
|
+
days; an online session's intent is always kept. A dropped intent's revision starts over at 0.
|
|
399
|
+
|
|
327
400
|
**Events.** `GET /api/agent/events?cursor=&wait=&limit=&type=&session=` reads a bounded per-user journal (24 h or
|
|
328
401
|
10,000 events) of compact events: `session.added/removed/changed`, `blocker.opened/closed`, `machine.changed`,
|
|
329
|
-
`message.changed`, `launch.changed`, `session.archived`, `session.named`, `role.claimed`, `role.released`. Each is `{id, at, type, subject, data}` with IDs and states
|
|
402
|
+
`message.changed`, `launch.changed`, `session.archived`, `session.named`, `session.intent`, `role.claimed`, `role.released`. Each is `{id, at, type, subject, data}` with IDs and states
|
|
330
403
|
only: never prompts, message bodies, titles, blocker summaries, launch reasons or labels. Without a cursor the answer
|
|
331
404
|
is empty with a cursor at the latest event; pass the returned `cursor` back to get what came after. `wait` (0–60 s)
|
|
332
405
|
holds the request while nothing matches; `limit` is 1–200 (default 50); `type` (exact, or `launch.*`) and `session`
|
|
@@ -389,7 +462,7 @@ cookie and a hub bearer token (400).
|
|
|
389
462
|
|---|---|---|---|---|
|
|
390
463
|
| `GET /api/agent/whoami` | ✓ | ✓ | ✓ (any scope) | `{actor, user}` |
|
|
391
464
|
| `GET /api/agent/status` | ✓ | ✓ | ✓ | machines → projects → sessions, each with name, roles, state, harness, activity and blockers, plus `attention` |
|
|
392
|
-
| `GET /api/agent/sessions?project=&state=&machine=&label=k=v
|
|
465
|
+
| `GET /api/agent/sessions?project=&state=&machine=&label=k=v&q=` | ✓ | ✓ | ✓ | the same sessions as a flat list; `q` (≤ 200 characters): every word must appear in the title or @name (case, `@` and punctuation ignored) |
|
|
393
466
|
| `GET /api/agent/sessions/:addr` | ✓ | ✓ | ✓ | one session with full blockers and the spoke's summary (`unsupported` when its harness lacks it) |
|
|
394
467
|
| `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` |
|
|
395
468
|
| `GET /api/agent/watch?blocked=1&session=<addr>&cursor=` | ✓ | ✓ | ✓ (+ each family's scope) | SSE of the same compact events as `/api/agent/events` (see **Events**), after `ready`; each frame's `id:` is a cursor to resume with (`?cursor=` or `Last-Event-ID`); `reset` when it is no longer covered |
|
|
@@ -458,9 +531,12 @@ most 10,000 characters (a decline's reason 1,000).
|
|
|
458
531
|
|
|
459
532
|
**Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
|
|
460
533
|
OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
|
|
461
|
-
turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it
|
|
462
|
-
|
|
463
|
-
|
|
534
|
+
turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it. A `--no-wake` message never
|
|
535
|
+
gets a model call of its own: an idle session reads it with its next turn; a busy one gets it once the running turn
|
|
536
|
+
ends (until then the hub keeps it `pending`), so it too arrives with the next turn. Urgent `--no-wake` messages,
|
|
537
|
+
replies and asks to a busy session that is not woken (a subagent, past the hop cap or wake budget) go into the running
|
|
538
|
+
turn at once, which may cost that turn one more model call. Subagent sessions are never woken. A session that is
|
|
539
|
+
offline gets it when it comes back (pending, for 24 hours). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
|
|
464
540
|
`unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
|
|
465
541
|
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`).
|
|
466
542
|
|
|
@@ -491,7 +567,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
|
|
|
491
567
|
**Where agents send from.**
|
|
492
568
|
- In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
|
|
493
569
|
- The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
|
|
494
|
-
`whoami`,
|
|
570
|
+
`whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, `name`, `role_claim` and
|
|
571
|
+
`role_release` for its own @name and roles, and the [act verbs](#agent-actions-and-grants)), as the calling session ([Spoke](#spoke-opencode-plugin)).
|
|
495
572
|
- You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
|
|
496
573
|
- Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
|
|
497
574
|
[`POST /mcp`](#over-http-mcp) as the bearer token sent.
|
|
@@ -696,6 +773,7 @@ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pin
|
|
|
696
773
|
hub whoami who this credential is
|
|
697
774
|
hub who [--project P] [--role R | @@role] named and role-holding sessions
|
|
698
775
|
hub name <@name> [--session <addr>]
|
|
776
|
+
hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
|
|
699
777
|
hub role claim <role[.project]> [--exclusive] [--session <addr>]
|
|
700
778
|
hub role release <role[.project]> [--session <addr>]
|
|
701
779
|
hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
|
|
@@ -743,7 +821,8 @@ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on st
|
|
|
743
821
|
message, any candidates or retry time, a missing feature, and the hint on stderr.
|
|
744
822
|
|
|
745
823
|
**Messaging.** `msg` and `ask` print what was stored and how it reached the recipient (`delivered` into a new or
|
|
746
|
-
the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline
|
|
824
|
+
the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline (or,
|
|
825
|
+
for a `--no-wake` message, while the recipient's turn runs), or
|
|
747
826
|
`stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
|
|
748
827
|
`--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
|
|
749
828
|
read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
|
|
@@ -796,7 +875,16 @@ timestamps older than 5 minutes refused). The spec's fields and limits, states,
|
|
|
796
875
|
**hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
|
|
797
876
|
machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
|
|
798
877
|
least `{"workspaceRoots": ["/Users/you/projects"]}` (optional `labels`, `slots`, `memoryMB`); it exits 1 when the
|
|
799
|
-
machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM.
|
|
878
|
+
machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. While it offers the
|
|
879
|
+
opencode harness (the default; node.json `"harnesses": {"opencode": false}` withholds it) it keeps OpenCode's
|
|
880
|
+
background service warm: every 30 s it finds the service through its registration file
|
|
881
|
+
(`$XDG_STATE_HOME/opencode/service.json`, default `~/.local/state/opencode/service.json`, read directly: no request
|
|
882
|
+
before the check) and, only for a literal loopback URL without user info (127.0.0.1, [::1]; localhost is asked as
|
|
883
|
+
127.0.0.1), reads `GET /api/agent` (the service's default location; redirects are never followed) so the hub plugin loads and the machine comes online without
|
|
884
|
+
anyone opening a project (logged when it starts working or fails). It also loads the projects of the service's
|
|
885
|
+
recent sessions under `workspaceRoots` (at most 8, or node.json `opencode.directories`), and warms each foreground
|
|
886
|
+
server listed in node.json `opencode.servers` (`{url, passwordFile, directories?}`: a loopback URL and a `chmod 600`
|
|
887
|
+
file holding its password; see [docs/launches.md](docs/launches.md#hub-node)) the same way. `hub node install` writes a
|
|
800
888
|
launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
|
|
801
889
|
`~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
|
|
802
890
|
the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
|
|
@@ -1050,8 +1138,11 @@ not persist those potentially sensitive details.
|
|
|
1050
1138
|
keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
|
|
1051
1139
|
interrupt the root turn at once.
|
|
1052
1140
|
- **Messaging other sessions**: the plugin adds a tool `hub {verb, …}` with the verbs `msg`, `ask`, `reply`,
|
|
1053
|
-
`decline`, `inbox`, `who`, `status` and `
|
|
1054
|
-
with that session's own hub token (the token never appears in the tool's output). `
|
|
1141
|
+
`decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
|
|
1142
|
+
with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
|
|
1143
|
+
session's own @name (the old one stays an alias for 24 h; `name_taken`, invalid and reserved names fail as in
|
|
1144
|
+
`hub name`), `role_claim {role, project?, exclusive?}` and `role_release {role, project?}` its own roles; these
|
|
1145
|
+
take no `session`: a session changes only itself. `ask` with `wait` (seconds,
|
|
1055
1146
|
at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
|
|
1056
1147
|
message. Messages from other sessions are shown in the hub's envelope and are not from the user.
|
|
1057
1148
|
- Child text and execution events are paired by the hub. The latest assistant message of a successful
|
|
@@ -1093,6 +1184,7 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
|
|
|
1093
1184
|
| `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 |
|
|
1094
1185
|
| `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) |
|
|
1095
1186
|
| `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
|
|
1187
|
+
| `HUB_DATABASE_STATEMENT_TIMEOUT_MS` | `30000` | cluster mode: Postgres `statement_timeout` of the hub's pools (`0` turns it off) |
|
|
1096
1188
|
| `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
|
|
1097
1189
|
| `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
|
|
1098
1190
|
| `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
|
|
@@ -1119,6 +1211,26 @@ the Docker image, npm package and binaries ship the build). One store fed by the
|
|
|
1119
1211
|
terminal grouped by project (or server) with `+`, Recents; rows show status, a pulsing green dot left of the
|
|
1120
1212
|
title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
|
|
1121
1213
|
(rename, pin, archive, stop, Allow/Disallow Voice).
|
|
1214
|
+
- **Primary navigation** above the session groups: **All sessions**, **Artifacts** (the library across workspaces,
|
|
1215
|
+
[docs/artifacts.md](docs/artifacts.md)), **Watchtower** ([docs/watchtower.md](docs/watchtower.md)) and **Agent
|
|
1216
|
+
profiles** ([docs/profiles.md](docs/profiles.md)). The sidebar's **Workspaces** tree (drag to supervise) and the
|
|
1217
|
+
session ⋯ menu's **Start side chat…** are described in [docs/workspaces.md](docs/workspaces.md).
|
|
1218
|
+
- **Split panes**: **Open beside** in a session's `⋯` menu (table, sidebar, phone list) opens it next to the
|
|
1219
|
+
focused pane, up to 4 panes, side by side, stacked or in a grid (the icon switch in the bar above the panes).
|
|
1220
|
+
On desktop you can also **drag** a session row (sidebar or Workspaces tree), an artifact (Artifacts library) or
|
|
1221
|
+
the Watchtower detail's **Drag into a pane** handle onto a pane: an edge splits there (left/right side by side,
|
|
1222
|
+
top/bottom stacked), the centre replaces that pane, and the shape reflows (a pane dropped across a side-by-side
|
|
1223
|
+
pair makes a grid). Off the pane view a small map of your panes appears to drop on. Drag a pane's grip (⠿ in
|
|
1224
|
+
its header) to move it the same way. A session already open moves instead of opening twice; a fifth pane is
|
|
1225
|
+
refused with a notice. The `⋯` menu's **Open in pane → left/right/top/bottom** does the same from the keyboard
|
|
1226
|
+
(beside the focused pane). Drags carry no session ID or link outside the console. The focused pane
|
|
1227
|
+
(accent line) owns the composer, Dictate and Voice; the URL is its session, and following a link replaces
|
|
1228
|
+
it. Click a pane, or press F6 / Shift+F6 or Ctrl+Alt+1…4, to focus one; Ctrl+Alt+Shift+arrows (or the pane's
|
|
1229
|
+
arrows) move it; Ctrl+Alt+W or ✕ closes it. Closing a pane never stops its session, and a pane whose session
|
|
1230
|
+
vanishes closes with a notice. Drag the separators, or focus one and use the arrows (16px, Shift 64px),
|
|
1231
|
+
Home/End; double-click evens them. Which panes are open is kept per device (`hub.paneLayout.v1`), their sizes
|
|
1232
|
+
too (`hub.paneSizes.v1`); nothing is sent to the hub. On a phone the panes are tabs (arrow keys between
|
|
1233
|
+
tabs); only the selected one is on screen and streams live text.
|
|
1122
1234
|
- **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
|
|
1123
1235
|
Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
|
|
1124
1236
|
archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
|
|
@@ -1218,12 +1330,16 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
|
|
|
1218
1330
|
a task or ask a question" (images can be attached), and under it where it runs: **Environment** (your
|
|
1219
1331
|
machines on this hub: online first with their process and OpenCode version, offline ones listed but not
|
|
1220
1332
|
selectable; ✓ on the chosen one), **Project** (a popover with search: Herdr workspaces with their pane
|
|
1221
|
-
folders, then known folders
|
|
1333
|
+
folders, then known folders, with full paths; the chosen folder's full path shows under the chips; switching
|
|
1334
|
+
the environment never carries a folder over, not even a path both machines report: the chip says "Choose a
|
|
1335
|
+
project" until you pick one of that machine's own), the folder's **branch** (from the summary of the latest session in that
|
|
1222
1336
|
folder, when there is one) and **Open in Herdr tab** (a Herdr workspace on a machine that runs Herdr; on
|
|
1223
1337
|
by default). Submitting sends `POST /api/machines/:id/sessions {directory, herdrWorkspace?}`, opens the
|
|
1224
1338
|
new session and sends the prompt (and images) as its first turn (`POST /api/sessions/:id/turn`). The last
|
|
1225
|
-
environment and folder are remembered on the device (`localStorage` `hub.newSession`); the
|
|
1226
|
-
|
|
1339
|
+
environment and folder are remembered on the device (`localStorage` `hub.newSession`); the remembered folder is
|
|
1340
|
+
preselected only while that machine still reports exactly that folder, and nothing else is guessed: otherwise the
|
|
1341
|
+
chip says "Choose a project", and the send arrow stays grey (disabled, `aria-disabled`) until a project is chosen
|
|
1342
|
+
(CON-12). The group "+" preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
|
|
1227
1343
|
failed, the session still exists and the hub's `warning` is shown. 409 (background service not
|
|
1228
1344
|
connected) is shown under the composer.
|
|
1229
1345
|
|
|
@@ -1873,8 +1989,12 @@ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack
|
|
|
1873
1989
|
|
|
1874
1990
|
Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
|
|
1875
1991
|
`npm run dev:web` serves the console with hot reload, proxying `/api` to it (`HUB_URL` for a real hub).
|
|
1876
|
-
`npm run test:e2e` runs
|
|
1877
|
-
|
|
1992
|
+
`npm run test:e2e` (scripts/test-e2e.sh) runs every headless Chromium suite, each alone: the smoke test against the
|
|
1993
|
+
mock hub at desktop and phone sizes (with the nav, profiles, workspace, Watchtower, team editor and artifact suites
|
|
1994
|
+
it chains), then the real-hub suites `web/e2e/artifact-host.ts`, `web/e2e/artifact-bridge.ts` and
|
|
1995
|
+
`web/e2e/hwa-workflow.ts` (two simulated machines: side chat launch and Send to main, a declared action from an
|
|
1996
|
+
embedded app). Build `web/dist` first; `CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to
|
|
1997
|
+
the temp dir. CI does not run them (no Chromium there): `scripts/test-ci.sh` runs the bun tests.
|
|
1878
1998
|
|
|
1879
1999
|
`test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
|
|
1880
2000
|
`hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
|
|
@@ -1900,7 +2020,9 @@ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.tes
|
|
|
1900
2020
|
`opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
|
|
1901
2021
|
`HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
|
|
1902
2022
|
test/spoke-deliver.it.test.ts` does the same for hub messages: the model sees the delivered envelope, a repeated
|
|
1903
|
-
message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin.
|
|
2023
|
+
message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin.
|
|
2024
|
+
`HUB_IT_OPENCODE=1 bun test test/e2e-quiet-delivery.it.test.ts` sends `wake: false` messages through a real hub to an
|
|
2025
|
+
idle and to a busy session and checks that neither costs a model call and the next real turn carries them. `HUB_IT_OPENCODE=1 bun test test/spoke-hub-tool.it.test.ts` has the stub model call the `hub` tool and checks
|
|
1904
2026
|
that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
|
|
1905
2027
|
test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
|
|
1906
2028
|
reference note attached, and that hub-sourced prompts get none.
|
|
@@ -1914,6 +2036,13 @@ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy
|
|
|
1914
2036
|
`bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
|
|
1915
2037
|
<dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
1916
2038
|
|
|
2039
|
+
`test/e2e-hwa-*.test.ts` (part of `npm test`) run workspaces, profiles, side chats, artifacts, AI jobs, teams and the
|
|
2040
|
+
Watchtower graph end to end across two simulated machines (`test/e2e-hwa-kit.ts`: each a node plus a service spoke,
|
|
2041
|
+
real launch engine): a workspace bound on both, supervision, board, digests and checkpoints, disconnect/reconnect,
|
|
2042
|
+
master replacement, archive and a restart that keeps everything; profile acks; jobs handled on the other machine;
|
|
2043
|
+
teams through `POST /mcp`; load caps; a record from a newer hub refused, never overwritten; and a tenant matrix
|
|
2044
|
+
that sends every parameterised route of these features as another user (answers like an unknown ID).
|
|
2045
|
+
|
|
1917
2046
|
Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
|
|
1918
2047
|
`{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
|
|
1919
2048
|
OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Huy Le",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"packages/codex"
|
|
100
100
|
],
|
|
101
101
|
"engines": {
|
|
102
|
-
"bun": ">=1.
|
|
102
|
+
"bun": ">=1.4.4"
|
|
103
103
|
},
|
|
104
104
|
"publishConfig": {
|
|
105
105
|
"access": "public"
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"build:web": "vite build --config web/vite.config.ts",
|
|
111
111
|
"build:cli": "bun build packages/cli/src/main.ts --target=node --outfile=packages/cli/dist/hub-cli.js",
|
|
112
112
|
"dev:web": "vite --config web/vite.config.ts",
|
|
113
|
-
"test:e2e": "
|
|
113
|
+
"test:e2e": "bash scripts/test-e2e.sh",
|
|
114
114
|
"prepack": "npm run build:web && npm run build:cli",
|
|
115
115
|
"hub": "bun bin/hub.ts"
|
|
116
116
|
},
|
|
@@ -121,7 +121,9 @@
|
|
|
121
121
|
},
|
|
122
122
|
"devDependencies": {
|
|
123
123
|
"@types/bun": "^1.4.2",
|
|
124
|
+
"@types/three": "0.186.0",
|
|
124
125
|
"@types/web-push": "^3.6.4",
|
|
126
|
+
"3d-force-graph": "1.80.1",
|
|
125
127
|
"onnxruntime-web": "1.23.2",
|
|
126
128
|
"playwright-core": "^1.64.1",
|
|
127
129
|
"solid-js": "^1.9.17",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-claude-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// once and runs its claude headless (launcher.ts) with the hub plugin, so its hooks come back here like any other
|
|
10
10
|
// session's. turn and interrupt reach only those sessions (stdin user messages, control requests); a terminal
|
|
11
11
|
// session refuses them. stop interrupts a launched session's running turn at once, besides the hook-level stop.
|
|
12
|
-
// Stopping the daemon ends the claude processes it started.
|
|
12
|
+
// Stopping the daemon ends the claude processes it started. profileApply (HWA-17, profile.ts) is served either way.
|
|
13
13
|
//
|
|
14
14
|
// A `hub claude` run registers its pid ("launch"); the daemon checks it now and then and ends the run's sessions
|
|
15
15
|
// when the process is gone (Claude Code killed without SessionEnd), and at once when the wrapper reports its exit.
|
|
@@ -23,6 +23,7 @@ import { parseHookInput } from "./hook-input.ts"
|
|
|
23
23
|
import { ensurePrivateDir, SOCKET_PATH_MAX } from "./paths.ts"
|
|
24
24
|
import { createMcpRouter, MCP_END, type McpRouter } from "./mcp.ts"
|
|
25
25
|
import { ClaudeLauncher, launchAgent, launchDirectory, launchModel } from "./launcher.ts"
|
|
26
|
+
import { ClaudeProfiles } from "./profile.ts"
|
|
26
27
|
import { hubReferences } from "./references.ts"
|
|
27
28
|
import { ClaudeSessions, type SessionsOptions } from "./sessions.ts"
|
|
28
29
|
import { ask, onLines, WIRE_VERSION } from "./wire.ts"
|
|
@@ -167,9 +168,14 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
|
|
|
167
168
|
return { stopped: stopped.stopped.length ? stopped.stopped : [req.sessionID] }
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
const profiles = new ClaudeProfiles({
|
|
172
|
+
kind: (id, native = sessions.nativeOf(id)) => (native === undefined ? undefined : launchKeys.has(native) ? "launched" : "terminal"),
|
|
173
|
+
setModel: (id, model) => launcher!.setModel(sessions.nativeOf(id)!, model),
|
|
174
|
+
})
|
|
170
175
|
const handlers: OpHandlers = {
|
|
171
176
|
permission: (req) => sessions.permission(req),
|
|
172
177
|
stop,
|
|
178
|
+
profileApply: profiles.handle,
|
|
173
179
|
...(launcher ? { create, turn, interrupt } : {}),
|
|
174
180
|
}
|
|
175
181
|
const features: ModifierFeature[] = ["permissionMessage", "hubPrompt", ...(launcher ? (["launch"] as const) : [])]
|
|
@@ -195,6 +201,7 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
|
|
|
195
201
|
...(options.tokenWaitMs !== undefined ? { tokenWaitMs: options.tokenWaitMs } : {}),
|
|
196
202
|
...(options.permissionWaitMs !== undefined ? { permissionWaitMs: options.permissionWaitMs } : {}),
|
|
197
203
|
references: options.references ?? hubReferences((id) => spoke.sessionToken(id)),
|
|
204
|
+
profiles,
|
|
198
205
|
})
|
|
199
206
|
|
|
200
207
|
const launches = new Map<string, number>()
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// --input-format stream-json --output-format stream-json --verbose [--model m] [--agent a]`, so its hooks reach the
|
|
4
4
|
// daemon like any `hub claude` run (HUB_CLAUDE_LAUNCH, HUB_CLAUDE_SOCKET), a turn is a user message written to its
|
|
5
5
|
// stdin, the turn's end is the result line on its stdout, and an interrupt is a control request (Esc has no hook,
|
|
6
|
-
// a control request works).
|
|
6
|
+
// a control request works). A profile's model (HWA-17) is the control request set_model, confirmed by Claude Code's
|
|
7
|
+
// control response. Turns are answered in order (Claude Code runs queued user messages one after another).
|
|
7
8
|
// When claude exits its open turns fail and onExit reports it; stopAll closes stdin (claude ends after the running
|
|
8
9
|
// turn) and kills what lingers.
|
|
9
10
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
|
|
@@ -44,7 +45,8 @@ interface Turn {
|
|
|
44
45
|
interface Proc {
|
|
45
46
|
readonly child: ChildProcessWithoutNullStreams
|
|
46
47
|
readonly turns: Turn[]
|
|
47
|
-
|
|
48
|
+
/** Open control requests: called with Claude Code's response, or nothing when it exited. */
|
|
49
|
+
readonly controls: Map<string, (response?: Record<string, any>) => void>
|
|
48
50
|
readonly exited: Promise<void>
|
|
49
51
|
stderr: string
|
|
50
52
|
}
|
|
@@ -224,14 +226,32 @@ export class ClaudeLauncher {
|
|
|
224
226
|
const proc = this.#procs.get(sessionID)
|
|
225
227
|
if (!proc) return false
|
|
226
228
|
for (const turn of proc.turns) turn.interrupted = true
|
|
227
|
-
|
|
228
|
-
const answered = new Promise<void>((resolve) => proc.controls.set(id, resolve))
|
|
229
|
-
proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request: { subtype: "interrupt" } })}\n`)
|
|
230
|
-
await Promise.race([answered, sleep(CONTROL_WAIT_MS)])
|
|
231
|
-
proc.controls.delete(id)
|
|
229
|
+
await this.#control(proc, { subtype: "interrupt" }, CONTROL_WAIT_MS)
|
|
232
230
|
return true
|
|
233
231
|
}
|
|
234
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Changes the session's model (control request set_model, a Claude Code model ID or alias); resolves when Claude
|
|
235
|
+
* Code confirms, rejects with its error, when it does not answer in `waitMs`, or for a session it did not start.
|
|
236
|
+
*/
|
|
237
|
+
async setModel(sessionID: string, model: string, waitMs = CONTROL_WAIT_MS): Promise<void> {
|
|
238
|
+
const proc = this.#procs.get(sessionID)
|
|
239
|
+
if (!proc) throw new Error(`Session ${sessionID} was not started by the hub here (only hub-launched Claude Code sessions change model from the hub)`)
|
|
240
|
+
const response = await this.#control(proc, { subtype: "set_model", model }, waitMs)
|
|
241
|
+
if (!response) throw new Error("Claude Code did not confirm the model change")
|
|
242
|
+
if (response.subtype !== "success") throw new Error(typeof response.error === "string" && response.error ? response.error.slice(0, 200) : "Claude Code refused the model change")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Sends a control request; resolves with Claude Code's response, or undefined (no answer in time, or it exited). */
|
|
246
|
+
async #control(proc: Proc, request: Record<string, unknown>, waitMs: number): Promise<Record<string, any> | undefined> {
|
|
247
|
+
const id = `hub_ctl_${++this.#controlID}`
|
|
248
|
+
const answered = new Promise<Record<string, any> | undefined>((resolve) => proc.controls.set(id, resolve))
|
|
249
|
+
proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request })}\n`)
|
|
250
|
+
const response = await Promise.race([answered, sleep(waitMs).then(() => undefined)])
|
|
251
|
+
proc.controls.delete(id)
|
|
252
|
+
return response
|
|
253
|
+
}
|
|
254
|
+
|
|
235
255
|
/** Ends every process: stdin closed, then SIGTERM and SIGKILL for what lingers. */
|
|
236
256
|
async stopAll(): Promise<void> {
|
|
237
257
|
const procs = [...this.#procs.values()]
|
|
@@ -258,8 +278,8 @@ export class ClaudeLauncher {
|
|
|
258
278
|
const turn = proc.turns.shift()
|
|
259
279
|
turn?.resolve(turnResult(line, turn.interrupted))
|
|
260
280
|
} else if (line.type === "control_response") {
|
|
261
|
-
const
|
|
262
|
-
if (typeof
|
|
281
|
+
const response = record(line.response)
|
|
282
|
+
if (typeof response.request_id === "string") proc.controls.get(response.request_id)?.(response)
|
|
263
283
|
}
|
|
264
284
|
}
|
|
265
285
|
}
|