@huynle/hub 0.6.1 → 0.7.2
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 +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,875 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.7.2
|
|
6
|
+
|
|
7
|
+
First release with signed standalone `hubctl` binaries (`hubctl-<target>`, `hubctl-manifest.json` signed by `hub-release-1`, `HUBCTL_SHA256SUMS`) and pinned OpenCode 2.2.0 for `hubctl setup`.
|
|
8
|
+
|
|
9
|
+
- **`hub login` with a device code**: without `--token`, `hub login [--url URL] [--scope S]... [--name N]` prints an
|
|
10
|
+
approval link and code; you approve it in the console as yourself (`/approve`: "CLI login for <name>", every
|
|
11
|
+
scope as a checkbox, the requested ones ticked) and get an app token with the scopes you chose (default
|
|
12
|
+
`sessions:read`, `messages:send`, `messages:read`; `--scope sessions:launch` or ticking it for launching, which
|
|
13
|
+
also needs a grant with verb `launch`, as the output says). Stored like `--token` (owner-only, now written
|
|
14
|
+
atomically); the URL defaults to this machine's enrollment. `--token` and a piped token still work. New hub routes:
|
|
15
|
+
`POST /cli/login`, `/cli/login/poll`, `GET /api/cli-logins/:code`, `POST …/approve|deny` (devices only; single-use,
|
|
16
|
+
10-minute codes; audited `cli-login.approve|deny`, never the token).
|
|
17
|
+
- On an enrolled machine without a user login, `hub launch`, `whoami` and the other user commands now say that the
|
|
18
|
+
machine enrollment (`mch_…`) is not a user login and to run `hub login`; a forbidden `hub launch` with an app token
|
|
19
|
+
says it needs a grant with verb `launch`.
|
|
20
|
+
- **`hub` on PATH for plugin-only installs**: the OpenCode plugin links `~/.local/bin/hub` to its launcher (only when
|
|
21
|
+
nothing of yours is there and no other `hub` is on PATH; the setup's `hub` → `hubctl` link is kept), with one hint
|
|
22
|
+
when `~/.local/bin` is not on PATH.
|
|
23
|
+
|
|
24
|
+
- **hubctl bootstrap**: `hubctl machine enroll|status` and `hubctl setup discover|preview|apply|verify|drift|
|
|
25
|
+
rollback|recover|ansible` (also as `hub …`; the program name grants nothing). A standalone hubctl enrolls the
|
|
26
|
+
machine with the device code, previews a deterministic typed plan (writes nothing; `--out` keeps it for apply),
|
|
27
|
+
and applies exactly that plan after rediscovering and re-planning: pinned OpenCode, Claude Code and Codex installs
|
|
28
|
+
from the signed release manifest (`--manifest`), lossless config edits, hub's user services (loaded only with
|
|
29
|
+
`--confirm-activation`), an optional bounded verification launch (`--verify-launch`), all journaled and undone
|
|
30
|
+
with `setup rollback`. Readiness comes from real probes (`setup verify`, exit 15 when not ready). `--json` prints
|
|
31
|
+
one `hub.bootstrap.result` envelope; exits 10–15 are new (docs/bootstrap-contracts.md §3.11).
|
|
32
|
+
- **hubctl bootstrap, install is not readiness (contract v1.2)**: `setup apply` on a fresh host no longer rolls
|
|
33
|
+
back because you have not logged in to a provider or started the harness yet (which needs the installed harness).
|
|
34
|
+
Readiness probes are classified: a broken install (missing or wrong binary, version mismatch, container shim, a
|
|
35
|
+
binary that does not run) still rolls back; a harness that only waits on you is committed as "installed, not
|
|
36
|
+
ready", with each probe's status and the next steps (`--json`: `data.harnesses`). `--require-ready` keeps the
|
|
37
|
+
strict behaviour (exit 15, rolled back); `setup verify` stays the strict gate; `--verify-launch` keeps its
|
|
38
|
+
harness strict. A rollback now also removes the root directories the apply created (e.g. `~/.local/bin`), and a
|
|
39
|
+
directory hub created that something else wrote into is kept as a note, not a conflict. The Ansible role passes
|
|
40
|
+
`--require-ready` only with `hub_bootstrap_apply_require_ready` and gates readiness at its verify step.
|
|
41
|
+
- **hubctl bootstrap, HBS-12 acceptance fixes**: `machine enroll` turns sharing on (`mode: "always"`) when
|
|
42
|
+
`config.json` has no mode, so the OpenCode spoke of a hubctl-enrolled machine connects (an explicit `"off"` is kept
|
|
43
|
+
and reported); a rerun on a converged host plans only verification (the installed hubctl is compared by digest, not
|
|
44
|
+
path); a hub-installed OpenCode, Claude Code or Codex is linked into `~/.local/bin` when no other one is on PATH
|
|
45
|
+
(with a hint when `~/.local/bin` is not on PATH); the OpenCode probes retry once when a cold OpenCode service has
|
|
46
|
+
not finished activating its plugins (HTTP 503); and the plugin no longer loads `@opencode/plugin`'s runtime, so it
|
|
47
|
+
activates faster on a cold start. Units for a standalone hubctl no longer set `BUN_BE_BUN=1` (it made the compiled
|
|
48
|
+
binary act as the bun CLI and crash-loop), and `--confirm-activation` now checks that each activated service stays
|
|
49
|
+
running: a failed or crash-looping service fails the apply with `service_failed` (exit 15), is unloaded and rolled
|
|
50
|
+
back, and the message points at `journalctl --user -u <unit>` or the agent's log.
|
|
51
|
+
- **Hub API**: `GET /spoke/presence` (machine token only, read-only) lists which harness spokes of the caller's own
|
|
52
|
+
machine are connected (harness, evidence, features), for `hubctl setup verify`.
|
|
53
|
+
- `hub node install`, `hub claude-code install` and `hub codex-spoke install` render their units from templates
|
|
54
|
+
embedded in the program (so a standalone hubctl can install them); a standalone hubctl's units start the binary
|
|
55
|
+
itself. The hints for an unenrolled machine now say `hubctl machine enroll`.
|
|
56
|
+
- The `hub`/`hubctl` launchers run `~/.local/bin/hubctl` when it is installed. `hub claude`, the Claude Code daemon
|
|
57
|
+
and the node run the Claude Code `hubctl setup` installed (HUB_CLAUDE_BIN, else hub's `claude-code.json`
|
|
58
|
+
`binary`, else PATH); `hub codex`, the Codex spoke and its unit the hub-installed Codex (HUB_CODEX_BIN, else
|
|
59
|
+
`~/.local/share/hub/harnesses/codex/current/bin/codex`, else PATH). The node's host runtime now offers `codex`
|
|
60
|
+
when the Codex CLI is found.
|
|
61
|
+
|
|
62
|
+
- **Archive through MCP**: `archive` and `unarchive` (and the narrow agent API
|
|
63
|
+
`PUT /api/agent/sessions/:addr/archive`) change the Hub's archived state without deleting a harness session.
|
|
64
|
+
App tokens use the new explicit `sessions:manage` scope, selectable in Settings → Agents. Existing tokens do
|
|
65
|
+
not gain the scope; operations are idempotent and preserve unrelated view preferences.
|
|
66
|
+
|
|
67
|
+
- Add App token management to Settings → Agents: create scoped tokens, copy the one-time bearer secret and grant-holder ID, list existing tokens and revoke them.
|
|
68
|
+
|
|
69
|
+
- **MCP over HTTP**: the hub serves `hub mcp`'s tools at `POST /mcp` (MCP Streamable HTTP, stateless JSON
|
|
70
|
+
responses) for MCP clients and gateways. Auth is `Authorization: Bearer <token>` (an app token with the tools'
|
|
71
|
+
scopes; device, session and act tokens too); a missing or bad token answers `401` with `WWW-Authenticate: Bearer`.
|
|
72
|
+
The tools run as that token through the agent API in process, so scopes, grants, tenancy and the audit apply.
|
|
73
|
+
Packages now ship `packages/cli/src` (the image copies it too).
|
|
74
|
+
|
|
75
|
+
## 0.7.1
|
|
76
|
+
|
|
77
|
+
- **Release**: the npm package is published again. 0.7.0's publish step had no Bun for the CLI bundle, so 0.7.0 is
|
|
78
|
+
on GitHub and ghcr.io but not on npm; 0.7.1 is the same code.
|
|
79
|
+
|
|
80
|
+
## 0.7.0
|
|
81
|
+
|
|
82
|
+
The agent control plane: sessions get addresses and talk to each other, ask you for Decisions, act for you only
|
|
83
|
+
within grants you give, and can be launched on your machines, in sandboxes, or across a cluster of hubs. Hub also
|
|
84
|
+
speaks to Claude Code, Codex and terminal tools, not only OpenCode. Upgrading keeps every paired device and enrolled
|
|
85
|
+
machine; the new data lives in `agent/` next to `state.json`, which 0.6.x ignores if you roll back.
|
|
86
|
+
|
|
87
|
+
- **Agent identity**: `@name`, `@@role` and `@me` addresses, the `hub` CLI for agents, app tokens, session tokens.
|
|
88
|
+
- **Agent messaging**: `msg`, `ask`, `reply`, `decline`, inboxes and Decisions (`ask @me`), the OpenCode `hub` tool,
|
|
89
|
+
`/who` and `/msg` in the TUI, `hub mcp`.
|
|
90
|
+
- **Agent actions**: grants, approve/deny/answer/prompt/stop for agents within them, escalation to Decisions, kill
|
|
91
|
+
switches, the agent audit (Settings → Agents).
|
|
92
|
+
- **Launches**: `hub launch` and `hub node`, worktree/checkout/clone workspaces, placement, signed webhooks.
|
|
93
|
+
- **Sandboxed launches** in OpenShell sandboxes.
|
|
94
|
+
- **More harnesses**: `hub claude` (Claude Code), `hub codex` (Codex), presets for other terminal tools, the spoke SDK
|
|
95
|
+
and conformance suite.
|
|
96
|
+
- **Cluster mode**: several hubs on Postgres and Valkey, `hub-server migrate` and `import`, draining, `/readyz`,
|
|
97
|
+
`deploy/k8s`.
|
|
98
|
+
|
|
99
|
+
The details of each part follow.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
### Agent identity
|
|
103
|
+
|
|
104
|
+
Agents, scripts and apps get their own identity on the hub: every session's shells carry a session token, `hub`
|
|
105
|
+
(the CLI) reads and names sessions through the new `/api/agent` API, and apps link with a code. The parts, from
|
|
106
|
+
the protocol up:
|
|
107
|
+
|
|
108
|
+
- **Harness-neutral spoke protocol** (still version 1, compatible both ways). The hello may carry `harness`,
|
|
109
|
+
`harnessVersion` and `evidence` (`native` | `hooks` | `rpc` | `screen`; anything else counts as `unknown`); the
|
|
110
|
+
OpenCode spoke sends `harness: "opencode"`, `evidence: "native"` and keeps `opencodeVersion` for older hubs. A
|
|
111
|
+
hello without `harness` is an OpenCode spoke with the implied v1 features (`IMPLIED_OPENCODE_FEATURES`); new
|
|
112
|
+
spokes announce all their features (`SPOKE_FEATURES`). The welcome lists the hub's features (`HUB_FEATURES`,
|
|
113
|
+
now `["harness"]`) and spokes remember them (`hubSupports`). `/api/machines` processes gain `harness`,
|
|
114
|
+
`harnessVersion` and `evidence`.
|
|
115
|
+
- **Device tokens, agent API framework, discovery**: every `/api` request now resolves its caller through one
|
|
116
|
+
credential resolver. Besides the `voice_device` cookie, the same device secret works as
|
|
117
|
+
`Authorization: Bearer <device token>` (for native apps and CLIs) with the same powers and without the
|
|
118
|
+
same-origin check; the cookie together with a hub bearer token is refused (400 `invalid`), while a bearer the hub does not know (e.g. from an auth proxy) next to the cookie is ignored. New `/api/agent/*` routes answer
|
|
119
|
+
errors as `{error: {code, message, hint?}}` (`unauthorized`, `token_expired`, `forbidden`, `not_found`, …)
|
|
120
|
+
and start with `GET /api/agent/whoami` (`{actor, user}`). `GET /.well-known/hub` (public) answers
|
|
121
|
+
`{publicURL, version, minClientVersion, auth: {modes, oidc?}, features}`. Existing routes keep their error
|
|
122
|
+
format.
|
|
123
|
+
- **Link native apps**: Settings → Devices → **Add app** shows a QR code and a `hubapp://link?hub=…&code=…`
|
|
124
|
+
link with a 5-minute countdown; the list updates once the app has linked. `POST /api/devices/link` issues
|
|
125
|
+
the single-use 256-bit link code (not valid at `/login` or `/api/pair`); `POST /api/devices/redeem {code,
|
|
126
|
+
deviceName?, publicKey?}` (public, works without `Origin` when the request carries no credential at all)
|
|
127
|
+
answers `{device, token}`: a device token for `Authorization: Bearer`, no cookie. An optional device public
|
|
128
|
+
key is stored for later strong approvals. The console's QR encoder is built in (no new dependency).
|
|
129
|
+
- **Session tokens**: the hub mints a token for every session (subagents included) a spoke reports for its own
|
|
130
|
+
user, and sends it with the `sessionTokens` op to spokes that announce the feature: on each sessions frame
|
|
131
|
+
(new sessions, tokens with less than a third of their 24 h left, or after a key or epoch change), again in
|
|
132
|
+
full after a reconnect, and from a 10-minute refresh sweep. Format `hst1.<payload>.<HMAC-SHA256>` (claims
|
|
133
|
+
`typ, uid, mid, sid, exp, kid, ue, me, aud`), signed with the keys in `<HUB_STATE_DIR>/token-keys.json` or
|
|
134
|
+
`HUB_TOKEN_KEYS`. As `Authorization: Bearer`, a session token is a session actor on `/api/agent/*` routes
|
|
135
|
+
that admit sessions (`whoami`), `403` on console routes, `401 token_expired` once expired and `401` once the
|
|
136
|
+
session is no longer reported by that machine, the machine is revoked or the user disabled (both bump the
|
|
137
|
+
token epoch, so re-enabling does not revive old tokens). Tokens are never logged or audited. A session's
|
|
138
|
+
token is visible to its shell and child processes; it is limited by its expiry, the session's liveness and
|
|
139
|
+
the verbs sessions may use.
|
|
140
|
+
- **Session tokens reach the spoke** (protocol still version 1): new server op `sessionTokens{tokens: [{sessionID,
|
|
141
|
+
token, exp}], hubURL?}` (at most 500 tokens per frame; `batchSessionTokens` splits a list under `MAX_FRAME`),
|
|
142
|
+
answered with `{applied, skipped: [{sessionID, reason: "unknown" | "expired"}]}`. It is the spoke feature
|
|
143
|
+
`sessionTokens`, announced by new spokes only (not implied for older OpenCode spokes), so hubs send it only to
|
|
144
|
+
spokes that list it. The OpenCode spoke keeps each reported session's latest token in memory (never logged,
|
|
145
|
+
dropped when expired or when the connection changes) and adopts no session for it.
|
|
146
|
+
- **Session identity in shells**: every shell an agent runs through OpenCode's shell tool gets its session's
|
|
147
|
+
`HUB_SESSION_TOKEN` and `HUB_URL` (the spoke's server), set by plugin hooks (tool `execute.before` paired with
|
|
148
|
+
shell `create.before` by command; the same command pending from two sessions gets no token). Inherited
|
|
149
|
+
`HUB_URL`/`HUB_SESSION_TOKEN` are removed from the OpenCode process at plugin start and from every shell, and the
|
|
150
|
+
hub bin dir (`$HUB_STATE_DIR/bin`, default `~/.local/state/hub/bin`) goes first on PATH. A deleted session's token
|
|
151
|
+
is dropped. User `!` commands get no token. Opt-in integration test: `HUB_IT_OPENCODE=1 bun test
|
|
152
|
+
test/spoke-env-inject.it.test.ts`.
|
|
153
|
+
- **App tokens**: a signed-in user (device cookie or device token) creates app/agent tokens with
|
|
154
|
+
`POST /api/agent/app-tokens {label?, scopes?}` → `201 {id, token, label?, scopes, created}` (the token,
|
|
155
|
+
`hat_<id>_<secret>`, is shown only here), lists them with `GET /api/agent/app-tokens` → `{tokens}` (no secrets)
|
|
156
|
+
and revokes one with `DELETE /api/agent/app-tokens/:id` → `204`. Labels ≤ 100 characters; scopes from
|
|
157
|
+
`sessions:read` (the default); at most 50 per user (`429 rate_limited`). Only sha256 hashes are stored. An app
|
|
158
|
+
token is an app actor on `/api/agent/*` routes its scopes allow (`whoami` always); it is refused on console
|
|
159
|
+
routes and on token management (`403 forbidden`), and once revoked or its user disabled (`401`). Creation and
|
|
160
|
+
revocation are audited (`app-token.create`, `app-token.revoke`).
|
|
161
|
+
- **Session names and roles** (`@name`, `@@role`): the hub keeps a per-user record of every session its spokes
|
|
162
|
+
report (machine, parent, project, lastSeen; at most 2000). Root sessions get a name once, from their first
|
|
163
|
+
real title (OpenCode's "New session" placeholders are skipped): its first few words (at most 4 and 24
|
|
164
|
+
characters, cut at a word boundary, ID-like tokens such as `ses_…` dropped), with `-2`, `-3` on a clash.
|
|
165
|
+
`POST /api/agent/name {name, session?}` sets one (409 `name_taken` when taken); the old name stays an alias for
|
|
166
|
+
24 h; a name is released 7 days after its session is archived (the console's Archive) or deleted.
|
|
167
|
+
`POST /api/agent/roles/claim {role, exclusive?, project?, session?}` / `roles/release` manage roles
|
|
168
|
+
(`role.project` scopes; every new holder gets a new `generation`; an exclusive claim fails while another live
|
|
169
|
+
holder exists; a holder offline for 30 min loses the role, except in the 2 min after the hub starts;
|
|
170
|
+
some role names, such as `orchestrator…`, are reserved to the user).
|
|
171
|
+
`GET /api/agent/who?project=&role=` lists named, role-holding and online sessions. Session tokens may read,
|
|
172
|
+
and name or claim for their own session only.
|
|
173
|
+
Addresses (`ses_…` or a unique ID prefix, `@name[.project]`, `@@role[.project]`) resolve within the caller's
|
|
174
|
+
sessions only: another user's are `not_found`, and `ambiguous` candidates never list them.
|
|
175
|
+
- **Agent reading** (`/api/agent/*`, for users, session tokens and app tokens with `sessions:read`):
|
|
176
|
+
`GET /api/agent/status` answers the caller's snapshot `{at, you, machines: [{id, name, online, harnesses,
|
|
177
|
+
projects: [{root, name, sessions}]}], attention}`; each session is `{id, name?, roles, title, state, harness,
|
|
178
|
+
parentID?, busySince?, now?, blockers: [{kind, id, summary}], children, labels}` and `attention` lists every
|
|
179
|
+
blocker and error, oldest first. `GET /api/agent/sessions?project=&state=&machine=&label=k=v` is the same
|
|
180
|
+
sessions as a flat list (with `machineID`, `project`). Both read only the hub's catalog and never call a spoke.
|
|
181
|
+
`GET /api/agent/sessions/:addr` adds full blockers (form fields), `directory`, `online` and the spoke's
|
|
182
|
+
`summary` (`unsupported: [{feature, harness}]` instead when the spoke lacks it; `state: "offline"` for a known
|
|
183
|
+
session nobody reports). `GET /api/agent/sessions/:addr/log?n=20` (1–200) answers `{sessionID, rows, older?}`,
|
|
184
|
+
newest first, from the spoke's `timeline` (422 `unsupported {feature, harness}` without it, 503 `offline`).
|
|
185
|
+
`GET /api/agent/watch?blocked=1&session=<addr>` is an SSE stream of compact JSON events for the caller's own
|
|
186
|
+
sessions (`ready`, `state`, `blocker.opened`, `blocker.closed`, `session.added`, `session.removed`; no text),
|
|
187
|
+
with a `ping` event every 20 s. Another user's sessions, names and roles answer `404 not_found` on every route.
|
|
188
|
+
- **hub CLI** (`hub`, alias `hubctl`; private workspace `packages/cli`): `status`, `sessions [--project --state
|
|
189
|
+
--machine --label]`, `show <addr>`, `log <addr> [-n]`, `watch [--blocked] [--session]` (JSON lines), `whoami`,
|
|
190
|
+
`who [--project] [--role]`, `name <@name>`, `role claim|release <role> [--exclusive] [--session]`, `login`/`logout`.
|
|
191
|
+
Short text by default, `--json` for the raw answer; exit codes 0 ok, 1 other, 2 usage, 3 not found/ambiguous/
|
|
192
|
+
taken, 4 needs user, 5 forbidden/paused, 6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired.
|
|
193
|
+
Credentials: `HUB_URL` + `HUB_SESSION_TOKEN` first, else `~/.config/hub/cli.json` (0600) written by `hub login`
|
|
194
|
+
with an app token. Shipped as one JS bundle (`packages/cli/dist/hub-cli.js`, `npm run build:cli`, built by
|
|
195
|
+
prepack); the plugin writes `hub` and `hubctl` launchers into the hub bin dir at start that run it with the
|
|
196
|
+
OpenCode binary as bun (`BUN_BE_BUN=1`; `node` without Bun; the TypeScript source in a checkout without a build).
|
|
197
|
+
- **Tests**: `test/e2e-phase1.test.ts` (in `npm test`) runs all of it end to end in process: a hub, the OpenCode
|
|
198
|
+
spoke and a second spoke of another harness (`fake`, evidence `hooks`, no features: no tokens, `hub log`
|
|
199
|
+
unsupported), and the CLI as a session token, an app token (`hub login`), a linked device and a second user (who
|
|
200
|
+
sees nothing: exit 3), until revoking the machine ends the session token (exit 9). The opt-in
|
|
201
|
+
`HUB_IT_OPENCODE=1 bun test test/spoke-env-inject.it.test.ts` also runs `hub whoami` in a real OpenCode shell
|
|
202
|
+
against an in-process hub and checks it prints the session.
|
|
203
|
+
|
|
204
|
+
### Agent messaging
|
|
205
|
+
|
|
206
|
+
Sessions, scripts, apps and you message each other through the hub: `msg`, `ask`, `reply`, `decline`, `inbox` and
|
|
207
|
+
`mute` in the `hub` CLI, the OpenCode `hub` tool, the TUI's `/msg` and `/who`, and `hub mcp`; asks to `@me` become
|
|
208
|
+
Decisions answered in the console. Messages reach OpenCode sessions as hub messages in their conversation, in an
|
|
209
|
+
envelope that marks them as not coming from the user. See the README's *Agent messaging* section and
|
|
210
|
+
[docs/multi-user.md](docs/multi-user.md#messaging-tenancy).
|
|
211
|
+
|
|
212
|
+
- **Delivery engine**: messages go only through spokes that announce `deliver` (an older spoke or another harness
|
|
213
|
+
without it: `unsupported {feature: "deliver"}`, nothing stored; no fallback through steer). A role address picks
|
|
214
|
+
one holder (the idle one, else the most recently active); `all` sends to each. Offline recipients get the message
|
|
215
|
+
when they come back (pending for 24 h); an open ask to an idle recipient is re-woken after 5, 15 and 45 minutes,
|
|
216
|
+
then the user is notified. Messages, asks, pending deliveries, Decisions and mutes live under
|
|
217
|
+
`<HUB_STATE_DIR>/agent/` (one file per user and collection, 0600), not in `state.json`.
|
|
218
|
+
- **Envelope**: `[hub] <kind> from <sender> <to> · <time> · <id>`, the body between two boundary lines carrying the
|
|
219
|
+
message id, and a reply hint. Body lines that look like a header, boundary or hint are escaped; control, ANSI,
|
|
220
|
+
bidi and zero-width characters are stripped from the text and from names.
|
|
221
|
+
- **Identity primer**: once per session (when it is first named explicitly or gets its first message), a short
|
|
222
|
+
note without a turn: who the session is, how to message others, and that `[hub]` messages are not the user's.
|
|
223
|
+
- **Loop protection**: per 10 minutes, 20 messages per sender→recipient pair and 300 per user (`429
|
|
224
|
+
rate_limited` with `retryAfter`), 30 turns started per recipient (then delivered without waking); 8 hops of
|
|
225
|
+
agent-to-agent replies without a human turn (then delivered without waking, the user notified once). Mutes
|
|
226
|
+
refuse a sender (`403 forbidden`); the per-user messaging switch refuses all sends (`423 paused`).
|
|
227
|
+
- **The name `me` is reserved**: `@me` always means the user, so no session can be named `me` (`400 invalid`) and
|
|
228
|
+
a title like "Me" derives `me-2`.
|
|
229
|
+
- **CLI messaging verbs**: `hub msg`, `hub ask [--wait] [--expires]`, `hub reply`, `hub decline --why`, `hub
|
|
230
|
+
inbox`, `hub mute` / `unmute`, with `--all`, `--no-wake`, `--urgent` and `-` for text on stdin. Exit codes: 5
|
|
231
|
+
`forbidden`/`paused`, 6 `rate_limited`, 7 `unsupported`, 8 `gone` (or an expired ask); a waiting ask exits 1
|
|
232
|
+
when declined or not answered in time.
|
|
233
|
+
- **`hub mcp`**: a Model Context Protocol server on stdio (newline-delimited JSON-RPC; `initialize`, `ping`,
|
|
234
|
+
`tools/list`, `tools/call`) whose tools are the CLI's verbs, acting as the CLI's credential (in practice the
|
|
235
|
+
app token from `hub login`). Only protocol frames go to stdout.
|
|
236
|
+
- **Console message rows**: hub messages show in a session's conversation, "from @y" for received ones (from the
|
|
237
|
+
spoke's timeline) and "to @@r (→ @x)" for sent ones (from the hub, `GET /api/sessions/:session/messages` and SSE
|
|
238
|
+
`message` events), merged by time; the primer folds into one line.
|
|
239
|
+
- **OpenCode `hub` tool**: the plugin adds one tool `hub {verb, …}` (verbs `msg`, `ask`, `reply`, `decline`,
|
|
240
|
+
`inbox`, `who`, `status`, `whoami`; JSON Schema input, not in code mode) that calls `/api/agent` through the SDK
|
|
241
|
+
as the calling session, with that session's own token (never in the tool's output or metadata; redacted should
|
|
242
|
+
a hub echo it). Answers and inbox items show in the hub's sanitised envelope; the description and results say
|
|
243
|
+
messages from other sessions are not from the user. `ask` waits at most 10 minutes (`wait` seconds) and stops
|
|
244
|
+
when the tool call is aborted, naming the still-open ask. Readable errors when the session has no token yet
|
|
245
|
+
or the hub lacks `messaging` (messaging verbs only). Opt-in check: `HUB_IT_OPENCODE=1 bun test
|
|
246
|
+
test/spoke-hub-tool.it.test.ts`.
|
|
247
|
+
- **TUI `/who` and `/msg`**: `/who [project | @@role]` lists the hub's named and role-holding sessions (name,
|
|
248
|
+
project, state, online, roles) in a dialog; `/msg @name text` (also `@@role`, `@name.project`, `ses_…`, `@me`)
|
|
249
|
+
sends the text word for word as the session on screen, with that session's own hub token, and toasts where it
|
|
250
|
+
went (`Sent to @bob (queued)`). `/msg` alone (or from the palette) asks for `@name text`. On the home screen
|
|
251
|
+
both say to open a session first. New RPC methods `hubWho {sessionID, project?, role?}` and `hubMsg
|
|
252
|
+
{sessionID, to, text}` (error `unsupported` when the hub lacks `messaging`); Hub commands may now take slash
|
|
253
|
+
arguments (`HubCommand.arguments`, `run(input?)`).
|
|
254
|
+
- **Prompt references**: `@name`, `@name.project`, `@@role` and `@@role.project` typed into an OpenCode prompt
|
|
255
|
+
still go to that session unchanged; the plugin's prompt hook adds one attached text file
|
|
256
|
+
(`hub-references.txt`, a `data:` URI the model sees inside the same message) listing each address that names
|
|
257
|
+
one of your sessions (name, session ID, harness, machine, project, state, your open asks to it) and saying to
|
|
258
|
+
contact it with the `hub` tool. OpenCode's own `@` meanings win (an attached file or agent mention for the same
|
|
259
|
+
`@x` is left alone), `@me` and unknown or other users' names add nothing, and hub-delivered prompts are skipped.
|
|
260
|
+
The lookup (`who`, `status`, `inbox` as the session) runs only when an address appears, gives up after 1.5 s,
|
|
261
|
+
fails open and is cached for 10 s per session. Opt-in check: `HUB_IT_OPENCODE=1 bun test
|
|
262
|
+
test/spoke-prompt-refs.it.test.ts`.
|
|
263
|
+
- **Decisions and `@me`**: `@me` addresses the user. `msg @me` is stored in the user's inbox; `ask @me` opens a
|
|
264
|
+
Decision (`dec_…`: question, `options [{id, label, safe?}]`, `recommendation {option, why?}`, `urgency`,
|
|
265
|
+
`tier` confirm|tap, `deadline` seconds, `onTimeout`, `about` addresses), answered only by the user on the console:
|
|
266
|
+
`GET /api/decisions?state=`, `GET /api/decisions/:id`, `POST /api/decisions/:id/answer {option, text?}`
|
|
267
|
+
(`tap`: the console cookie only). The first answer wins (later: `409 gone`) and goes back to the asker as the
|
|
268
|
+
user's reply; at the deadline only a `safe` `onTimeout` answers it (`answeredBy: "timeout"`), otherwise it stays
|
|
269
|
+
open and is pushed once more. Agents read their own with `GET /api/agent/decisions[/:id]`; a Decision's ask
|
|
270
|
+
cannot be replied to through `/api/agent/reply`. SDK `ask` takes the Decision fields, plus `decisions`,
|
|
271
|
+
`decision`, `answerDecision`; CLI `hub ask @me --option id=label[:safe] --recommend id --why … --urgency …
|
|
272
|
+
--deadline 2h --on-timeout id --tier … --about @x` and `hub decisions list [--state S]`.
|
|
273
|
+
- **Decision cards in the console**: open Decisions show on Home and in the session view of the asking session
|
|
274
|
+
and the sessions they are about: question, options (the recommended one marked, with why; safe ones marked),
|
|
275
|
+
urgency, a live deadline countdown with the timeout's option, and "Answer here (tap)" for `tap` Decisions.
|
|
276
|
+
Answer with an option (and an optional note) or text; an answer that lost the race says "Already answered or
|
|
277
|
+
expired". SSE `{type: "decision", decision}` goes to the user's streams whenever a Decision opens, is
|
|
278
|
+
answered (also by timeout), escalates or expires; a `decision` push carries `decisionID` and its tap opens
|
|
279
|
+
`/s/<session>?decision=<id>` (or `/?decision=…`), which marks the card.
|
|
280
|
+
- **Push kinds `decision` and `message`** (both on by default, high urgency, sent even while a device shows the
|
|
281
|
+
session, generic bodies): a Decision opened or escalated; a `msg` to `@me`, an unanswered ask after every
|
|
282
|
+
re-wake, or a message loop (these two were `question` before).
|
|
283
|
+
- **Message routes on `/api/agent`**: `POST msg {to, text, wake?, urgent?, all?}`, `POST ask {…, expiresIn?,
|
|
284
|
+
wait?}` (seconds; `wait` ≤ 60 long-polls for the answer), `POST reply {replyTo, text, wake?}`, `POST decline
|
|
285
|
+
{replyTo, why}`, `GET inbox` (`{asks, waiting, messages, muted}`), `GET messages/:id?wait=`, `POST mute` /
|
|
286
|
+
`unmute {session}`, and the per-user switch `GET`/`PUT messaging {enabled}` (users only; off: sends answer
|
|
287
|
+
`423 paused`). The sender is always the credential (body fields are ignored); a session cannot message
|
|
288
|
+
itself (`400 invalid`, also left out of a role it holds); only a message's recipient may answer it, anyone
|
|
289
|
+
else and foreign ids get `404 not_found`. The user and apps have a stored-only inbox. New app token scopes
|
|
290
|
+
`messages:send` and `messages:read`. `GET /api/agent/watch` adds `message` events (no text; users see all,
|
|
291
|
+
sessions and apps only their own). Console: `GET /api/sessions/:session/messages?limit=` lists a session's
|
|
292
|
+
sent and received messages, newest first.
|
|
293
|
+
- **SDK messaging verbs**: `msg`, `ask` (with `wait` looping over polls of at most 60 s), `message`, `reply`,
|
|
294
|
+
`decline`, `inbox`, `mute`, `unmute`.
|
|
295
|
+
- **OpenCode spoke delivers hub messages** (`deliver` announced when OpenCode has `session.synthetic`). The
|
|
296
|
+
envelope goes in as a synthetic message with metadata `{source: "hub", messageID, kind, from, to, replyTo?}`
|
|
297
|
+
(the model sees only the text) and OpenCode's client id (the hub's `msg_…` id, else a stable `msg_hub…` id
|
|
298
|
+
derived from it), so a retry is applied once. An idle session gets a turn (`turn`); a busy one gets a plain
|
|
299
|
+
message after the running turn (`queued`) and an ask into it (`steer`) unless `meta.delivery` says otherwise;
|
|
300
|
+
with a form open OpenCode holds it (`retained`). `wake: false` and subagent sessions are admitted without
|
|
301
|
+
starting a turn (`queued`, steer without resume: it arrives with the next turn, or joins one already running).
|
|
302
|
+
A repeated `messageID` returns the first result without another admission (only a retry that now asks to wake
|
|
303
|
+
a message admitted without waking is sent again). No voice model, agent or conversation changes. For hubs
|
|
304
|
+
with `messaging`, `session.inbox.delivered` carries `origin: "user"` (typed or phone prompts) or `"hub"` (a
|
|
305
|
+
delivered message) when the spoke saw the item enqueued. Opt-in check against a real `opencode serve`:
|
|
306
|
+
`HUB_IT_OPENCODE=1 bun test test/spoke-deliver.it.test.ts`.
|
|
307
|
+
- **Protocol: `deliver` op and messaging features** (still version 1). The hub admits a message into a session
|
|
308
|
+
with `deliver {sessionID, messageID, text, wake, meta: {kind, from, to, replyTo?, delivery?}}`, answered with
|
|
309
|
+
`{delivered: "steer" | "turn" | "queued" | "retained", inboxID?}`. `kind` is `msg`, `ask`, `reply`,
|
|
310
|
+
`decline`, `primer` or `decision` (`reply` and `decline` need `replyTo`); `delivery` is `queue` or `steer`;
|
|
311
|
+
`text` is at most 16000 characters, ids and addresses at most 300, and addresses may not contain control,
|
|
312
|
+
bidi or zero-width characters (`deliverProblem`, `isDeliverResult`). Spokes announce `deliver` in
|
|
313
|
+
`SPOKE_FEATURES` (old OpenCode spokes do not imply it, so the hub must check before sending); the welcome's
|
|
314
|
+
`HUB_FEATURES` gains `messaging`. `session.inbox.delivered` may carry `origin: "user" | "hub"` (same event
|
|
315
|
+
type, so older hubs keep accepting it), and timeline rows gain the `message` kind (`TimelineMessageItem`).
|
|
316
|
+
- **Tests**: `test/e2e-phase2.test.ts` runs messaging end to end in process (a spoke with and one without
|
|
317
|
+
`deliver`, the CLI as sessions, an app and two users, the `hub` tool's code path, `hub mcp` over pipes, a Decision
|
|
318
|
+
answered on the console route, mutes, the pair limit and the messaging switch). Opt-in against a real `opencode
|
|
319
|
+
serve`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase2-live.it.test.ts` (one session's model messages another).
|
|
320
|
+
- **Internal**: the pure envelope renderer, sanitiser and address parser moved into `packages/sdk` (shared by the
|
|
321
|
+
hub, the plugin and `hub mcp`); the Docker image now copies `packages/sdk/src`.
|
|
322
|
+
|
|
323
|
+
### Agent actions
|
|
324
|
+
|
|
325
|
+
Agents can now act on your sessions — approve or deny permission requests, answer forms, prompt and stop sessions
|
|
326
|
+
— within grants you create, with everything outside a grant coming back to you as a Decision, two kill switches and
|
|
327
|
+
a per-user audit. See the README's *Agent actions and grants* and
|
|
328
|
+
[docs/multi-user.md](docs/multi-user.md#grants-tenancy).
|
|
329
|
+
|
|
330
|
+
- **Grant policy**: patterns `action[:glob]` with OpenCode's wildcard semantics (`bash` is `shell`); a request is
|
|
331
|
+
allowed only when every resource matches an allow pattern, never beats allow (also on the rules an `always`
|
|
332
|
+
reply would save, which must lie inside the allow patterns), and in shell allow patterns `$(`, backticks and
|
|
333
|
+
`<`/`>` must be written literally. Never patterns also match the command without leading assignments and
|
|
334
|
+
wrappers (`sudo`, `env`, `command`, `exec`, `nohup`, `time`). Escalation tiers are the hub's: `tap` for shell,
|
|
335
|
+
`external_directory`, MCP and unknown tools and anything near a never rule, else `confirm`. Project scopes are
|
|
336
|
+
canonical absolute paths matched on path boundaries.
|
|
337
|
+
- **Grants** (`/api/agent/grants`: `POST`, `GET`, `GET`/`PUT`/`DELETE …/:id`; users only): holder `ses_…` or
|
|
338
|
+
`@name` (pinned to the session at creation), a reserved `@@orchestrator…` role (follows its holder) or
|
|
339
|
+
`app:<token id>`; `scope.projects` (1–20), `allow` verbs (`approve`, `deny`, `answer`, `prompt`, `stop`, `roles`),
|
|
340
|
+
`permissions {allow, never}`, `expires` (1 minute–30 days, default 8 h), `maxActionsPerHour` (1–1000, default
|
|
341
|
+
60; past it `429 rate_limited`), `allowAlways`. At most 50 active per user; revoked and expired ones stay listed
|
|
342
|
+
(`state`). Owner, holder and token are re-checked on every use.
|
|
343
|
+
- **hub.json `agents.never`**: patterns no agent may approve for any user, checked before every grant.
|
|
344
|
+
- **Act verbs** (`POST /api/agent/sessions/:addr/approve {requestID, always?, why?}`, `…/deny {requestID, why?}`,
|
|
345
|
+
`…/answer {formID, answer | cancel}`, `…/prompt {text, delivery?}`, `…/stop`): users on their own sessions;
|
|
346
|
+
sessions only with their act token; app tokens with the new scope `sessions:act`, under a grant. Check order:
|
|
347
|
+
tenancy → kill switches → grant → own session tree (a session never answers in its own tree) → hub never → grant
|
|
348
|
+
never → allow (else `403 needs_user` with the evaluation `{verdict, reason, tier, grants}`) → hourly cap → kill
|
|
349
|
+
switches again → spoke. Errors: `needs_user`/`forbidden` 403, `not_found` 404, `gone` 410 (answered elsewhere
|
|
350
|
+
first), `unsupported` 422, `paused` 423, `rate_limited` 429, `offline` 503.
|
|
351
|
+
- **Act tokens**: the hub mints a second token per session (`typ: "act"`, same format) and pushes it with the new
|
|
352
|
+
`actTokens` op only to spokes announcing that feature. The OpenCode spoke keeps them apart from shell tokens
|
|
353
|
+
(`actIdentity`), never in any environment or tool output. Shell tokens (`HUB_SESSION_TOKEN`) can never act
|
|
354
|
+
(`403 forbidden`).
|
|
355
|
+
- **Escalations**: an approval outside the grant opens one Decision per agent and request (Approve once / Approve
|
|
356
|
+
always / Deny, the agent's `why` as its recommendation, the hub's tier); asking again updates its reason. The
|
|
357
|
+
user's answer replies to the permission as the user (no grant or kill switch applies), audited as the user's. A
|
|
358
|
+
request answered elsewhere or ended closes the Decision (new Decision state `closed`; the console drops it from
|
|
359
|
+
its open list) and the agent's retry is `not_found`. The request's blocker shows the recommendation, and so does
|
|
360
|
+
the escalation push (fetched by the service worker); permissions escalated to `tap` Decisions get no
|
|
361
|
+
Allow/Reject in the notification. Limits: 20 new escalations per agent an hour, 20 open per user; pushes within
|
|
362
|
+
a minute coalesce. Decisions gain actions run on the answer (permission and grant kinds), quiet opens and
|
|
363
|
+
revisions.
|
|
364
|
+
- **Grant requests**: `POST /api/agent/grants/requests {grant, why}` from a session (any token) or an app token with
|
|
365
|
+
`messages:send` opens a tap Decision summarising the grant; approving creates it as the user's (holder pinned to
|
|
366
|
+
the requester, or a reserved role it holds) and the requester gets the grant id in a reply. One open request per
|
|
367
|
+
requester, 5 an hour.
|
|
368
|
+
- **Hub prompts with provenance**: an agent's prompt is a `deliver` of kind `prompt` naming the sender and the grant
|
|
369
|
+
(spoke feature `hubPrompt`): the spoke wraps it in `[hub] prompt from <sender> · grant <id> · <msg id>` with
|
|
370
|
+
boundary lines and admits it as the hub's (inbox origin `hub`), never as a human turn, so it does not reset the
|
|
371
|
+
hop chain. Agents prompt root sessions only, never themselves, text only, always waking. The user's prompt stays a
|
|
372
|
+
human turn.
|
|
373
|
+
- **Deny reasons**: a reject carries an optional `message` (spoke feature `permissionMessage`); the OpenCode spoke
|
|
374
|
+
passes it to OpenCode as the rejection's feedback, prefixed with who denied it and under which grant. Older
|
|
375
|
+
spokes take the reject without it (`reasonSent: false`).
|
|
376
|
+
- **Kill switches**: the user's `GET`/`PUT /api/agent/acting {enabled}` and the admin's hub-wide
|
|
377
|
+
`GET`/`PUT /api/admin/agents/acting {enabled}`; either pauses every agent action (`423 paused`) while reading,
|
|
378
|
+
messaging and the user's own actions keep working.
|
|
379
|
+
- **Agent audit**: every agent action and attempt (decision `allow`, `needs_user`, `forbidden`, `paused`,
|
|
380
|
+
`rate_limited`; outcome `ok`, `gone`, `offline`, `unsupported`, `escalated`, `refused`, `failed`), switch changes,
|
|
381
|
+
escalation answers and grant requests, in a per-user ring of 2000: `GET /api/agent/audit?limit=&before=` (users
|
|
382
|
+
only) and SSE `{type: "agent", entry}` to the owner. Admins get summaries only (`agent.<verb>`, the actor kind).
|
|
383
|
+
- **Clients**: SDK act verbs (`approve`, `deny`, `answer`, `cancelForm`, `prompt`, `stop`), grants (`grants`,
|
|
384
|
+
`grant`, `createGrant`, `updateGrant`, `revokeGrant`, `requestGrant`) and `needsUserOf`; CLI `hub approve | deny |
|
|
385
|
+
answer | prompt | stop` (needs user: exit 4 with the evaluation and the Decision), `hub grant list | show | create |
|
|
386
|
+
revoke | request`, `hub decisions list --state closed`; `hub mcp` tools `approve`, `deny`, `answer`, `prompt`,
|
|
387
|
+
`stop`; the OpenCode `hub` tool's act verbs (with the session's act token; `needs_user` reads as "the user
|
|
388
|
+
decides, do not retry").
|
|
389
|
+
- **Console**: Settings → Agents lists grants (state, holder, projects, verbs, never patterns, expiry countdown)
|
|
390
|
+
with create, edit and revoke, the acting switch (and the hub-wide one for admins) and an Activity tab (the agent
|
|
391
|
+
audit, paged). Session views show agents' recommendations on blockers, who answered a Decision, and escalation
|
|
392
|
+
details on Decision cards.
|
|
393
|
+
- **Tests**: `test/e2e-phase3.test.ts` runs it end to end in process (a real spoke with `deliver`, `actTokens`,
|
|
394
|
+
`hubPrompt` and `permissionMessage` and a raw spoke without; the holder acting through the `hub` tool's code path
|
|
395
|
+
and an app token through the CLI; shell tokens, never rules, the own-tree ban, escalation and the user answering
|
|
396
|
+
first, kill switches, the hourly cap, prompts, deny reasons, grant requests and the audit), and
|
|
397
|
+
`test/central-tenancy.test.ts` covers every phase-3 route and CLI verb across users. Opt-in against a real
|
|
398
|
+
`opencode serve`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase3-live.it.test.ts` (one session's model approves
|
|
399
|
+
another's shell permission through the `hub` tool) and `test/spoke-acting.it.test.ts` (hub prompts, deny reasons).
|
|
400
|
+
|
|
401
|
+
### Launches
|
|
402
|
+
|
|
403
|
+
You, a script or an app can now start a session on one of your machines and have it end by itself: a `hub node`
|
|
404
|
+
daemon prepares a git worktree (or a checkout or a clone), the machine's OpenCode service creates the session in it,
|
|
405
|
+
and the hub names it, gives it roles, labels and a first prompt, watches it to its end and tells apps through signed
|
|
406
|
+
webhooks. See [docs/launches.md](docs/launches.md) and [docs/multi-user.md](docs/multi-user.md#launch-tenancy).
|
|
407
|
+
|
|
408
|
+
- **Protocol** (still version 1): a node hello on `/spoke/ws` (role `node`: runtimes, labels, capacity and use,
|
|
409
|
+
workspace roots, draining, the launches it holds), node ops `prepare`, `collect`, `cleanup` and `cancel` with
|
|
410
|
+
`progress` and `capacity` frames, the launch spec and launch view types, and the spoke feature `launch` (a create
|
|
411
|
+
op carrying `launchID`, `agent` and `model`). Hub features `nodes` and `launches` are announced only by hubs that
|
|
412
|
+
serve them; the webhook signature format (`Hub-Signature: t=…,v1=…`, `Hub-Event`, `Hub-Delivery`) lives in the
|
|
413
|
+
protocol package.
|
|
414
|
+
- **Launch spec and store**: `POST /api/agent/launches` bodies are validated strictly (unknown fields refused; git
|
|
415
|
+
branches per `git check-ref-format` with no option-like parts; https/ssh remotes only, without credentials;
|
|
416
|
+
absolute repo paths; bounded name, roles, prompt, labels and timeout) with defaults filled in (harness
|
|
417
|
+
`opencode`, mode `worktree`, runtime `host`, end `idle`, cleanup `keep`). Launches are stored per user with a
|
|
418
|
+
state machine `queued → placing → provisioning → running → ended{idle, needs_user, failed, timeout, stopped,
|
|
419
|
+
lost}`, a bounded step history and idempotency keys (same key and body: the same launch; another body:
|
|
420
|
+
`400 invalid`); ended launches are pruned after 7 days (at most 500 kept).
|
|
421
|
+
- **Verb `launch` and scope `sessions:launch`**: grants may allow `launch` for their projects; app tokens may carry
|
|
422
|
+
`sessions:launch`.
|
|
423
|
+
- **Nodes and placement**: the hub keeps a registry of connected `hub node` daemons per machine owner and places
|
|
424
|
+
each launch on one of the user's own nodes (runtime, harness, workspace root, a service spoke announcing
|
|
425
|
+
`launch`, `placement.machine` and labels as `require` or `prefer`, free slots), oldest first, under a per-user
|
|
426
|
+
lease so several hub processes never place the same launch. Per-user caps in `hub.json agents.launches`
|
|
427
|
+
(`maxQueued` 50, `maxActive` 4, `perHour` 60 by default).
|
|
428
|
+
- **Spoke launch creates**: the OpenCode service spoke announces `launch` and creates a launch's session (with its
|
|
429
|
+
agent and model) only in a directory under the node's workspace roots, which it reads from `node.json` beside its
|
|
430
|
+
config.
|
|
431
|
+
- **`hub node`** (packages `@huynle/hub-node` and `@huynle/hub-runtime-sdk`): the per-machine daemon, configured by
|
|
432
|
+
`node.json` (`workspaceRoots`, `labels`, `slots`, `memoryMB`) and connecting with the machine token it reads,
|
|
433
|
+
read-only, from the spoke's `config.json`. Its host runtime makes worktrees at `<repo>/.worktrees/<branch>`
|
|
434
|
+
(reused when the branch already has one), checkouts and clones, all resolved through symlinks under the roots;
|
|
435
|
+
git runs without a shell, only over https and ssh, without prompts or inherited `GIT_*` settings, with time
|
|
436
|
+
limits and cancellation. Cleanup never forces: it keeps reused workspaces, checkouts, roots, workspaces another
|
|
437
|
+
launch made (a marker in the git directory), uncommitted changes and unpushed clones. It reconnects with backoff,
|
|
438
|
+
waits 30 minutes between tries on a hub that does not serve nodes, and stops when its machine is revoked;
|
|
439
|
+
launchd and systemd unit templates in `scripts/`. Runs on Bun and Node.
|
|
440
|
+
- **Launch engine**: each placed launch is prepared on the node, created through the machine's service spoke, given
|
|
441
|
+
its roles, its name and the hub's primer, then prompted once, and runs. It ends `idle` only after its prompt was
|
|
442
|
+
seen working and the session then stayed idle (no blockers, asks or open Decisions) for 30 seconds; `asked` also
|
|
443
|
+
ends `needs_user`; `never` only by a stop or `end.timeout`; a session unreported for 10 minutes, or a node gone
|
|
444
|
+
while provisioning, ends `lost`. At the end the hub stops the session when needed, collects artifacts (branch,
|
|
445
|
+
head, commits, dirty) and asks the node to clean up (later, when the node is away). A restarted hub re-places
|
|
446
|
+
queued launches, continues provisioning ones from their last step and watches running ones again.
|
|
447
|
+
- **Launch API**: `POST`/`GET /api/agent/launches` (`?state=`, `?label=k=v`), `GET`/`DELETE
|
|
448
|
+
/api/agent/launches/:id` (`410 gone` once ended), for users, app tokens with `sessions:launch` and sessions through
|
|
449
|
+
their act token, each under a grant with verb `launch` covering the repo (users need none); the kill switches
|
|
450
|
+
pause apps and sessions (`423`); apps and sessions see and stop only their own launches; every launch and stop is
|
|
451
|
+
audited. Launch labels show on read routes and filter lists; every change is an SSE `launch` event and a `launch`
|
|
452
|
+
watch event; the welcome announces `launches`.
|
|
453
|
+
- **Webhooks**: `POST`/`GET /api/agent/webhooks`, `GET`/`DELETE /api/agent/webhooks/:id` for users (all of theirs)
|
|
454
|
+
and app tokens with `sessions:launch` (only their own, and only events of launches they started). Each launch
|
|
455
|
+
change (`launch.queued`, `launch.provisioning`, `launch.running`, `launch.ended`) is POSTed as a CloudEvents-style
|
|
456
|
+
JSON body, signed with the registration's `whsec_…` secret (HMAC-SHA256 of `<t>.<body>`), from a durable outbox:
|
|
457
|
+
at least once, deduplicated, retried with backoff for about 8.5 hours. Targets must be `https://` on public hosts,
|
|
458
|
+
checked again (re-resolved) before every send, with no redirects, a 10 s timeout and a 64 KiB response cap;
|
|
459
|
+
`hub.json agents.webhooks.allowPrivate` admits `http://` and private hosts for self-hosted receivers. Push
|
|
460
|
+
targets share the same public-URL checks.
|
|
461
|
+
- **Clients**: the SDK gains `hub.launches.create/list/get/stop` and `verifyWebhook` / `signWebhook` (constant-time,
|
|
462
|
+
5-minute tolerance, several secrets while rotating); the CLI `hub launch` (every spec field as a flag;
|
|
463
|
+
`--prompt -` reads stdin), `hub launches list/show/stop` and `hub node run/install/uninstall/status` (install
|
|
464
|
+
writes the unit and prints the commands that load it; it never runs them); `hub mcp` adds the tools `launch`,
|
|
465
|
+
`launches`, `launch_show` and `launch_stop`.
|
|
466
|
+
- **Tests**: `test/e2e-phase4.test.ts` runs a hub, a fake OpenCode service spoke and a real `hub node` (host runtime)
|
|
467
|
+
on temporary git repositories: `hub launch` through every state into a real worktree with name, role, labels,
|
|
468
|
+
primer and prompt, the idle end with artifacts and the worktree removed, signed webhooks checked with
|
|
469
|
+
`verifyWebhook`, idempotent keys, `maxActive`, app tokens with and without a launch grant, the kill switch, a
|
|
470
|
+
session's shell token refused, tenancy and a node lost while preparing. Opt-in against a real
|
|
471
|
+
`opencode serve --service`: `HUB_IT_OPENCODE=1 bun test test/e2e-phase4-live.it.test.ts` (the launched session
|
|
472
|
+
runs in the node's worktree and the model receives the prompt).
|
|
473
|
+
|
|
474
|
+
### Sandboxed launches
|
|
475
|
+
|
|
476
|
+
- Launches can run in OpenShell sandboxes (`runtime: {kind: "openshell", profile}`, clone workspaces only): the node
|
|
477
|
+
clones on the host, creates a labelled sandbox in the OpenShell workspace `hub` with a deny-by-default policy,
|
|
478
|
+
moves code in and out as git bundles, starts the harness from the sandbox image and collects the work into the
|
|
479
|
+
host clone ([docs/launches.md](docs/launches.md#sandboxed-launches-openshell-runtime)).
|
|
480
|
+
- Launch-bound tokens (`hlt1`) let a sandboxed plugin connect for one launch only; the hub keeps such connections
|
|
481
|
+
out of service selection, closes them when the launch ends and gives their sessions no act tokens.
|
|
482
|
+
- The plugin's launch-bound mode reads `$HUB_LAUNCH_CONFIG` (or `/sandbox/.hub/config.json`) and never enrolls,
|
|
483
|
+
starts a local hub or writes config.
|
|
484
|
+
- node.json `openshell` section (`enabled`, `gateway`, `image`, `profiles`); the node announces the `sandbox`
|
|
485
|
+
feature, reports its sandboxes after a restart, and falls back to `host` when the CLI cannot be used.
|
|
486
|
+
- Profiles compile to OpenShell policies (hub, optional git remote, extra endpoints, only for the harness binary);
|
|
487
|
+
profiles asking for mounts or CPU/memory limits are refused.
|
|
488
|
+
- The sandbox image (`packages/runtime-openshell/image/`) with start and entry scripts and an upload fallback.
|
|
489
|
+
- `hub node openshell check [--json]` reports whether a node can run sandboxed launches.
|
|
490
|
+
|
|
491
|
+
### More harnesses
|
|
492
|
+
|
|
493
|
+
Claude Code, Codex and terminal agents described by a preset connect to the hub next to OpenCode, each through a
|
|
494
|
+
spoke that announces exactly what it can do ([docs/harnesses.md](docs/harnesses.md)), and new harness spokes have
|
|
495
|
+
an SDK, a test kit, a conformance suite and a scaffold ([docs/spoke-sdk.md](docs/spoke-sdk.md)).
|
|
496
|
+
|
|
497
|
+
- **Protocol** (still version 1, compatible both ways). Core ops (`turn`, `steer`, `interrupt`, `stop`,
|
|
498
|
+
`permission`, `form`) are announced features for non-OpenCode hellos; OpenCode hellos keep implying them.
|
|
499
|
+
`opencodeVersion` is optional for other harnesses. The harness event subset (`HARNESS_EVENTS`,
|
|
500
|
+
`harnessEventProblem`) fixes which forwarded events another harness's spoke may send and their shapes, including
|
|
501
|
+
`session.execution.interrupted {inferred: true}` for interruptions the harness never reported and
|
|
502
|
+
`session.inbox.delivered {origin}`. New feature `turnEvents` (every turn end is the harness's own). The harness
|
|
503
|
+
preset schema (`HarnessPreset`, `presetProblem`): data only, an argv without shell syntax, delivery `paste`,
|
|
504
|
+
evidence `screen`, built-in names reserved.
|
|
505
|
+
- **Hub.** One service spoke per machine and harness (`serviceFor(machine, harness)`): a Claude Code or Codex
|
|
506
|
+
service never hides the OpenCode one. Launches and placement use the service spoke of `spec.harness`. Ops a spoke
|
|
507
|
+
did not announce are refused before they reach it: agent API `422 unsupported {feature, harness}`, console `409`
|
|
508
|
+
code `unsupported`, CLI exit 7, a launch fails with the reason; an escalated permission Decision for such a spoke
|
|
509
|
+
closes as `unsupported`. Events from non-OpenCode spokes are validated against the subset (invalid ones dropped
|
|
510
|
+
with a rate-limited warning). Idle by real evidence trusts `native` and `hooks`, and `rpc` only with
|
|
511
|
+
`turnEvents`; after an inferred interruption it waits 5 s.
|
|
512
|
+
- **Console.** Controls follow each process's announced features (OpenCode unchanged); other harnesses' sessions
|
|
513
|
+
show an evidence badge (Hooks, RPC, Screen, Unknown), and delivery labels never claim more than the spoke can
|
|
514
|
+
confirm (hooks and screen: queued). A `409 unsupported` answer shows a toast naming what is missing.
|
|
515
|
+
- **Claude Code** (`packages/claude-code`). `hub claude [claude args…]` runs Claude Code with the hub plugin through
|
|
516
|
+
`--plugin-dir` (never editing `~/.claude`) and starts the per-user daemon (`hub claude-code daemon`, evidence
|
|
517
|
+
`hooks`) on a Unix socket in a private directory. The hook bridge (`hub claude-code hook`) fails open. Sessions
|
|
518
|
+
are `ses_cc<uuid hex>`; tool names map to hub actions (Bash → `shell`, Edit/Write → `edit`, Read → `read`,
|
|
519
|
+
`mcp__s__t` → `s_t`). Permissions wait for the hub in the PermissionRequest hook (`HUB_CLAUDE_PERMISSION_WAIT`,
|
|
520
|
+
default 120 s; 0 reports only), racing Claude Code's own dialog; deny reasons reach the model. Messages arrive at
|
|
521
|
+
the next tool boundary or prompt (`queued`), and an idle session is woken through an asyncRewake Stop hook
|
|
522
|
+
(`turn`, framed as a system notification); `hub claude -p` turns that hook off. Stop ends the running turn at its
|
|
523
|
+
next tool hook (denying the pending call). Esc interruptions are inferred from the next prompt or notification.
|
|
524
|
+
Session tokens reach the Bash tool's shells through the SessionStart env file; act verbs go through the plugin's
|
|
525
|
+
`hub` MCP server, with the act token kept in the daemon. Prompt references are attached as context. With the
|
|
526
|
+
`claude` CLI found, the daemon also runs hub launches headless (`-p --input-format stream-json`) in node
|
|
527
|
+
workspaces, taking turns and interrupts there.
|
|
528
|
+
- **Codex** (`packages/codex`). `hub codex [codex args…]` runs Codex with its shared app-server daemon on for that
|
|
529
|
+
run (`--enable daemon_auto_start`; `~/.codex` is never written) and starts the per-user spoke (`hub codex-spoke
|
|
530
|
+
serve`, evidence `rpc`, `turnEvents`). The spoke attaches to the daemon's threads (TUI sessions included) as one
|
|
531
|
+
client among others, and runs its own `codex app-server` over stdio for threads the hub starts. Sessions are
|
|
532
|
+
`ses_cx<thread uuid hex>`. Turns (`turn/start`), steer (`turn/steer`), interrupt and stop (`turn/interrupt`),
|
|
533
|
+
approvals (first answer wins; `gone` when the TUI answered first), messages (`turn/steer`, `turn/start`, or
|
|
534
|
+
`thread/inject_items` without waking; each message once, since Codex's queue does not deduplicate) and launches
|
|
535
|
+
(`thread/start`). Settings `HUB_CODEX_BIN`, `HUB_CODEX_DAEMON_SOCKET`, `HUB_CODEX_CONFIG`.
|
|
536
|
+
- **Presets** (`packages/preset`). `hub preset list | check | run` with the official presets `aider` and `goose`:
|
|
537
|
+
the harness runs in a new pane on a private tmux server (`-L hub-preset`; your own only with `--current`), prompts
|
|
538
|
+
and messages are pasted (answered `timeout` and `queued`: never confirmed), stop and interrupt send Ctrl-C, and
|
|
539
|
+
nothing but the native session ID and the pane's liveness is read off the screen. Never permission, form or
|
|
540
|
+
steer; never idle. Sessions are `ses_pr<uuid hex>`.
|
|
541
|
+
- **Services.** `hub claude-code install | uninstall [--dry-run] | status` and `hub codex-spoke install | uninstall
|
|
542
|
+
[--dry-run] | status` write a launchd agent or systemd user unit for the daemon and the spoke from the templates
|
|
543
|
+
in `scripts/`, like `hub node install`: the load commands are printed, never run.
|
|
544
|
+
- **Nodes and launches.** The host runtime reports the harnesses it offers: `opencode` always, `claude-code` when
|
|
545
|
+
the `claude` CLI is found (`HUB_CLAUDE_BIN`, else PATH), others through node.json `harnesses` (`{"codex": true}`,
|
|
546
|
+
`{"claude-code": false}`). `hub launch --harness claude-code | codex` places launches on such nodes
|
|
547
|
+
([docs/launches.md](docs/launches.md#harnesses)).
|
|
548
|
+
- **Spoke SDK** (`packages/spoke-sdk`, shipped inside `@huynle/hub`): the connection, enrollment, session tokens,
|
|
549
|
+
`DeliveryLedger`, prompt envelopes and references extracted from the OpenCode spoke (its files re-export them);
|
|
550
|
+
reversible session ID mapping (`hubSessionID`, `nativeSessionID`); `buildHarnessHello`; and `HarnessSpoke`, which
|
|
551
|
+
announces exactly the ops it has handlers for, refuses the rest and unknown sessions, sends validated events and
|
|
552
|
+
the sessions frame, keeps tokens apart in memory and admits each message once.
|
|
553
|
+
- **Testkit** (`packages/testkit`, development only): a fake hub and spoke client, a stub model server (OpenAI chat
|
|
554
|
+
completions, Anthropic Messages, OpenAI Responses; text and tool calls), a deny-all proxy for live harness tests,
|
|
555
|
+
and the harness driver protocol v1 (`serveDriver`, `spawnDriver`).
|
|
556
|
+
- **Conformance** (`packages/conformance`, development only): `hub-conformance spoke --driver "<command>"` runs a
|
|
557
|
+
spoke through its driver against a recording hub and checks the hello, feature honesty, events, deliver
|
|
558
|
+
idempotency and modes, permission replies, token hygiene, reconnect and frame limits (table or `--json`).
|
|
559
|
+
OpenCode, Claude Code, Codex and preset spokes all pass; a reference spoke has one broken variant per check.
|
|
560
|
+
- **Scaffold**: `bun packages/spoke-sdk/scaffold.ts <name> <dir> [--namespace xy]` writes a minimal `HarnessSpoke`
|
|
561
|
+
spoke and its conformance driver for a new harness; the output passes the conformance suite as written.
|
|
562
|
+
- **Tests**: `test/e2e-phase7.test.ts` (every spoke on one machine, honest delivery, acting, unsupported answers,
|
|
563
|
+
claude-code and codex launches, tenancy) and `test/e2e-phase7-conformance.test.ts`; opt-in live tests against the
|
|
564
|
+
real CLIs with stub models, throwaway config dirs and a deny-all proxy (`HUB_IT_CLAUDE=1`, `HUB_IT_CODEX=1`).
|
|
565
|
+
- **Docs**: [docs/harnesses.md](docs/harnesses.md) (support matrix, evidence levels, setup, known limits, threat
|
|
566
|
+
model) and [docs/spoke-sdk.md](docs/spoke-sdk.md) (writing a spoke, the event subset, conformance, testkit).
|
|
567
|
+
|
|
568
|
+
### Cluster mode
|
|
569
|
+
|
|
570
|
+
The hub can run as several pods behind one load balancer, sharing Postgres and Valkey (or Redis), with no sticky
|
|
571
|
+
sessions and rolling updates without downtime ([docs/cluster.md](docs/cluster.md)). The single-process hub is
|
|
572
|
+
unchanged (its files stay byte-identical) and remains the default.
|
|
573
|
+
|
|
574
|
+
- **Configuration**: `HUB_DATABASE_URL` (`postgres://`) and `HUB_VALKEY_URL` (`redis://`, `rediss://`, `valkey://`,
|
|
575
|
+
`valkeys://`), or hub.json `cluster {database, valkey}`; both or neither. `HUB_POD_ID` names the pod (default the
|
|
576
|
+
hostname). Cluster mode requires `HUB_TOKEN_KEYS` (shared by every pod) and `HUB_AUTH=oidc`, and refuses pairing
|
|
577
|
+
mode. The URLs are never logged; a readable hub.json's URLs with a password are ignored.
|
|
578
|
+
- **Shared state**: users, machines, devices, settings, push subscriptions, the audit log and agent data live in
|
|
579
|
+
Postgres (`hub_state`, `hub_claims`, `hub_schema`); pods cache them, tell each other what changed and reload every
|
|
580
|
+
30 s; writes are version-checked. Codes (pairing, login, link, enrollment), OIDC sign-in state, rate limits, push
|
|
581
|
+
cooldowns and answered-request markers live in Valkey, so a code issued on one pod works on another. No new
|
|
582
|
+
dependencies (Bun's own Postgres and Redis clients).
|
|
583
|
+
- **Routing across pods**: each pod records its spoke and node connections in Valkey and serves requests for them
|
|
584
|
+
on `pod.<id>.rpc`; a request for a session on another pod is forwarded there (tenant checked again by the owning
|
|
585
|
+
pod). Every pod sees every connection through a shared cluster view, so status, `/api/agent/*`, placement and turn
|
|
586
|
+
routing work wherever a request lands. Pods re-check their sockets every 30 s and close those of revoked machines
|
|
587
|
+
and disabled users.
|
|
588
|
+
- **Live events across pods**: console SSE (`/api/events`), `/api/agent/watch`, message and launch feeds reach every
|
|
589
|
+
pod; events (except streamed text) go to a per-user Valkey stream, so `Last-Event-ID` resumes on any pod.
|
|
590
|
+
- **Leader jobs**: decision deadlines, message re-wakes, the launch supervisor and the webhook sender run on one pod
|
|
591
|
+
at a time under fenced 15 s leases, failing over when a pod drains or dies; a session's turn and message flush take
|
|
592
|
+
cluster-wide leases.
|
|
593
|
+
- **Draining and readiness** (every mode): `GET /readyz` answers 503 while draining and, in cluster mode, when
|
|
594
|
+
Postgres or Valkey does not answer within 2 s (`/healthz` stays the liveness check). SIGTERM drains: new
|
|
595
|
+
WebSockets are refused (503, `Retry-After`), spokes and nodes get `bye {reason: "draining", retryAfterMs}` and
|
|
596
|
+
close code 4006 once their requests are answered, leader leases are released, SSE streams end with `retry:`, and
|
|
597
|
+
requests in flight get up to 50 s. A second signal stops at once (exit 1); SIGINT stops without draining.
|
|
598
|
+
Protocol (still version 1): `bye.retryAfterMs` (0–300000, optional); the OpenCode spoke and `hub node` reconnect
|
|
599
|
+
after a random 0–`retryAfterMs` instead of their usual backoff.
|
|
600
|
+
- **`hub-server migrate`** applies the Postgres schema (idempotent, under an advisory lock); pods never migrate and
|
|
601
|
+
refuse an older schema. **`hub-server import --state <dir> [--dry-run] [--force] [--print-token-keys]`** copies a
|
|
602
|
+
single-process hub's state (state.json and agent data) into Postgres: idempotent, conflicts reported (overwritten
|
|
603
|
+
only with `--force`), machines and devices keep their tokens; `--print-token-keys` prints `token-keys.json` as the
|
|
604
|
+
`HUB_TOKEN_KEYS` value, which is never stored.
|
|
605
|
+
- **Kubernetes manifests** in `deploy/k8s/`: Deployment (2 replicas, topology spread, `HUB_POD_ID` from the pod
|
|
606
|
+
name, `/readyz` and `/healthz` probes, `preStop` sleep, 60 s grace period, non-root, read-only root filesystem),
|
|
607
|
+
Service, Ingress example and Traefik notes for long WebSocket/SSE timeouts without buffering, Secret template,
|
|
608
|
+
PodDisruptionBudget, migrate Job, NetworkPolicy, Kustomization and a Flux example with SOPS; validated offline
|
|
609
|
+
(`kubeconform -strict`, `test/deploy-k8s.test.ts`).
|
|
610
|
+
- **Tests**: backend contract suites run against memory and file backends by default and against real Postgres 16
|
|
611
|
+
and Valkey 8 containers with `HUB_IT_CLUSTER=1`; in-process multi-pod suites on shared memory backends run in
|
|
612
|
+
the default CI.
|
|
613
|
+
|
|
614
|
+
### Other changes
|
|
615
|
+
|
|
616
|
+
- **Auto-name**: let OpenCode name a session with its Title agent, from the console. **Auto-name** in every
|
|
617
|
+
session `⋯` menu (table, phone list, sidebar, session title) and a ✨ button next to the inline rename field;
|
|
618
|
+
the title shows "Naming…" until OpenCode answers, then "Renamed to …" or "OpenCode kept the title".
|
|
619
|
+
`POST /api/sessions/:id/rename {auto: true}` → spoke op `autoName`, which calls `session.update` with an empty
|
|
620
|
+
title on the process's own server (the plugin API's update would set an empty title) and waits up to 20 s for
|
|
621
|
+
`session.renamed` (202 `{pending}` after that). Spokes now list their optional features in the hello
|
|
622
|
+
(`features: ["autoName"]`); a process with an older plugin gets "Update the Hub plugin to use Auto-name"
|
|
623
|
+
instead of a request it would drop.
|
|
624
|
+
|
|
625
|
+
- **Question answers no longer lost**: a question card in the console kept its answers in components that were rebuilt
|
|
626
|
+
whenever any live update arrived (every session change sends a fresh catalog), so choices and typed text were
|
|
627
|
+
silently reset and Submit sent every question to OpenCode as "Unanswered". Cards are now keyed by request ID and
|
|
628
|
+
fields by key, and a question form with nothing chosen is refused with a hint (Dismiss skips it).
|
|
629
|
+
- **Voice mode: progress cues play as loud as the spoken replies.** Short cue phrases ("On it.", "Hang on." …)
|
|
630
|
+
came out of TTS up to ~4 dB louder or quieter than replies (measured: −15 to −22 LUFS against replies around
|
|
631
|
+
−19.5). Each phrase is now normalised while it is prefetched: measured (BS.1770 gated loudness), trimmed of
|
|
632
|
+
silence beyond 80 ms (cues start sooner), gained to the replies' loudness with peaks limited to −1 dBFS, and
|
|
633
|
+
cached as WAV. The target is the running median of measured replies per TTS voice (completed sentences decoded
|
|
634
|
+
when idle; −19.5 LUFS until then). The cue cache moved to `hub-cues-v2`, so old un-normalised cues are fetched
|
|
635
|
+
again once. Both audio elements play at volume 1. Measured with the default voice: cue spread around the reply
|
|
636
|
+
median went from −2.0…+4.8 dB to within 0.1 dB; normalising adds ~8 ms (max 60 ms) per phrase at prefetch and
|
|
637
|
+
nothing at play time.
|
|
638
|
+
- **No more false "finished" notifications; pick exactly which notifications each device gets.** "Finished"
|
|
639
|
+
fired on every turn of a root session, but in OpenCode v2 each background subagent that completes starts a
|
|
640
|
+
short continuation turn in its parent (a synthetic message), as do steering and Voice turns: in a real session
|
|
641
|
+
82 of 114 "finished" pushes came while subagents were still running. It also fired while you were looking at
|
|
642
|
+
the session. Now (see [docs/notifications.md](docs/notifications.md)):
|
|
643
|
+
- **All done** (new, on by default) replaces it: once per run, when the root's last turn succeeded and the
|
|
644
|
+
whole tree stayed settled for 15 s (no subagent or shell job running, no question or permission pending,
|
|
645
|
+
no hub turn). A continuation turn within the 15 s cancels it.
|
|
646
|
+
- New per-device kinds, off by default: **Turn finished** (the old every-turn behaviour), **Stopped**
|
|
647
|
+
(shutdown/inactivity), **Retrying**, **Subagent finished/failed**, **Script finished/failed** (background
|
|
648
|
+
shell or terminal exit code), **Long tool finished** (≥ 30 s). Failed (with the reason) is on by default.
|
|
649
|
+
- Titles say the kind: "KVM Rewrite · All done", "· Subagent S3 macros finished", "· Script exited 1";
|
|
650
|
+
progress kinds replace each other quietly.
|
|
651
|
+
- Run notifications are no longer sent while a device shows the session (questions and permissions are).
|
|
652
|
+
- Settings → Notifications: kinds grouped (Needs you, Session, Subagents, Scripts and tools, Voice, Activity
|
|
653
|
+
digest) with a one-line description each, a "You'll get: …" summary, and a test of a chosen kind
|
|
654
|
+
(`POST /api/push/test {kind}`).
|
|
655
|
+
- Prefs migrate on start: `finished: true` becomes All done (not every turn); failed, activity and the rest
|
|
656
|
+
are kept; new kinds start off. The API still accepts `finished` (→ `done`) and `blocked`.
|
|
657
|
+
|
|
658
|
+
- **The console keeps a session's conversation in order**: subagents no longer bunch together at the bottom of
|
|
659
|
+
the conversation, and thoughts no longer jump below the text or tools that came after them. Rows used to be
|
|
660
|
+
sorted by their timestamps, which come from different clocks (OpenCode's message and part times, the hub's,
|
|
661
|
+
the phone's), and a finished subagent got the time the page loaded. Now:
|
|
662
|
+
- Every timeline row from the spoke carries `pos`, its position in the session (message order, then part
|
|
663
|
+
order), and the console orders rows by it. Tool rows of `subagent`/`task` carry `childSessionID` (from the
|
|
664
|
+
tool's metadata, else its output) and `title`; tool rows `callID`; text rows `ordinal`.
|
|
665
|
+
- A subagent row stands in place of the tool call that spawned it, also after it finished and after a reload.
|
|
666
|
+
When the spawn is in an older page that is not loaded yet, the subagent shows at the top of the loaded
|
|
667
|
+
history and moves to its spawn when that page loads.
|
|
668
|
+
- Live thoughts, text, tools, sent messages and spawned subagents appear in arrival order after the history,
|
|
669
|
+
and when the stored rows arrive (reconnect) they replace the live ones in place.
|
|
670
|
+
- The spoke forwards a subagent tool's child session (`session.tool.progress`, sent as a compact
|
|
671
|
+
`session.tool.called {id, childSessionID}`, which older hubs accept); SSE `activity` items carry
|
|
672
|
+
`childSessionID` and `delta` events the text part's `ordinal`. All new fields are optional: older spokes
|
|
673
|
+
and hubs keep working (rows without `pos` keep the order of their pages).
|
|
674
|
+
|
|
675
|
+
- **Sessions open on their newest messages, and older ones load while you scroll up**: the console used to load
|
|
676
|
+
a conversation oldest first, 200 rows at a time and at most 2000 rows, before showing anything, so a long
|
|
677
|
+
session opened slowly and could miss its newest messages. It now asks for the newest 80 rows (one request),
|
|
678
|
+
shows them at the bottom, and loads the page before them as you get near the top ("Loading earlier
|
|
679
|
+
messages…", then "Beginning of conversation"; a failed page offers Retry). What is on screen stays in place
|
|
680
|
+
while older rows are added above it, and live replies still arrive at the bottom. Measured on a mock session
|
|
681
|
+
with ~1000 rows: 1 request and 13.7 KB instead of 6 requests and 178 KB, and only 80 rows rendered at first.
|
|
682
|
+
- API: `GET /api/sessions/:id/timeline?limit=N&order=desc` returns the newest rows with an `older` cursor;
|
|
683
|
+
`?before=<older>` returns the page before it. Cursors are row keys, so they stay valid while new messages
|
|
684
|
+
arrive. The forward `?cursor=` mode is unchanged. Consoles talking to older spokes fall back to loading the
|
|
685
|
+
whole history as before.
|
|
686
|
+
- The spoke reads OpenCode's history once per first page and serves the following pages from a short-lived
|
|
687
|
+
per-session cache (30 s, dropped on any event of the session), instead of reading the whole history again
|
|
688
|
+
for every page.
|
|
689
|
+
- After a reconnect that missed events, the console reloads the newest page. It keeps the older pages already
|
|
690
|
+
loaded when the new page overlaps them; otherwise it starts over from the newest page.
|
|
691
|
+
|
|
692
|
+
- **Voice mode tells the truth with the screen off**: Android Chrome usually stops the microphone when the
|
|
693
|
+
screen turns off, while the "Voice mode on · listening" notification stayed. Voice mode now notices (no
|
|
694
|
+
audio frames for 1.5 s, the mic track muted or ended, the AudioContext suspended) and says so: the Voice
|
|
695
|
+
bar shows "Paused — mic stopped by the phone (screen off). Unlock to resume.", the notification turns into
|
|
696
|
+
"Voice mode paused · unlock to talk" (End and Open kept; updated at once), the lock-screen controls show
|
|
697
|
+
paused and a short falling tone plays. On unlock it resumes the audio, asks for the microphone again if
|
|
698
|
+
needed (or offers "Tap to start Voice mode"), retries with backoff and plays a rising tone; it says
|
|
699
|
+
"listening" only once audio arrives. While hidden it keeps trying (audio resume, silent loop). The Settings
|
|
700
|
+
text for "Keep listening when the screen is off" now says it is best effort.
|
|
701
|
+
- **Smoother live connection**:
|
|
702
|
+
- **Resume instead of reload**: every SSE message carries an id; a console that reconnects (phone unlocked,
|
|
703
|
+
app switched back, network blip) sends `Last-Event-ID` (or `?last=`) and the hub replays what it missed from
|
|
704
|
+
a per-user buffer (last 2000 events or 5 minutes, one fresh catalog for any catalog changes). When the gap
|
|
705
|
+
is not covered (hub restarted, too long away) it sends the catalog and a `resync` marker, and the open
|
|
706
|
+
session refetches its conversation, status and details.
|
|
707
|
+
- **No bandwidth while you are not looking**: a page hidden for 30 s closes its stream (Voice mode keeps it
|
|
708
|
+
open); showing it again (or coming back from the back/forward cache) reopens it at once with the last id. A
|
|
709
|
+
stream silent for 45 s (no message, no heartbeat) is reopened. Heartbeats are now `event: ping`.
|
|
710
|
+
- **Lite streams**: the console asks for `?lite=1&session=<id>`: streamed text and reasoning only for the
|
|
711
|
+
session on screen (everything for Voice mode). Measured on an idle hub: ~50 bytes/min after the first
|
|
712
|
+
catalog; a busy session over a minute: 9.3 KB full vs 7.4 KB lite (the rest is catalogs, status and activity).
|
|
713
|
+
- The top bar shows "Reconnecting…" and then "Updated" for a moment instead of a reload.
|
|
714
|
+
- **Activity updates** (opt-in push, per device; Settings → Notifications shows a hint until it is on): one
|
|
715
|
+
quiet, self-replacing notification per session with what happened — subagents started/finished, tools of
|
|
716
|
+
30 s or more, background shells and terminals that ended (with exit code), the session going idle — at most
|
|
717
|
+
one per session per interval (1–60 minutes, default 5; "3 tools · explore finished · Finished"). Quiet
|
|
718
|
+
hours and muted sessions apply; nothing while a device shows the session.
|
|
719
|
+
- **Running detection**:
|
|
720
|
+
- Sessions running a script are working: the spoke forwards OpenCode's `session.status` (busy/retry/idle) and
|
|
721
|
+
the shells and terminals sessions start (compact: id, session, command ≤ 200 chars, status, exit code). A
|
|
722
|
+
session works while its run, a busy or retrying status, or a running shell/terminal lasts.
|
|
723
|
+
- **Retrying** shows with its reason ("Retrying (attempt 2): Rate limited").
|
|
724
|
+
- **Background jobs**: a count next to the status in the table and sidebar, "· 1 job" in the session header,
|
|
725
|
+
and a **Jobs** card in the rail (command, running time, exit code for a minute after it ends).
|
|
726
|
+
- **Seeding**: on connect, after a hub reconnect and after OpenCode's event stream comes back, the spoke asks
|
|
727
|
+
its own server what runs (`session.active`, `shell.list`) so sessions already running show as working.
|
|
728
|
+
- **Nicer replies**: the console renders more of the Markdown agents write, still as elements (never raw HTML;
|
|
729
|
+
HTML in a reply shows as text):
|
|
730
|
+
- **GFM tables** (column alignment, formatting in cells, scrolling sideways on narrow screens), **nested lists**
|
|
731
|
+
(by indentation, numbered and bulleted mixed), **task lists** (disabled checkboxes), list items with several
|
|
732
|
+
paragraphs or a code block, headings, lists and code **inside quotes**, **setext headings**, `~~strike~~`,
|
|
733
|
+
`==highlight==`, **autolinks** (bare http(s) URLs and `<url>`), line breaks (a single newline in a paragraph
|
|
734
|
+
is a break, as agents write), backslash escapes, and images as a link named by their alt text (never loaded).
|
|
735
|
+
- **Code blocks**: language label, **Copy** (with "Copied"), **Wrap** toggle (else long lines scroll), and
|
|
736
|
+
syntax highlighting for TypeScript/JavaScript/TSX, JSON, bash/sh, Python, Go, Rust, YAML, diff (added and
|
|
737
|
+
removed lines colored), SQL, CSS, HTML/XML and Markdown: a small built-in tokenizer loaded with the first
|
|
738
|
+
code block (3.3 KB gzipped chunk; the main chunk grew 5.7 KB gzipped).
|
|
739
|
+
- **While a reply streams**, the block being written closes what it opened (`**bold` shows bold, a table
|
|
740
|
+
header alone is a table, a link whose address is still arriving shows its label), and blocks update in place
|
|
741
|
+
instead of being rebuilt.
|
|
742
|
+
- **Text size and pinch-to-zoom**: all console text sizes follow one `--font-scale` (80–180%). Set it in
|
|
743
|
+
**Settings → Appearance → Text size** (with Reset), or **pinch** the conversation or the session list: two
|
|
744
|
+
fingers on a phone, a trackpad pinch on desktop (ctrl+wheel, Safari gesture events). The pinch only changes the
|
|
745
|
+
text there; elsewhere (and ⌘+/⌘−) the browser zooms as before. Kept per device and applied before the first
|
|
746
|
+
paint. On touch screens text fields are at least 16 px, so iOS no longer zooms in when one gets focus.
|
|
747
|
+
|
|
748
|
+
- **Voice mode hears you, not the room**:
|
|
749
|
+
- **On-device speech detection**: Silero VAD v5 (MIT) runs in the browser through onnxruntime-web (MIT; wasm,
|
|
750
|
+
single-threaded, in a Web Worker) on 16 kHz, 32 ms chunks: speech starts at probability ≥ 0.6 for 250 ms and
|
|
751
|
+
ends after 350 ms below 0.35. Typing, fans, clatter, beeps and hum no longer start a turn. The energy
|
|
752
|
+
detector stays as a pre-gate (quiet chunks skip the model) and as the fallback while the model loads or if
|
|
753
|
+
it fails (logged once). The model and wasm load when Voice mode first starts, as hashed assets outside the
|
|
754
|
+
main JS chunk; the hub now serves `/assets/*` with `cache-control: public, max-age=31536000, immutable`,
|
|
755
|
+
`.wasm` and `.onnx` with their types, and compresses big hashed assets (Brotli or gzip) when accepted.
|
|
756
|
+
Measured about 1 ms per 32 ms chunk; segments end within one chunk of the energy detector.
|
|
757
|
+
- **Echo guard**: a mono mic track with the browser's echo cancellation, noise suppression and auto gain; while
|
|
758
|
+
a reply or a progress cue plays (and 400 ms after) a barge-in needs probability ≥ 0.85 and 8 dB more energy.
|
|
759
|
+
- **Settings → Voice → Mic sensitivity** (Low / Normal / High, per device, applies at once): model thresholds,
|
|
760
|
+
speech needed to start, and the energy margins.
|
|
761
|
+
- **Noise transcripts dropped**: "Thank you.", "you", "Thanks for watching!", "Subtitles by …", "[Music]",
|
|
762
|
+
punctuation-only and repeated-word transcripts, and one or two words from under 400 ms of speech (unless a
|
|
763
|
+
command or answer like "yes", "no", "stop", "go ahead", "cancel that") never reach the agent; the Voice bar
|
|
764
|
+
shows them dim for a moment ("ignored: …").
|
|
765
|
+
- **Whisper's no-speech rating**: with a Whisper model the hub asks for `verbose_json` and drops segments with
|
|
766
|
+
`no_speech_prob` > 0.6 (falls back to plain requests for providers that refuse it).
|
|
767
|
+
|
|
768
|
+
- **Voice mode: varied progress cues**: instead of the same "Working on it.", "Started a subagent." and
|
|
769
|
+
"Still working." every time, each cue has several short, natural variants ("On it.", "Give me a sec.",
|
|
770
|
+
"I'll hand that off.", "Still on it.", "Almost there." …). A shuffle-bag picks them: every variant once
|
|
771
|
+
before any repeats, never the same one twice in a row, preferring one whose audio is already cached (else
|
|
772
|
+
the short tone, as before). All variants are prefetched when Voice mode starts, 2 at a time, one of each
|
|
773
|
+
kind first, and cached per TTS voice as before; ending Voice mode cancels a prefetch still running (aborts
|
|
774
|
+
its requests), so it never holds the browser's connections.
|
|
775
|
+
|
|
776
|
+
- **Voice mode: streaming speech and several tasks at once** (release 2 of 3):
|
|
777
|
+
- **Replies play while their audio downloads**: `/api/speak` passes the TTS provider's MP3 through as it
|
|
778
|
+
arrives (nothing is buffered on the hub; a cancelled request aborts the upstream one), and the phone plays
|
|
779
|
+
each sentence through MediaSource while it is still downloading. Browsers without MP3 in MediaSource (some
|
|
780
|
+
iOS Safari versions) play each sentence once complete, as before. Prefetching two ahead, barge-in, Voice
|
|
781
|
+
speed and the cached cue phrases work as before. New turn step `hub-voice:first-byte` (the Voice bar tooltip
|
|
782
|
+
shows "first byte" between "first reply" and "audio").
|
|
783
|
+
- **Parallel workers**: the voice agent starts one background worker per independent request, all in the same
|
|
784
|
+
turn ("check the deploy and update the changelog" → two workers), names each in a few words ("Deploy
|
|
785
|
+
check"), and keeps sending follow-ups for a running task to the same worker.
|
|
786
|
+
- **Task board**: every voice turn carries a live list of the agent's workers (title, sessionID, working /
|
|
787
|
+
blocked on what / done with its result / failed with the reason / stopped, and for how long; at most 8, of at
|
|
788
|
+
most 200 characters each), so "what's running?" or "how's the deploy going?" is answered without a tool call.
|
|
789
|
+
- **Stop one task by voice**: new tool `hub_task_stop` ("cancel the deploy one" → that worker and what runs
|
|
790
|
+
below it; "stop all the workers" → `all: true`). It only reaches workers of the session calling it, never the
|
|
791
|
+
voice agent itself. The phone's stop words still interrupt the agent's turn at once.
|
|
792
|
+
- **Announcements**: with several workers in play, a finished worker's result starts with its task's name
|
|
793
|
+
("Deploy check finished: …"); a result the voice agent already said is not read again; a failed worker is
|
|
794
|
+
announced once with its reason, a permission request or question once ("Cleanup needs your permission.").
|
|
795
|
+
Announcements queue after the sentence playing.
|
|
796
|
+
- **Barge-in** between two sentences of a reply still being written now also drops the rest of that reply
|
|
797
|
+
(before, only a reply that was playing went quiet). Barge-in never interrupts the agent or its workers.
|
|
798
|
+
- **Fix**: Voice mode's progress-cue clock and the endpointer's hold timer called `setTimeout` as a method,
|
|
799
|
+
which browsers reject ("Illegal invocation"): progress cues could stop working and an unfinished-sounding
|
|
800
|
+
utterance was never sent after the hold.
|
|
801
|
+
|
|
802
|
+
- **Faster Voice mode** (release 1 of 3):
|
|
803
|
+
- **Speak while it writes**: the hub cuts a root reply's streamed text into sentences and sends a `say` per
|
|
804
|
+
completed sentence (new `seq`, counted per text part from 0) right after its delta; `session.text.ended`
|
|
805
|
+
flushes the rest, so the first sentence plays while the agent is still writing instead of after the whole
|
|
806
|
+
reply. Code blocks are one piece (spoken as "(code omitted)"); abbreviations, decimals and file names stay
|
|
807
|
+
together. One mechanism: each sentence is sent once, also with several processes reporting the session; an
|
|
808
|
+
interrupted run or Voice mode ending drops the unspoken rest. A subagent's final result is spoken as before
|
|
809
|
+
(one `say` without `seq`). The phone plays says in order, once, and synthesizes up to two sentences ahead;
|
|
810
|
+
barge-in also silences the rest of the reply it interrupted. The push for a reply nobody watches is sent
|
|
811
|
+
once with the whole text. Clients that ignore `seq` still get each sentence's text.
|
|
812
|
+
- **Instant acknowledgement**: the "heard" tone plays the moment you stop speaking (when the segment goes to
|
|
813
|
+
transcription), not when the agent receives the utterance; it does not play twice. Typed text in Voice mode
|
|
814
|
+
still gets it on delivery.
|
|
815
|
+
- **"Working on it." after 2.5 s** (was 5 s), counted from the end of your speech, and progress cues are
|
|
816
|
+
event-driven (one timer for the next deadline instead of a 1 s poll).
|
|
817
|
+
- **Cue phrases ready at once**: "Working on it.", "Started a subagent." and "Still working." are synthesized
|
|
818
|
+
in parallel when Voice mode starts and kept in the browser's Cache Storage per TTS voice (provider, model,
|
|
819
|
+
voice, speed, server), so later sessions need no synthesis; a ready phrase starts playing immediately, and
|
|
820
|
+
one not ready yet plays a short tone instead of arriving late.
|
|
821
|
+
- **Tighter turn-taking**: a segment ends after 350 ms of silence (was 550 ms), speech must last 300 ms to
|
|
822
|
+
count (was 250 ms), and an unfinished-looking transcript is held 0.8 s (was 1.5 s); all in `VOICE_TIMINGS`.
|
|
823
|
+
- **OpenRouter speech-to-text** uploads the audio as an OpenAI-style multipart file (documented by
|
|
824
|
+
OpenRouter) instead of base64 JSON: a third less to send, no encoding.
|
|
825
|
+
- **No model lookup per utterance**: the spoke remembers each session's model (from OpenCode's
|
|
826
|
+
`session.model.selected`, its own switches and lookups) instead of asking OpenCode on every utterance with a
|
|
827
|
+
Voice model set; it asks again after its event stream reconnects.
|
|
828
|
+
- **Turn timing**: `performance.mark`s for speech end, transcript, accepted, first say and first audio, and the
|
|
829
|
+
Voice bar's state tooltip shows "last turn: 1.8 s to first audio" with the steps.
|
|
830
|
+
|
|
831
|
+
- **Resizable sidebar and Details rail** (desktop): drag the thin handle on the sidebar's right edge or the
|
|
832
|
+
rail's left edge (it lights up on hover and while dragging). Sidebar 180–480 px (default 235), rail 220–640 px
|
|
833
|
+
(default 260); both give way so the transcript column keeps at least 420 px, and are re-fitted when the window
|
|
834
|
+
resizes. The handles are focusable separators: Arrow keys resize by 16 px (Shift: 64 px), Home/End jump to the
|
|
835
|
+
minimum/maximum, a double-click resets the default. Dragging the sidebar well past its minimum collapses it (as
|
|
836
|
+
⌘B does) and expanding it restores the last width. Widths are remembered per device (`hub.panels` in local
|
|
837
|
+
storage); phones keep the drawer and bottom sheet. Long session titles in the sidebar still end in an ellipsis,
|
|
838
|
+
with the full title as a tooltip.
|
|
839
|
+
|
|
840
|
+
- **Rename sessions**: **Rename…** in a session's `⋯` menu (table, sidebar, phone list, session title menu), or
|
|
841
|
+
a click on the session title in the top bar, edits the title in place (Enter saves, Escape cancels). The title
|
|
842
|
+
is set in OpenCode itself (`session.update`), so terminals and other consoles see it; the console shows it at
|
|
843
|
+
once (also while older catalog updates arrive) and reverts with a message if renaming fails. API: `POST /api/sessions/:id/rename {title}` → `{title}`
|
|
844
|
+
(trimmed, 1–200 characters), through the owning process with a fallback to other processes that report the
|
|
845
|
+
session; Voice-off sessions can be renamed too. New spoke op `rename`: a machine still on an older plugin
|
|
846
|
+
does not answer it, so renaming its sessions fails after the 15 s request timeout until it is updated.
|
|
847
|
+
- **Archive sessions** (replaces Hide): **Archive** / **Unarchive** in a session's `⋯` menu, with **Undo**.
|
|
848
|
+
Archived sessions leave the table, sidebar groups, Recents and Running on all your devices (synced through
|
|
849
|
+
the view prefs and their SSE `prefs` event); the **Archived** chip (formerly Hidden; a saved Hidden filter
|
|
850
|
+
opens Archived) lists them. An archived session that needs you still shows under **Needs you** so a question
|
|
851
|
+
is not missed. Bulk: checkboxes in the table (and select all shown) with **Archive** / **Unarchive**, and
|
|
852
|
+
**Archive inactive…** (more than N days without activity, 30 by default; not running, pinned or open in a
|
|
853
|
+
terminal) after a confirmation with the count. Nothing is deleted and OpenCode is not changed: OpenCode has
|
|
854
|
+
no archive API yet, so archiving is the hub's own (sessions archived in OpenCode itself are still never
|
|
855
|
+
listed). Sessions hidden before are archived now: the stored list is the same `hiddenSessions`, which now
|
|
856
|
+
holds up to 5000 sessions (`PUT /api/prefs/view`; other lists stay at 500), the oldest archives making room.
|
|
857
|
+
- **Composer**: a sent message (typed or dictated) no longer comes back into the input with a raw "Failed to
|
|
858
|
+
fetch" under it when the connection of the turn's request drops after the hub took the turn (e.g. a proxy
|
|
859
|
+
cutting the long request). If the session is running, the send counts as delivered: the input and the
|
|
860
|
+
session's draft stay empty, a note says the connection was lost and the reply streams into the transcript.
|
|
861
|
+
Only when the session never starts is the text put back (as for any refused send). Network-level failures
|
|
862
|
+
of console API calls now read "Can't reach the hub: the connection dropped…" instead of the browser's
|
|
863
|
+
"Failed to fetch".
|
|
864
|
+
- **Typed turns no longer hold a request open for the whole turn**: the console sends `POST
|
|
865
|
+
/api/sessions/:id/turn` with `Prefer: respond-async`; a turn still running after 1.5 s is answered 202
|
|
866
|
+
`{mode: "turn"}` and its result (failure notes, a reply that did not stream) arrives as a new SSE `turn`
|
|
867
|
+
event. Before, the request stayed open until the turn ended, and proxies cut it (Traefik's default
|
|
868
|
+
`readTimeout` is 60 s), which the console showed as "Failed to fetch". Quick results and refusals (busy,
|
|
869
|
+
unknown session, spoke errors) answer as before; clients without the header keep the blocking answer.
|
|
870
|
+
- **Console**: table rows, sidebar rows and an open `⋯` menu no longer re-render (and close) when an unrelated
|
|
871
|
+
catalog update arrives; unchanged rows keep their DOM.
|
|
872
|
+
|
|
3
873
|
## 0.6.1
|
|
4
874
|
|
|
5
875
|
- **GitHub installs**: an install without the built web app (`web/dist`, e.g. `opencode plugin add
|