@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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
|
|
@@ -151,6 +194,11 @@ curl -fsSLO https://github.com/huynle/hub-releases/releases/download/v<version>/
|
|
|
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
|
|
@@ -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 |
|
|
@@ -407,6 +480,11 @@ cookie and a hub bearer token (400).
|
|
|
407
480
|
| `GET` · `PUT /api/agent/messaging {enabled}` | ✓ | – | – | the per-user messaging switch (off: `423 paused`) |
|
|
408
481
|
| `POST /api/agent/app-tokens {label?, scopes?}` · `GET` · `DELETE …/:id` | ✓ | – | – | creates (token shown once), lists, revokes app tokens |
|
|
409
482
|
|
|
483
|
+
Sends to a session (`msg`, `ask`, `reply`, `decline`) answer `{message, delivery, woke, wakeSkipped?}`: `woke` says
|
|
484
|
+
whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says why (`wake_false`, `subagent`,
|
|
485
|
+
`hop_cap`, `budget`; see **Limits**). The CLI and MCP tools print it, e.g. `queued (read at its next turn) — wake
|
|
486
|
+
skipped: hop limit reached`. Hubs before 0.8.1 send neither field.
|
|
487
|
+
|
|
410
488
|
The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
|
|
411
489
|
|
|
412
490
|
**Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
|
|
@@ -458,9 +536,12 @@ most 10,000 characters (a decline's reason 1,000).
|
|
|
458
536
|
|
|
459
537
|
**Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
|
|
460
538
|
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
|
-
|
|
539
|
+
turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it. A `--no-wake` message never
|
|
540
|
+
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
|
|
541
|
+
ends (until then the hub keeps it `pending`), so it too arrives with the next turn. Urgent `--no-wake` messages,
|
|
542
|
+
replies and asks to a busy session that is not woken (a subagent, past the hop cap or wake budget) go into the running
|
|
543
|
+
turn at once, which may cost that turn one more model call. Subagent sessions are never woken. A session that is
|
|
544
|
+
offline gets it when it comes back (pending, for 24 hours; after that it expires undelivered, `expired`, and its sender is told: a session by a quiet hub notice, you by a notification). A session on a spoke that cannot take messages (an older plugin, another harness without `deliver`) answers
|
|
464
545
|
`unsupported` (exit 7) and nothing is stored. An open ask to an idle session is re-woken with a short reminder
|
|
465
546
|
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
547
|
|
|
@@ -491,7 +572,8 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
|
|
|
491
572
|
**Where agents send from.**
|
|
492
573
|
- In a shell of an OpenCode session: `hub msg @reviewer "…"` acts as that session ([hub CLI](#hub-cli)).
|
|
493
574
|
- The model itself: the plugin's `hub` tool (verbs `msg`, `ask`, `reply`, `decline`, `inbox`, `who`, `status`,
|
|
494
|
-
`whoami`,
|
|
575
|
+
`whoami`, the [team intake](docs/teams-intake.md) verbs `intake` and `intake_update`, `name`, `role_claim` and
|
|
576
|
+
`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
577
|
- You, in the OpenCode TUI: `/msg @name text` (as the session on screen) and `/who`.
|
|
496
578
|
- Other agents and tools: [`hub mcp`](#hub-mcp-mcp-server), as the app token you logged in with, or the hub's
|
|
497
579
|
[`POST /mcp`](#over-http-mcp) as the bearer token sent.
|
|
@@ -499,8 +581,21 @@ sessions or apps; see [Decisions and `@me`](#agent-identity-agent-api) for the r
|
|
|
499
581
|
|
|
500
582
|
**Limits.** Per 10 minutes: 20 messages per sender→recipient pair and 300 per user (`rate_limited`, exit 6, with
|
|
501
583
|
`retryAfter`), and 30 turns started per recipient (more messages are still delivered, without waking it). A chain of
|
|
502
|
-
|
|
503
|
-
you are notified once. Typing into a session (console, Voice, the harness) starts its chain over.
|
|
584
|
+
messages between sessions may run 8 hops without a human turn; past that, messages are delivered without waking and
|
|
585
|
+
you are notified once. Typing into a session (console, Voice, the harness) starts its chain over. Apps (an app
|
|
586
|
+
token, and the hub's own Concierge, workspace coordinator and team intake) count hops per conversation: a new `msg`
|
|
587
|
+
or `ask` from an app starts at hop 1 and replies to an app do not count against it, so an app's 5th (or 50th) ask
|
|
588
|
+
still wakes the session it talks to. Two guards keep apps from restarting chains: a reply or decline counts up from
|
|
589
|
+
the message it answers (reply ping-pong through an app is capped like any other chain), and for 5 minutes after a
|
|
590
|
+
session sends an app something, the app's new messages to *other* sessions continue from that hop (a relay A → app →
|
|
591
|
+
B → app → A reaches the cap after about 8 hops; spaced-out work starts at hop 1 again). An app answering one session
|
|
592
|
+
with new messages forever is held by the pair limit and the wake budget. A team intake submitted by a session
|
|
593
|
+
continues that session's chain. A send's answer says when and why it did not wake (`woke`, `wakeSkipped`); the CLI,
|
|
594
|
+
`hub mcp` and the OpenCode `hub` tool print it.
|
|
595
|
+
|
|
596
|
+
**Never give agents an app token.** A session that holds one (in its environment, an MCP config, a gateway) can send
|
|
597
|
+
as the app, at a fresh hop depth, outside its own chain and its session-scoped policy. Sessions message with their
|
|
598
|
+
own session token (the `hub` tool, `hub msg` in their shell); app tokens are for scripts and outside tools. You can switch
|
|
504
599
|
messaging off for your account (`PUT /api/agent/messaging {enabled: false}`): sends then answer `paused` (exit 5),
|
|
505
600
|
reading still works.
|
|
506
601
|
|
|
@@ -581,6 +676,7 @@ allow patterns (else `needs_user`) → the hourly cap → the kill switches agai
|
|
|
581
676
|
| `gone` | 410 | 8 | answered elsewhere first, expired or ended |
|
|
582
677
|
| `unsupported` | 422 | 7 | the session's spoke cannot do it (e.g. an agent's prompt without `hubPrompt`) |
|
|
583
678
|
| `offline` | 503 | 7 | no spoke reports the session now |
|
|
679
|
+
| `busy` | 503 | 7 | the hub's state did not answer in time (`retryAfter`): refused, try again |
|
|
584
680
|
|
|
585
681
|
**Escalation.** An approval outside the grant answers `needs_user` with the evaluation (`verdict`, `reason`, the
|
|
586
682
|
grants checked, and a `tier`) and opens a Decision for you: one per agent and request (asking again updates its
|
|
@@ -633,7 +729,7 @@ and the SDK. Only you manage grants: the console (Settings → Agents) or the CL
|
|
|
633
729
|
| `GET` · `PUT /api/admin/agents/acting {enabled}` | admins | – | – |
|
|
634
730
|
| `GET /api/agent/audit?limit=&before=` | ✓ | – | – |
|
|
635
731
|
|
|
636
|
-
**Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`) is in
|
|
732
|
+
**Audit.** Every agent action and every attempt (allowed, `needs_user`, `forbidden`, `paused`, `rate_limited`, `busy`) is in
|
|
637
733
|
your agent audit with who acted, the verb, the session and request, the grant, the reason and the outcome (`ok`,
|
|
638
734
|
`gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`); so are your answers to escalations, grant
|
|
639
735
|
requests and switch changes. Read it in Settings → Agents → Activity or with `GET /api/agent/audit` (newest first,
|
|
@@ -669,11 +765,17 @@ those harnesses through their service spokes ([docs/launches.md](docs/launches.m
|
|
|
669
765
|
spec's fields and limits to placement, caps, the node's git rules, webhook signatures and sandboxed launches in
|
|
670
766
|
OpenShell (`hub node openshell check`): [docs/launches.md](docs/launches.md).
|
|
671
767
|
|
|
768
|
+
A queued launch says why it waits (`hub launches list`, `GET /api/agent/launches/:id` `waiting`, Settings → Agents &
|
|
769
|
+
access → Launches): the per-user cap `agents.launches.maxActive` (default 4) with who holds it, a full node with its
|
|
770
|
+
used/reserved slots, or each node's first problem. The cap counts every launcher's live launches (apps, sessions,
|
|
771
|
+
you), except the Concierge's own; revoking an app token does not stop its launches, they are flagged so you can stop
|
|
772
|
+
them ([docs/launches.md](docs/launches.md#how-the-caps-count)).
|
|
773
|
+
|
|
672
774
|
## hub CLI
|
|
673
775
|
|
|
674
776
|
`hub` (also installed as `hubctl`) reads, names, messages and acts on your sessions through the hub's [`/api/agent` API](#agent-identity-agent-api),
|
|
675
|
-
for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (
|
|
676
|
-
`~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
|
|
777
|
+
for agents and scripts. The OpenCode plugin writes both launchers into the hub bin dir (`bin` in
|
|
778
|
+
the hub state dir, see `HUB_STATE_DIR` under [Configuration](#configuration): usually `~/.local/state/hub/bin`) at start and puts that dir first on PATH in every shell it prepares, so inside a
|
|
677
779
|
session `hub` just works. A launcher runs the CLI bundle shipped in this package (`packages/cli/dist/hub-cli.js`)
|
|
678
780
|
with the OpenCode binary itself (`BUN_BE_BUN=1 exec <opencode> hub-cli.js "$@"`), so neither bun nor node has to
|
|
679
781
|
be installed; without Bun it falls back to `node`. The launchers are rewritten only when their content changes.
|
|
@@ -681,7 +783,7 @@ When a standalone hubctl is installed as `~/.local/bin/hubctl` (what `hubctl set
|
|
|
681
783
|
instead, so shells, services and MCP clients all use that one signed program. For your own terminals the plugin also
|
|
682
784
|
links `~/.local/bin/hub` to its `hub` launcher, with the setup's rule for `~/.local/bin`: only when nothing is there or
|
|
683
785
|
it is hub's own link, never when another `hub` is on PATH (then it prints where hub's is), and only from the default
|
|
684
|
-
state dir (
|
|
786
|
+
state dir (never with `HUB_STATE_DIR`). `hubctl setup`'s `hub` → `hubctl` link is kept; `~/.local/bin/hubctl` is left to
|
|
685
787
|
`hubctl setup`. When `~/.local/bin` is not on PATH it prints one hint.
|
|
686
788
|
|
|
687
789
|
**Name clash:** GitHub's old `hub` CLI (github/hub) uses the same name. In hub sessions ours comes first on PATH;
|
|
@@ -696,6 +798,7 @@ hub watch [--blocked] [--session <addr>] events as JSON lines until Ctrl-C (pin
|
|
|
696
798
|
hub whoami who this credential is
|
|
697
799
|
hub who [--project P] [--role R | @@role] named and role-holding sessions
|
|
698
800
|
hub name <@name> [--session <addr>]
|
|
801
|
+
hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
|
|
699
802
|
hub role claim <role[.project]> [--exclusive] [--session <addr>]
|
|
700
803
|
hub role release <role[.project]> [--session <addr>]
|
|
701
804
|
hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
|
|
@@ -743,7 +846,8 @@ unchanged (for errors, the `{error: {code, message, hint?, …}}` envelope on st
|
|
|
743
846
|
message, any candidates or retry time, a missing feature, and the hint on stderr.
|
|
744
847
|
|
|
745
848
|
**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
|
|
849
|
+
the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline (or,
|
|
850
|
+
for a `--no-wake` message, while the recipient's turn runs), or
|
|
747
851
|
`stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
|
|
748
852
|
`--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
|
|
749
853
|
read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
|
|
@@ -796,7 +900,16 @@ timestamps older than 5 minutes refused). The spec's fields and limits, states,
|
|
|
796
900
|
**hub node.** `hub node run` is the per-machine daemon that prepares launch workspaces: it needs an enrolled
|
|
797
901
|
machine (it only reads the machine token in `~/.config/hub/config.json`) and `~/.config/hub/node.json` with at
|
|
798
902
|
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.
|
|
903
|
+
machine is not enrolled, 78 when there are no workspace roots, and 0 on SIGINT/SIGTERM. While it offers the
|
|
904
|
+
opencode harness (the default; node.json `"harnesses": {"opencode": false}` withholds it) it keeps OpenCode's
|
|
905
|
+
background service warm: every 30 s it finds the service through its registration file
|
|
906
|
+
(`$XDG_STATE_HOME/opencode/service.json`, default `~/.local/state/opencode/service.json`, read directly: no request
|
|
907
|
+
before the check) and, only for a literal loopback URL without user info (127.0.0.1, [::1]; localhost is asked as
|
|
908
|
+
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
|
|
909
|
+
anyone opening a project (logged when it starts working or fails). It also loads the projects of the service's
|
|
910
|
+
recent sessions under `workspaceRoots` (at most 8, or node.json `opencode.directories`), and warms each foreground
|
|
911
|
+
server listed in node.json `opencode.servers` (`{url, passwordFile, directories?}`: a loopback URL and a `chmod 600`
|
|
912
|
+
file holding its password; see [docs/launches.md](docs/launches.md#hub-node)) the same way. `hub node install` writes a
|
|
800
913
|
launchd agent (macOS, `~/Library/LaunchAgents/io.github.huynle.hub-node.plist`) or a systemd user unit (Linux,
|
|
801
914
|
`~/.config/systemd/user/hub-node.service`) from the templates in `scripts/`, with the runtime and CLI entry running
|
|
802
915
|
the command (so `hubctl node install` from a hub session runs the bundle with the OpenCode binary) and
|
|
@@ -836,7 +949,7 @@ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from
|
|
|
836
949
|
| 4 | `needs_user` |
|
|
837
950
|
| 5 | `forbidden`, `paused` |
|
|
838
951
|
| 6 | `rate_limited` (with `retryAfter`) |
|
|
839
|
-
| 7 | `unsupported`, `offline` |
|
|
952
|
+
| 7 | `unsupported`, `offline`, `busy` |
|
|
840
953
|
| 8 | `gone`; an `ask --wait` that expired |
|
|
841
954
|
| 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
|
|
842
955
|
|
|
@@ -856,7 +969,10 @@ carry the same canonical compact events as `events`; their signing secret appear
|
|
|
856
969
|
`answer` (`answer` object or `cancel`), `prompt` (`delivery`) and `stop`, and the launch tools `launch` (`repo`
|
|
857
970
|
required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles`, `harness`, `agent`, `model`,
|
|
858
971
|
`runtime`, `profile`, `machine`, `nodeLabels` and `labels` objects, `prefer`, `prompt`, `end`, `timeout`,
|
|
859
|
-
`cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`)
|
|
972
|
+
`cleanup`), `launches` (`state`, `label`), `launch_show` and `launch_stop` (`id`); the profile tools (`profiles`,
|
|
973
|
+
`profile_show`, `profile_create`, `profile_version_create`, `profile_skill_set`, `profile_command_set`,
|
|
974
|
+
`profile_assign`, …; [docs/profiles.md](docs/profiles.md#sdk-and-mcp)), the workspace, artifact and team tools
|
|
975
|
+
([docs/teams.md](docs/teams.md#mcp-tools); `team_staff` takes the nested launch spec), with the same arguments
|
|
860
976
|
(`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
|
|
861
977
|
tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
|
|
862
978
|
launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
|
|
@@ -1050,8 +1166,11 @@ not persist those potentially sensitive details.
|
|
|
1050
1166
|
keeps listening. The phone's stop words ("stop", "cancel", "never mind", "stop that", "hold on") still
|
|
1051
1167
|
interrupt the root turn at once.
|
|
1052
1168
|
- **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). `
|
|
1169
|
+
`decline`, `inbox`, `who`, `status`, `whoami`, `intake` and `intake_update` (`src/spoke/hub-tool.ts`). It acts as the calling session
|
|
1170
|
+
with that session's own hub token (the token never appears in the tool's output). `name {name}` sets the
|
|
1171
|
+
session's own @name (the old one stays an alias for 24 h; `name_taken`, invalid and reserved names fail as in
|
|
1172
|
+
`hub name`), `role_claim {role, project?, exclusive?}` and `role_release {role, project?}` its own roles; these
|
|
1173
|
+
take no `session`: a session changes only itself. `ask` with `wait` (seconds,
|
|
1055
1174
|
at most 600) blocks for the answer until the tool call is stopped; without it the answer arrives later as a hub
|
|
1056
1175
|
message. Messages from other sessions are shown in the hub's envelope and are not from the user.
|
|
1057
1176
|
- Child text and execution events are paired by the hub. The latest assistant message of a successful
|
|
@@ -1089,10 +1208,11 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
|
|
|
1089
1208
|
| `HUB_AUTH` | `pairing` | `oidc` for several users signing in with Google; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
1090
1209
|
| `HUB_HOST` / `HUB_PORT` | `127.0.0.1` / `4097` | listen address |
|
|
1091
1210
|
| `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 |
|
|
1092
|
-
| `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 |
|
|
1211
|
+
| `HUB_STATE_DIR` | `$XDG_STATE_HOME/hub` if it holds hub state (`state.json`, `token-keys.json`, `agent/`, `bin/`, … — a `bootstrap/` alone does not count), else an existing `~/.local/state/hub` holding hub state (kept after an upgrade: the hub logs the `mv` to switch), else `$XDG_STATE_HOME/hub` when XDG_STATE_HOME is set (fresh installs), else `~/.local/state/hub`; the plugin, the local hub, the hub server and the Claude Code and Codex spokes all resolve it the same way | `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 |
|
|
1093
1212
|
| `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
1213
|
| `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
1214
|
| `HUB_POD_ID` | the hostname | cluster mode: this pod's unique id |
|
|
1215
|
+
| `HUB_DATABASE_STATEMENT_TIMEOUT_MS` | `30000` | cluster mode: Postgres `statement_timeout` of the hub's pools (`0` turns it off) |
|
|
1096
1216
|
| `HUB_TURN_TIMEOUT_MS` | `600000` | the hub waits this + 15 s so the spoke's own timeout result arrives first |
|
|
1097
1217
|
| `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
|
|
1098
1218
|
| `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
|
|
@@ -1116,9 +1236,30 @@ recent activity.
|
|
|
1116
1236
|
the Docker image, npm package and binaries ship the build). One store fed by the hub's SSE stream drives:
|
|
1117
1237
|
|
|
1118
1238
|
- **Sidebar** (⌘B collapses; a drawer on phones): search, New session, Needs you, Running, Pinned, sessions open in a
|
|
1119
|
-
terminal grouped by project (or server) with `+`, Recents
|
|
1239
|
+
terminal grouped by project (or server) with `+`, Recents. Each section collapses from its heading (remembered on
|
|
1240
|
+
the device, `hub.sidebar.sections.collapsed`), showing its count; Needs you always shows its count. Rows show status, a pulsing green dot left of the
|
|
1120
1241
|
title for the session live in Voice (in Voice mode here, or in a conversation), an unread dot and a `⋯` menu
|
|
1121
1242
|
(rename, pin, archive, stop, Allow/Disallow Voice).
|
|
1243
|
+
- **Primary navigation** above the session groups: **All sessions**, **Artifacts** (the library across workspaces,
|
|
1244
|
+
[docs/artifacts.md](docs/artifacts.md)), **Watchtower** ([docs/watchtower.md](docs/watchtower.md)) and **Agent
|
|
1245
|
+
profiles** ([docs/profiles.md](docs/profiles.md)). The sidebar's **Workspaces** tree (drag to supervise) and the
|
|
1246
|
+
session ⋯ menu's **Start side chat…** are described in [docs/workspaces.md](docs/workspaces.md).
|
|
1247
|
+
- **Split panes**: **Open beside** in a session's `⋯` menu (table, sidebar, phone list) opens it next to the
|
|
1248
|
+
focused pane, up to 4 panes, side by side, stacked or in a grid (the icon switch in the bar above the panes).
|
|
1249
|
+
On desktop you can also **drag** a session row (sidebar or Workspaces tree), an artifact (Artifacts library) or
|
|
1250
|
+
the Watchtower detail's **Drag into a pane** handle onto a pane: an edge splits there (left/right side by side,
|
|
1251
|
+
top/bottom stacked), the centre replaces that pane, and the shape reflows (a pane dropped across a side-by-side
|
|
1252
|
+
pair makes a grid). Off the pane view a small map of your panes appears to drop on. Drag a pane's grip (⠿ in
|
|
1253
|
+
its header) to move it the same way. A session already open moves instead of opening twice; a fifth pane is
|
|
1254
|
+
refused with a notice. The `⋯` menu's **Open in pane → left/right/top/bottom** does the same from the keyboard
|
|
1255
|
+
(beside the focused pane). Drags carry no session ID or link outside the console. The focused pane
|
|
1256
|
+
(accent line) owns the composer, Dictate and Voice; the URL is its session, and following a link replaces
|
|
1257
|
+
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
|
|
1258
|
+
arrows) move it; Ctrl+Alt+W or ✕ closes it. Closing a pane never stops its session, and a pane whose session
|
|
1259
|
+
vanishes closes with a notice. Drag the separators, or focus one and use the arrows (16px, Shift 64px),
|
|
1260
|
+
Home/End; double-click evens them. Which panes are open is kept per device (`hub.paneLayout.v1`), their sizes
|
|
1261
|
+
too (`hub.paneSizes.v1`); nothing is sent to the hub. On a phone the panes are tabs (arrow keys between
|
|
1262
|
+
tabs); only the selected one is on screen and streams live text.
|
|
1122
1263
|
- **Archive**: **Archive** in a session's `⋯` menu takes it out of the table, the sidebar's groups, Recents and
|
|
1123
1264
|
Running, on all your devices (with **Undo**). Nothing is deleted and OpenCode's history is untouched:
|
|
1124
1265
|
archiving is the hub's own, per user, because OpenCode has no archive API yet (when it gets one, Archive
|
|
@@ -1218,12 +1359,17 @@ Pure logic lives in `web/src/core` and `web/src/lib` (bun tests in `test/web-*.t
|
|
|
1218
1359
|
a task or ask a question" (images can be attached), and under it where it runs: **Environment** (your
|
|
1219
1360
|
machines on this hub: online first with their process and OpenCode version, offline ones listed but not
|
|
1220
1361
|
selectable; ✓ on the chosen one), **Project** (a popover with search: Herdr workspaces with their pane
|
|
1221
|
-
folders, then known folders
|
|
1362
|
+
folders, then known folders, with full paths; the chosen folder's full path shows under the chips; switching
|
|
1363
|
+
the environment never carries a folder over, not even a path both machines report: the chip says "Choose a
|
|
1364
|
+
project" until you pick one of that machine's own), the folder's **branch** (from the summary of the latest session in that
|
|
1222
1365
|
folder, when there is one) and **Open in Herdr tab** (a Herdr workspace on a machine that runs Herdr; on
|
|
1223
1366
|
by default). Submitting sends `POST /api/machines/:id/sessions {directory, herdrWorkspace?}`, opens the
|
|
1224
1367
|
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
|
-
|
|
1368
|
+
environment and folder are remembered on the device (`localStorage` `hub.newSession`); the remembered folder is
|
|
1369
|
+
preselected only while that machine still reports exactly that folder, and nothing else is guessed: otherwise the
|
|
1370
|
+
chip says "Choose a project", and the send arrow stays grey (disabled, `aria-disabled`) until a project is chosen
|
|
1371
|
+
(CON-12). A machine that reports exactly one project has it chosen; Enter without a project says so and moves the
|
|
1372
|
+
focus to the Project chip (FX-06). The group "+" preselects with `?machine=&key=&dir=`. "Opened in Herdr · <workspace>" confirms the new tab; if Herdr
|
|
1227
1373
|
failed, the session still exists and the hub's `warning` is shown. 409 (background service not
|
|
1228
1374
|
connected) is shown under the composer.
|
|
1229
1375
|
|
|
@@ -1720,7 +1866,7 @@ in the file).
|
|
|
1720
1866
|
| `auth` | `{mode, issuer?, clientId, clientSecret, allowed, admins}`; see [Multi-user](#multi-user-sign-in-with-google) |
|
|
1721
1867
|
| `cluster` | `{database, valkey}` URLs for cluster mode (env `HUB_DATABASE_URL` / `HUB_VALKEY_URL` win); see [docs/cluster.md](docs/cluster.md) |
|
|
1722
1868
|
| `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) |
|
|
1723
|
-
| `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60); see [docs/launches.md](docs/launches.md#placement-and-caps) |
|
|
1869
|
+
| `agents.launches` | per-user launch caps `{maxQueued?, maxActive?, perHour?}` (defaults 50, 4, 60) and `lostAfterMinutes?` (0–1440 or false, default 5: a running launch the same node instance stops reporting, with its session gone, ends lost; 0/false: never); see [docs/launches.md](docs/launches.md#placement-and-caps) |
|
|
1724
1870
|
| `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) |
|
|
1725
1871
|
|
|
1726
1872
|
Server fields: env vars (`HUB_HOST`, `HUB_PORT`, `HUB_PUBLIC_URL`, `HUB_STATE_DIR`) > hub.json >
|
|
@@ -1760,8 +1906,11 @@ When the configured server is loopback (`localhost`, `127.0.0.1`, `[::1]`, http
|
|
|
1760
1906
|
on it, the plugin starts `<this repo>/bin/hub.ts` as a detached background process bound to that
|
|
1761
1907
|
address and port. The runtime (`resolveHubRuntime` in `src/spoke/localhub.ts`): this process when it is
|
|
1762
1908
|
`bun`, else `bun` on `PATH`, else `~/.bun/bin/bun`, else the current executable with `BUN_BE_BUN=1`
|
|
1763
|
-
(inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to
|
|
1764
|
-
|
|
1909
|
+
(inside OpenCode that is the `opencode` binary, a compiled Bun, so nothing else needs installing). Output is appended to `hub.log` in the hub state dir (`$HUB_STATE_DIR`, else as in the
|
|
1910
|
+
`HUB_STATE_DIR` row of the configuration table, usually `~/.local/state/hub`). When that dir holds no hub state yet (missing,
|
|
1911
|
+
empty, or only `hubctl setup`'s `bootstrap/`), the newest legacy state dir is copied there first (next to
|
|
1912
|
+
`bootstrap/`, never over it): `opencode-hub`, then `opencode-voice`, under `$XDG_STATE_HOME` and
|
|
1913
|
+
then under `~/.local/state` (never with `HUB_STATE_DIR`). It happens on setup (**On this
|
|
1765
1914
|
computer**, any process), on **Open hub** (even with auto-start
|
|
1766
1915
|
off), and when an enrolled connection is refused:
|
|
1767
1916
|
the shared service process (`--service`) tries at once, other processes after 8 s so the service goes
|
|
@@ -1873,8 +2022,12 @@ npm run build:cli # packages/cli/dist/hub-cli.js, the hub CLI bundle (prepack
|
|
|
1873
2022
|
|
|
1874
2023
|
Console development: `bun test/web-mock-hub.ts 4311` fakes a hub with fixtures (serves `web/dist`), and
|
|
1875
2024
|
`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
|
-
|
|
2025
|
+
`npm run test:e2e` (scripts/test-e2e.sh) runs every headless Chromium suite, each alone: the smoke test against the
|
|
2026
|
+
mock hub at desktop and phone sizes (with the nav, profiles, workspace, Watchtower, team editor and artifact suites
|
|
2027
|
+
it chains), then the real-hub suites `web/e2e/artifact-host.ts`, `web/e2e/artifact-bridge.ts` and
|
|
2028
|
+
`web/e2e/hwa-workflow.ts` (two simulated machines: side chat launch and Send to main, a declared action from an
|
|
2029
|
+
embedded app). Build `web/dist` first; `CHROMIUM_PATH` if Playwright's browsers are not installed; screenshots go to
|
|
2030
|
+
the temp dir. CI does not run them (no Chromium there): `scripts/test-ci.sh` runs the bun tests.
|
|
1878
2031
|
|
|
1879
2032
|
`test/e2e-phase2.test.ts` does the same for messaging: the OpenCode spoke (with `deliver`) and a spoke without it,
|
|
1880
2033
|
`hub msg`/`ask`/`mute` as session tokens, replies through the `hub` tool's code path, an app token with the
|
|
@@ -1900,7 +2053,9 @@ second user. The opt-in `HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.tes
|
|
|
1900
2053
|
`opencode serve` (own port, password and XDG dirs) with a local stub model and checks a real shell: its
|
|
1901
2054
|
`HUB_SESSION_TOKEN`, and that `hub whoami` there prints the session. `HUB_IT_OPENCODE=1 bun test
|
|
1902
2055
|
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.
|
|
2056
|
+
message id is applied once, `wake: false` waits for the next turn, and inbox deliveries carry their origin.
|
|
2057
|
+
`HUB_IT_OPENCODE=1 bun test test/e2e-quiet-delivery.it.test.ts` sends `wake: false` messages through a real hub to an
|
|
2058
|
+
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
2059
|
that the hub sees the session's token while the model and the transcript never do. `HUB_IT_OPENCODE=1 bun test
|
|
1905
2060
|
test/spoke-prompt-refs.it.test.ts` checks that a prompt naming a session reaches the model unchanged with the
|
|
1906
2061
|
reference note attached, and that hub-sourced prompts get none.
|
|
@@ -1914,6 +2069,13 @@ with a throwaway `HOME`, `CLAUDE_CONFIG_DIR` / `CODEX_HOME` and a deny-all proxy
|
|
|
1914
2069
|
`bun packages/spoke-sdk/scaffold.ts <name> <dir>`, whose output passes `npx hub-conformance spoke --driver "bun
|
|
1915
2070
|
<dir>/driver.ts"` as written ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
1916
2071
|
|
|
2072
|
+
`test/e2e-hwa-*.test.ts` (part of `npm test`) run workspaces, profiles, side chats, artifacts, AI jobs, teams and the
|
|
2073
|
+
Watchtower graph end to end across two simulated machines (`test/e2e-hwa-kit.ts`: each a node plus a service spoke,
|
|
2074
|
+
real launch engine): a workspace bound on both, supervision, board, digests and checkpoints, disconnect/reconnect,
|
|
2075
|
+
master replacement, archive and a restart that keeps everything; profile acks; jobs handled on the other machine;
|
|
2076
|
+
teams through `POST /mcp`; load caps; a record from a newer hub refused, never overwritten; and a tenant matrix
|
|
2077
|
+
that sends every parameterised route of these features as another user (answers like an unknown ID).
|
|
2078
|
+
|
|
1917
2079
|
Point a project's `.opencode/opencode.jsonc` at your checkout to try changes:
|
|
1918
2080
|
`{ "plugins": [{ "package": "/path/to/hub" }] }`. If the plugin is also installed globally,
|
|
1919
2081
|
OpenCode keeps the first copy with the same id, so test with `OPENCODE_CONFIG_DIR` pointing at a copy of
|
|
@@ -1944,6 +2106,42 @@ workflow). Publish it by hand after `@huynle/hub` of the same version is on npm:
|
|
|
1944
2106
|
`@huynle/opencode-hub` was never published, so it has no shim. A new package name needs one manual first
|
|
1945
2107
|
publish before trusted publishing can take over (see the comment in `release.yml`).
|
|
1946
2108
|
|
|
2109
|
+
### Hub state dir: upgrades and service units
|
|
2110
|
+
|
|
2111
|
+
A dir counts as the hub state dir only when it holds hub state (`state.json`, `token-keys.json`, `agent/`, `blobs/`,
|
|
2112
|
+
`concierge-sessions.json`, `bin/`, `hub.log`, `hub.pid`, `claude-code/` or `codex/`); `hubctl setup`'s `bootstrap/`
|
|
2113
|
+
alone does not count, and setup writes `bootstrap/` into the dir the hub resolves. A relative `XDG_STATE_HOME` is
|
|
2114
|
+
ignored (the XDG spec allows only absolute paths). Nothing moves on upgrade: with
|
|
2115
|
+
`XDG_STATE_HOME` set and an existing `~/.local/state/hub`, the hub keeps that one and logs the `mv` command to switch.
|
|
2116
|
+
|
|
2117
|
+
**Service units must see the same `XDG_STATE_HOME` as your shell** (or set the same `HUB_STATE_DIR`): the resolver
|
|
2118
|
+
runs in every process, so a unit without `XDG_STATE_HOME` next to a shell that has it can pick a different dir on a
|
|
2119
|
+
fresh install (two `hub.pid` locks, two Concierge lock files, launchers in one dir and logs in the other). The units
|
|
2120
|
+
hub writes itself (`hub claude-code install`, `hub codex-spoke install`) already pass `XDG_STATE_HOME` and
|
|
2121
|
+
`HUB_STATE_DIR` through. A unit you write yourself, such as one running `opencode serve --service` (the OpenCode
|
|
2122
|
+
background service, which loads the hub plugin), must set `Environment=XDG_STATE_HOME=…` (systemd) or the plist's
|
|
2123
|
+
`EnvironmentVariables` (launchd) to your shell's value, or `HUB_STATE_DIR`.
|
|
2124
|
+
|
|
2125
|
+
### Testing and preview isolation
|
|
2126
|
+
|
|
2127
|
+
A test or preview OpenCode with the hub plugin shares the user's files unless told otherwise: its spoke may
|
|
2128
|
+
auto-start a hub, write the `hub`/`hubctl` launchers, the Concierge lock file and `hub.log`, and read Herdr. Keep it
|
|
2129
|
+
apart:
|
|
2130
|
+
|
|
2131
|
+
- **State**: an isolated instance MUST set `HUB_STATE_DIR` to a scratch directory and its own `HOME` (plus its own
|
|
2132
|
+
`XDG_CONFIG_HOME` / `XDG_STATE_HOME` / `XDG_DATA_HOME` / `XDG_CACHE_HOME`). `XDG_STATE_HOME` alone is not enough:
|
|
2133
|
+
the state dir is sticky, so with the real `~/.local/state/hub` present it is used even when `XDG_STATE_HOME` points
|
|
2134
|
+
elsewhere. With `HUB_STATE_DIR` set the plugin never links `~/.local/bin/hub` and migrates nothing. With `HUB_STATE_DIR` set,
|
|
2135
|
+
the Concierge lock is read and written only there: an isolated instance never reads the user's
|
|
2136
|
+
`~/.local/state/hub` or `$XDG_STATE_HOME/hub`.
|
|
2137
|
+
- **Spoke config** (`config.json` in its own `XDG_CONFIG_HOME/hub`, or `HUB_SPOKE_CONFIG`): `"herdr": false` (no
|
|
2138
|
+
reading the user's Herdr panes), `"autoStartLocalHub": false` (no hub started on a loopback URL that is down), and
|
|
2139
|
+
the test hub's URL.
|
|
2140
|
+
- **Sessions**: create them only in scratch repositories (a temp dir with `git init`), never in a real project, so
|
|
2141
|
+
a stray session, launch or worktree cannot land in your work.
|
|
2142
|
+
- Never restart the user's `opencode serve --service`; start a private `opencode serve` on its own port and
|
|
2143
|
+
password (as the `*.it.test.ts` suites do).
|
|
2144
|
+
|
|
1947
2145
|
## Known limits
|
|
1948
2146
|
|
|
1949
2147
|
- Speech-to-text is batch per utterance (on-device voice detection + one request per utterance); no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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.1",
|
|
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>()
|