@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
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// The hub client: bearer requests to the hub's /api/agent with JSON bodies and query strings; failures become
|
|
2
|
+
// HubError (the envelope) or HubTransportError (errors.ts). Typed verbs are added on top of call()/request().
|
|
3
|
+
import { envelopeOf, HubError, HubTransportError } from "./errors.ts"
|
|
4
|
+
import { sseEvents } from "./sse.ts"
|
|
5
|
+
import type {
|
|
6
|
+
Approved,
|
|
7
|
+
AppTokenView,
|
|
8
|
+
Archived,
|
|
9
|
+
ArchiveInactivePreview,
|
|
10
|
+
ArchiveInactiveResult,
|
|
11
|
+
Asked,
|
|
12
|
+
CreatedAppToken,
|
|
13
|
+
Decision,
|
|
14
|
+
DecisionAnswered,
|
|
15
|
+
DecisionOption,
|
|
16
|
+
DecisionState,
|
|
17
|
+
DecisionTier,
|
|
18
|
+
DecisionUrgency,
|
|
19
|
+
Denied,
|
|
20
|
+
DirectoryEntry,
|
|
21
|
+
FormAnswer,
|
|
22
|
+
FormAnswered,
|
|
23
|
+
Grant,
|
|
24
|
+
GrantRequested,
|
|
25
|
+
GrantRequestSpec,
|
|
26
|
+
GrantSpec,
|
|
27
|
+
Inbox,
|
|
28
|
+
LaunchCreated,
|
|
29
|
+
LaunchSpec,
|
|
30
|
+
LaunchState,
|
|
31
|
+
LaunchView,
|
|
32
|
+
LogRow,
|
|
33
|
+
Named,
|
|
34
|
+
PromptDelivery,
|
|
35
|
+
Prompted,
|
|
36
|
+
Pinned,
|
|
37
|
+
Replied,
|
|
38
|
+
RoleClaim,
|
|
39
|
+
RoleRelease,
|
|
40
|
+
Sent,
|
|
41
|
+
SentAll,
|
|
42
|
+
SessionDetail,
|
|
43
|
+
SessionView,
|
|
44
|
+
Status,
|
|
45
|
+
Stopped,
|
|
46
|
+
WatchEvent,
|
|
47
|
+
EventsPage,
|
|
48
|
+
Whoami,
|
|
49
|
+
WebhookDelivery,
|
|
50
|
+
WebhookView,
|
|
51
|
+
} from "./types.ts"
|
|
52
|
+
|
|
53
|
+
const AGENT = "/api/agent"
|
|
54
|
+
|
|
55
|
+
/** The longest wait the hub serves in one request (seconds); longer waits loop. */
|
|
56
|
+
export const WAIT_POLL_MAX_S = 60
|
|
57
|
+
|
|
58
|
+
export type QueryValue = string | number | boolean | readonly string[] | undefined
|
|
59
|
+
|
|
60
|
+
export interface RequestOptions {
|
|
61
|
+
/** Query parameters; undefined values are skipped, arrays repeat the key. */
|
|
62
|
+
readonly query?: Readonly<Record<string, QueryValue>>
|
|
63
|
+
/** Sent as JSON. */
|
|
64
|
+
readonly body?: unknown
|
|
65
|
+
readonly signal?: AbortSignal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The part of fetch the client uses. */
|
|
69
|
+
export type Fetch = (input: string, init: RequestInit) => Promise<Response>
|
|
70
|
+
|
|
71
|
+
export interface HubClientOptions {
|
|
72
|
+
/** The hub's base URL, e.g. https://hub.example.com. */
|
|
73
|
+
readonly url: string
|
|
74
|
+
/** A session token (HUB_SESSION_TOKEN), an app token or a device token. */
|
|
75
|
+
readonly token: string
|
|
76
|
+
/** Defaults to the global fetch. */
|
|
77
|
+
readonly fetch?: Fetch
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `?a=1&b=2` (repeated keys for arrays); empty when nothing is set. */
|
|
81
|
+
export function queryString(query: Readonly<Record<string, QueryValue>> = {}): string {
|
|
82
|
+
const params = new URLSearchParams()
|
|
83
|
+
for (const [key, value] of Object.entries(query)) {
|
|
84
|
+
if (value === undefined) continue
|
|
85
|
+
for (const v of Array.isArray(value) ? value : [value]) params.append(key, String(v))
|
|
86
|
+
}
|
|
87
|
+
const text = params.toString()
|
|
88
|
+
return text ? `?${text}` : ""
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A path segment (an address or ID); `@` stays readable (it is valid in a path). */
|
|
92
|
+
export const segment = (value: string): string => encodeURIComponent(value).replace(/%40/g, "@")
|
|
93
|
+
|
|
94
|
+
/** `@@reviewer` → `reviewer` (the role routes take the bare role). */
|
|
95
|
+
const bareRole = (role: string) => role.replace(/^@@/, "")
|
|
96
|
+
|
|
97
|
+
/** Only an abort signal. */
|
|
98
|
+
export interface CallOptions {
|
|
99
|
+
readonly signal?: AbortSignal
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SessionsFilter extends CallOptions {
|
|
103
|
+
readonly project?: string
|
|
104
|
+
/** One state or several (sent comma-separated). */
|
|
105
|
+
readonly state?: string | readonly string[]
|
|
106
|
+
readonly machine?: string
|
|
107
|
+
/** `key=value` labels, all of which must match. */
|
|
108
|
+
readonly label?: readonly string[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface LaunchesFilter extends CallOptions {
|
|
112
|
+
/** One state or several (sent comma-separated). */
|
|
113
|
+
readonly state?: LaunchState | readonly LaunchState[]
|
|
114
|
+
/** `key=value` launch labels, all of which must match. */
|
|
115
|
+
readonly label?: readonly string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** hub.launches: start sessions (users; app tokens with sessions:launch and a launch grant; sessions by act token). */
|
|
119
|
+
export interface Launches {
|
|
120
|
+
/** Starts a launch; the same key and body again answers the same launch (created false). */
|
|
121
|
+
create(spec: LaunchSpec, options?: CallOptions): Promise<LaunchCreated>
|
|
122
|
+
/** The caller's launches, oldest first (apps and sessions: only those they started). */
|
|
123
|
+
list(filter?: LaunchesFilter): Promise<{ launches: LaunchView[] }>
|
|
124
|
+
get(id: string, options?: CallOptions): Promise<{ launch: LaunchView }>
|
|
125
|
+
/** Ends a launch (outcome stopped); one that already ended is gone (HubError). */
|
|
126
|
+
stop(id: string, options?: CallOptions): Promise<{ launch: LaunchView }>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TargetOptions extends CallOptions {
|
|
130
|
+
/** Act on this session (address) instead of the caller's own. */
|
|
131
|
+
readonly session?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RoleOptions extends TargetOptions {
|
|
135
|
+
readonly project?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface MsgOptions extends CallOptions {
|
|
139
|
+
/** false: admit without starting a turn. */
|
|
140
|
+
readonly wake?: boolean
|
|
141
|
+
/** Steer into a running turn. */
|
|
142
|
+
readonly urgent?: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface AskOptions extends MsgOptions {
|
|
146
|
+
/** Seconds until the ask expires (the hub's default when unset). */
|
|
147
|
+
readonly expires?: number
|
|
148
|
+
/** Seconds to wait for the answer; the hub serves at most WAIT_POLL_MAX_S per request, so longer waits loop. */
|
|
149
|
+
readonly wait?: number
|
|
150
|
+
// Asks to @me open a Decision (design §12.1) with these:
|
|
151
|
+
readonly options?: readonly DecisionOption[]
|
|
152
|
+
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
153
|
+
readonly urgency?: DecisionUrgency
|
|
154
|
+
readonly tier?: DecisionTier
|
|
155
|
+
/** Seconds until the deadline. */
|
|
156
|
+
readonly deadline?: number
|
|
157
|
+
/** A safe option taken at the deadline. */
|
|
158
|
+
readonly onTimeout?: string
|
|
159
|
+
/** Addresses of the sessions it concerns. */
|
|
160
|
+
readonly about?: readonly string[]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The Decision fields of AskOptions, when given. */
|
|
164
|
+
const decisionFields = (options: AskOptions) => {
|
|
165
|
+
const out: Record<string, unknown> = {}
|
|
166
|
+
for (const key of ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const) if (options[key] !== undefined) out[key] = options[key]
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Options of the act verbs: the reason, recorded in the agent audit (deny passes it to the harness). */
|
|
171
|
+
export interface ActOptions extends CallOptions {
|
|
172
|
+
readonly why?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface ApproveOptions extends ActOptions {
|
|
176
|
+
/** Approve for good (OpenCode saves the rule); agents need a grant with allowAlways. */
|
|
177
|
+
readonly always?: boolean
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface PromptOptions extends ActOptions {
|
|
181
|
+
readonly delivery?: PromptDelivery
|
|
182
|
+
/** Agents' prompts always wake the session (false is refused). */
|
|
183
|
+
readonly wake?: boolean
|
|
184
|
+
/** Users only. */
|
|
185
|
+
readonly images?: readonly { readonly mime: string; readonly data: string; readonly name?: string }[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The why of an act verb, when given. */
|
|
189
|
+
const whyField = (options: ActOptions) => (options.why !== undefined ? { why: options.why } : {})
|
|
190
|
+
|
|
191
|
+
const signalOf = (options: CallOptions) => (options.signal ? { signal: options.signal } : {})
|
|
192
|
+
/** The wake and urgent fields of a send, when given. */
|
|
193
|
+
const sendFields = (options: MsgOptions) => ({ ...(options.wake !== undefined ? { wake: options.wake } : {}), ...(options.urgent !== undefined ? { urgent: options.urgent } : {}) })
|
|
194
|
+
/** The session and project fields of a role request, when given. */
|
|
195
|
+
const roleTarget = (options: RoleOptions) => ({ ...(options.session ? { session: options.session } : {}), ...(options.project !== undefined ? { project: options.project } : {}) })
|
|
196
|
+
|
|
197
|
+
export class HubClient {
|
|
198
|
+
/** The URL as given (used in messages). */
|
|
199
|
+
readonly url: string
|
|
200
|
+
readonly #base: string
|
|
201
|
+
readonly #token: string
|
|
202
|
+
readonly #fetch: Fetch
|
|
203
|
+
|
|
204
|
+
/** Launches (design §6.1): create, list, get, stop. */
|
|
205
|
+
readonly launches: Launches = {
|
|
206
|
+
create: async (spec, options = {}) => {
|
|
207
|
+
const response = await this.request("POST", `${AGENT}/launches`, { body: spec, ...signalOf(options) })
|
|
208
|
+
const body = (await this.#json(response, `${AGENT}/launches`)) as { launch: LaunchView }
|
|
209
|
+
return { launch: body.launch, created: response.status === 201 }
|
|
210
|
+
},
|
|
211
|
+
list: (filter = {}) => {
|
|
212
|
+
const state = typeof filter.state === "string" ? filter.state : filter.state?.join(",")
|
|
213
|
+
return this.call("GET", `${AGENT}/launches`, { query: { state, label: filter.label }, ...signalOf(filter) })
|
|
214
|
+
},
|
|
215
|
+
get: (id, options = {}) => this.call("GET", `${AGENT}/launches/${segment(id)}`, signalOf(options)),
|
|
216
|
+
stop: (id, options = {}) => this.call("DELETE", `${AGENT}/launches/${segment(id)}`, signalOf(options)),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
constructor(options: HubClientOptions) {
|
|
220
|
+
this.url = options.url
|
|
221
|
+
this.#base = options.url.replace(/\/+$/, "")
|
|
222
|
+
this.#token = options.token
|
|
223
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Sends a request; resolves the response when OK, else throws HubError / HubTransportError. */
|
|
227
|
+
async request(method: string, pathname: string, options: RequestOptions = {}): Promise<Response> {
|
|
228
|
+
const target = `${pathname}${queryString(options.query)}`
|
|
229
|
+
const headers: Record<string, string> = { authorization: `Bearer ${this.#token}`, accept: "application/json" }
|
|
230
|
+
if (options.body !== undefined) headers["content-type"] = "application/json"
|
|
231
|
+
let response: Response
|
|
232
|
+
try {
|
|
233
|
+
response = await this.#fetch(`${this.#base}${target}`, {
|
|
234
|
+
method,
|
|
235
|
+
headers,
|
|
236
|
+
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
|
237
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
238
|
+
redirect: "manual",
|
|
239
|
+
})
|
|
240
|
+
} catch (cause) {
|
|
241
|
+
if (options.signal?.aborted) throw cause
|
|
242
|
+
throw new HubTransportError("unreachable", `cannot reach ${this.url} (${cause instanceof Error ? cause.message : String(cause)})`, { cause })
|
|
243
|
+
}
|
|
244
|
+
if (response.ok) return response
|
|
245
|
+
const text = await response.text().catch(() => "")
|
|
246
|
+
let body: unknown
|
|
247
|
+
try {
|
|
248
|
+
body = JSON.parse(text)
|
|
249
|
+
} catch {
|
|
250
|
+
body = undefined
|
|
251
|
+
}
|
|
252
|
+
const envelope = envelopeOf(body)
|
|
253
|
+
if (envelope) throw new HubError(response.status, envelope)
|
|
254
|
+
throw new HubTransportError("http", `HTTP ${response.status} from ${this.url}${target}`, { status: response.status })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** request() answering JSON (undefined for 204). */
|
|
258
|
+
async call<T = unknown>(method: string, pathname: string, options: RequestOptions = {}): Promise<T> {
|
|
259
|
+
const response = await this.request(method, pathname, options)
|
|
260
|
+
if (response.status === 204) return undefined as T
|
|
261
|
+
return (await this.#json(response, `${pathname}${queryString(options.query)}`)) as T
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async #json(response: Response, target: string): Promise<unknown> {
|
|
265
|
+
try {
|
|
266
|
+
return await response.json()
|
|
267
|
+
} catch (cause) {
|
|
268
|
+
throw new HubTransportError("bad_response", `the hub at ${this.url} did not answer JSON for ${target}`, { cause })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Reading (sessions:read).
|
|
273
|
+
|
|
274
|
+
whoami(options: CallOptions = {}): Promise<Whoami> {
|
|
275
|
+
return this.call("GET", `${AGENT}/whoami`, signalOf(options))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
status(options: CallOptions = {}): Promise<Status> {
|
|
279
|
+
return this.call("GET", `${AGENT}/status`, signalOf(options))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
sessions(filter: SessionsFilter = {}): Promise<{ sessions: SessionView[] }> {
|
|
283
|
+
const state = typeof filter.state === "string" ? filter.state : filter.state?.join(",")
|
|
284
|
+
return this.call("GET", `${AGENT}/sessions`, {
|
|
285
|
+
query: { project: filter.project, state, machine: filter.machine, label: filter.label },
|
|
286
|
+
...signalOf(filter),
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One session by address (ses_… or a prefix, @name, @@role, @name.project). */
|
|
291
|
+
show(addr: string, options: CallOptions = {}): Promise<SessionDetail> {
|
|
292
|
+
return this.call("GET", `${AGENT}/sessions/${segment(addr)}`, signalOf(options))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The newest timeline rows (n: 1–200, the hub's default when unset). */
|
|
296
|
+
log(addr: string, options: CallOptions & { readonly n?: number | string } = {}): Promise<{ rows: LogRow[] }> {
|
|
297
|
+
return this.call("GET", `${AGENT}/sessions/${segment(addr)}/log`, { query: { n: options.n }, ...signalOf(options) })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Named and role-holding sessions. */
|
|
301
|
+
who(options: CallOptions & { readonly project?: string; readonly role?: string } = {}): Promise<{ sessions: DirectoryEntry[] }> {
|
|
302
|
+
return this.call("GET", `${AGENT}/who`, { query: { project: options.project, role: options.role === undefined ? undefined : bareRole(options.role) }, ...signalOf(options) })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* GET /api/agent/watch: the journal's compact events (the same as events()) as they happen, until the hub closes
|
|
307
|
+
* the stream or `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
|
|
308
|
+
* address; cursor: resume after an event's `id` (or an events() cursor).
|
|
309
|
+
*/
|
|
310
|
+
async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string; readonly cursor?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
|
|
311
|
+
const { signal } = options
|
|
312
|
+
if (signal?.aborted) return
|
|
313
|
+
let response: Response
|
|
314
|
+
try {
|
|
315
|
+
response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session, cursor: options.cursor }, ...signalOf(options) })
|
|
316
|
+
} catch (cause) {
|
|
317
|
+
if (signal?.aborted) return
|
|
318
|
+
throw cause
|
|
319
|
+
}
|
|
320
|
+
yield* sseEvents(response, signal)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* GET /api/agent/events: the compact events after `cursor` (none: start at the latest). wait: seconds (0–60) to
|
|
325
|
+
* hold while nothing matches; limit 1–200; types (exact, or `launch.*`) and sessions filter but still advance the
|
|
326
|
+
* cursor. App tokens need events:subscribe plus each family's scope.
|
|
327
|
+
*/
|
|
328
|
+
events(options: CallOptions & { readonly cursor?: string; readonly wait?: number; readonly limit?: number; readonly types?: readonly string[]; readonly sessions?: readonly string[] } = {}): Promise<EventsPage> {
|
|
329
|
+
return this.call("GET", `${AGENT}/events`, { query: { cursor: options.cursor, wait: options.wait, limit: options.limit, type: options.types, session: options.sessions }, ...signalOf(options) })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
webhookCreate(input: { readonly url: string; readonly events?: readonly string[]; readonly label?: string }, options: CallOptions = {}): Promise<{ webhook: WebhookView; secret: string }> {
|
|
333
|
+
return this.call("POST", `${AGENT}/webhooks`, { body: input, ...signalOf(options) })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
webhooks(options: CallOptions = {}): Promise<{ webhooks: WebhookView[] }> {
|
|
337
|
+
return this.call("GET", `${AGENT}/webhooks`, signalOf(options))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
webhook(id: string, options: CallOptions = {}): Promise<{ webhook: WebhookView; deliveries: WebhookDelivery[] }> {
|
|
341
|
+
return this.call("GET", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
webhookDelete(id: string, options: CallOptions = {}): Promise<void> {
|
|
345
|
+
return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Organization (sessions:manage).
|
|
349
|
+
|
|
350
|
+
setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
|
|
351
|
+
return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/archive`, { body: { archived }, ...signalOf(options) })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setPinned(addr: string, pinned: boolean, options: CallOptions = {}): Promise<Pinned> {
|
|
355
|
+
return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/pin`, { body: { pinned }, ...signalOf(options) })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
previewArchiveInactive(inactiveFor: string, limit: number, options: CallOptions = {}): Promise<ArchiveInactivePreview> {
|
|
359
|
+
return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { preview: true, inactiveFor, limit }, ...signalOf(options) })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
archiveInactive(previewToken: string, options: CallOptions = {}): Promise<ArchiveInactiveResult> {
|
|
363
|
+
return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { previewToken, confirm: true }, ...signalOf(options) })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Naming.
|
|
367
|
+
|
|
368
|
+
name(name: string, options: TargetOptions = {}): Promise<Named> {
|
|
369
|
+
return this.call("POST", `${AGENT}/name`, { body: { name, ...(options.session ? { session: options.session } : {}) }, ...signalOf(options) })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
claimRole(role: string, options: RoleOptions & { readonly exclusive?: boolean } = {}): Promise<RoleClaim> {
|
|
373
|
+
return this.call("POST", `${AGENT}/roles/claim`, { body: { role: bareRole(role), ...(options.exclusive ? { exclusive: true } : {}), ...roleTarget(options) }, ...signalOf(options) })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
releaseRole(role: string, options: RoleOptions = {}): Promise<RoleRelease> {
|
|
377
|
+
return this.call("POST", `${AGENT}/roles/release`, { body: { role: bareRole(role), ...roleTarget(options) }, ...signalOf(options) })
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Messaging (messages:send / messages:read).
|
|
381
|
+
|
|
382
|
+
/** Tells a session something (fire and forget). */
|
|
383
|
+
msg(to: string, text: string, options?: MsgOptions & { readonly all?: false }): Promise<Sent>
|
|
384
|
+
/** To every session the address names (each holder of a role). */
|
|
385
|
+
msg(to: string, text: string, options: MsgOptions & { readonly all: true }): Promise<SentAll>
|
|
386
|
+
msg(to: string, text: string, options: MsgOptions & { readonly all?: boolean } = {}): Promise<Sent | SentAll> {
|
|
387
|
+
return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...signalOf(options) })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Asks a session; with wait, polls (≤WAIT_POLL_MAX_S each) until it is answered, declined or expired, or the wait is spent. */
|
|
391
|
+
ask(to: string, text: string, options?: AskOptions & { readonly all?: false }): Promise<Asked>
|
|
392
|
+
/** To every session the address names (no wait: TypeError). */
|
|
393
|
+
ask(to: string, text: string, options: AskOptions & { readonly all: true }): Promise<SentAll>
|
|
394
|
+
async ask(to: string, text: string, options: AskOptions & { readonly all?: boolean } = {}): Promise<Asked | SentAll> {
|
|
395
|
+
if (options.all && options.wait !== undefined) throw new TypeError("ask: wait cannot be combined with all")
|
|
396
|
+
let left = options.wait ?? 0
|
|
397
|
+
const first = Math.min(left, WAIT_POLL_MAX_S)
|
|
398
|
+
left -= first
|
|
399
|
+
const body = {
|
|
400
|
+
to,
|
|
401
|
+
text,
|
|
402
|
+
...sendFields(options),
|
|
403
|
+
...(options.all ? { all: true } : {}),
|
|
404
|
+
...(options.expires !== undefined ? { expiresIn: options.expires } : {}),
|
|
405
|
+
...(options.wait !== undefined ? { wait: first } : {}),
|
|
406
|
+
...decisionFields(options),
|
|
407
|
+
}
|
|
408
|
+
const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...signalOf(options) })
|
|
409
|
+
if ("sent" in asked) return asked
|
|
410
|
+
let current: Asked = asked
|
|
411
|
+
while (current.message.state === "open" && left > 0 && !options.signal?.aborted) {
|
|
412
|
+
const wait = Math.min(left, WAIT_POLL_MAX_S)
|
|
413
|
+
left -= wait
|
|
414
|
+
current = { ...(asked.delivery ? { delivery: asked.delivery } : {}), ...(asked.decision ? { decision: asked.decision } : {}), ...(await this.message(current.message.id, { wait, ...signalOf(options) })) }
|
|
415
|
+
}
|
|
416
|
+
return current
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** One message the caller sent or received; wait (≤WAIT_POLL_MAX_S seconds) long-polls an open ask. */
|
|
420
|
+
message(id: string, options: CallOptions & { readonly wait?: number } = {}): Promise<Asked> {
|
|
421
|
+
return this.call("GET", `${AGENT}/messages/${segment(id)}`, { query: { wait: options.wait }, ...signalOf(options) })
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Replies to a message (always to its real sender). */
|
|
425
|
+
reply(id: string, text: string, options: CallOptions & { readonly wake?: boolean } = {}): Promise<Replied> {
|
|
426
|
+
return this.call("POST", `${AGENT}/reply`, { body: { replyTo: id, text, ...(options.wake !== undefined ? { wake: options.wake } : {}) }, ...signalOf(options) })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Declines an ask, with a reason. */
|
|
430
|
+
decline(id: string, why: string, options: CallOptions = {}): Promise<Replied> {
|
|
431
|
+
return this.call("POST", `${AGENT}/decline`, { body: { replyTo: id, why }, ...signalOf(options) })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Open asks to the caller, its own waiting asks, recent messages and mutes. */
|
|
435
|
+
inbox(options: CallOptions = {}): Promise<Inbox> {
|
|
436
|
+
return this.call("GET", `${AGENT}/inbox`, signalOf(options))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Refuses messages from a session (an address). */
|
|
440
|
+
mute(session: string, options: CallOptions = {}): Promise<{ muted: string[] }> {
|
|
441
|
+
return this.call("POST", `${AGENT}/mute`, { body: { session }, ...signalOf(options) })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
unmute(session: string, options: CallOptions = {}): Promise<{ muted: string[] }> {
|
|
445
|
+
return this.call("POST", `${AGENT}/unmute`, { body: { session }, ...signalOf(options) })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Decisions (asks to @me).
|
|
449
|
+
|
|
450
|
+
/** Decisions the caller asked (the user: all of theirs), newest first. */
|
|
451
|
+
decisions(options: CallOptions & { readonly state?: DecisionState } = {}): Promise<{ decisions: Decision[] }> {
|
|
452
|
+
return this.call("GET", `${AGENT}/decisions`, { query: { state: options.state }, ...signalOf(options) })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
decision(id: string, options: CallOptions = {}): Promise<{ decision: Decision }> {
|
|
456
|
+
return this.call("GET", `${AGENT}/decisions/${segment(id)}`, signalOf(options))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Answers a Decision as the user (a device token; tap Decisions need the console): first answer wins, later ones are gone. */
|
|
460
|
+
answerDecision(id: string, answer: CallOptions & { readonly option?: string; readonly text?: string }): Promise<DecisionAnswered> {
|
|
461
|
+
const { signal: _signal, ...body } = answer
|
|
462
|
+
return this.call("POST", `/api/decisions/${segment(id)}/answer`, { body, ...signalOf(answer) })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Acting (users; sessions through their act token; app tokens with sessions:act). Outside the caller's grant the
|
|
466
|
+
// hub answers needs_user (HubError; needsUserOf reads its evaluation).
|
|
467
|
+
|
|
468
|
+
/** Approves a pending permission request in a session's tree. */
|
|
469
|
+
approve(addr: string, requestID: string, options: ApproveOptions = {}): Promise<Approved> {
|
|
470
|
+
const body = { requestID, ...(options.always ? { always: true } : {}), ...whyField(options) }
|
|
471
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/approve`, { body, ...signalOf(options) })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Rejects a pending permission request; the why reaches the harness (with who denied it). */
|
|
475
|
+
deny(addr: string, requestID: string, options: ActOptions = {}): Promise<Denied> {
|
|
476
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/deny`, { body: { requestID, ...whyField(options) }, ...signalOf(options) })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Answers a pending form (question). */
|
|
480
|
+
answer(addr: string, formID: string, answer: FormAnswer, options: ActOptions = {}): Promise<FormAnswered> {
|
|
481
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/answer`, { body: { formID, answer, ...whyField(options) }, ...signalOf(options) })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Dismisses a pending form without an answer. */
|
|
485
|
+
cancelForm(addr: string, formID: string, options: ActOptions = {}): Promise<FormAnswered> {
|
|
486
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/answer`, { body: { formID, cancel: true, ...whyField(options) }, ...signalOf(options) })
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Prompts a session (agents' prompts carry the hub's provenance). */
|
|
490
|
+
prompt(addr: string, text: string, options: PromptOptions = {}): Promise<Prompted> {
|
|
491
|
+
const body = {
|
|
492
|
+
text,
|
|
493
|
+
...(options.images ? { images: options.images } : {}),
|
|
494
|
+
...(options.delivery ? { delivery: options.delivery } : {}),
|
|
495
|
+
...(options.wake !== undefined ? { wake: options.wake } : {}),
|
|
496
|
+
...whyField(options),
|
|
497
|
+
}
|
|
498
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/prompt`, { body, ...signalOf(options) })
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Stops a session's running turn. */
|
|
502
|
+
stop(addr: string, options: ActOptions = {}): Promise<Stopped> {
|
|
503
|
+
return this.call("POST", `${AGENT}/sessions/${segment(addr)}/stop`, { body: whyField(options), ...signalOf(options) })
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Grants (user credentials only: a session or app token is forbidden).
|
|
507
|
+
|
|
508
|
+
/** The user's grants, newest first. */
|
|
509
|
+
grants(options: CallOptions = {}): Promise<{ grants: Grant[] }> {
|
|
510
|
+
return this.call("GET", `${AGENT}/grants`, signalOf(options))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
grant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
|
|
514
|
+
return this.call("GET", `${AGENT}/grants/${segment(id)}`, signalOf(options))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
createGrant(spec: GrantSpec, options: CallOptions = {}): Promise<{ grant: Grant }> {
|
|
518
|
+
return this.call("POST", `${AGENT}/grants`, { body: spec, ...signalOf(options) })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Replaces a grant (its holder stays). */
|
|
522
|
+
updateGrant(id: string, spec: GrantSpec, options: CallOptions = {}): Promise<{ grant: Grant }> {
|
|
523
|
+
return this.call("PUT", `${AGENT}/grants/${segment(id)}`, { body: spec, ...signalOf(options) })
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Revokes a grant (gone when it already ended). */
|
|
527
|
+
revokeGrant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
|
|
528
|
+
return this.call("DELETE", `${AGENT}/grants/${segment(id)}`, signalOf(options))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Asks the user for a grant (sessions; app tokens with messages:send): opens a tap Decision for the user, whose
|
|
533
|
+
* approval creates the grant; the answer comes back as a reply to the ask (with the grant id when approved).
|
|
534
|
+
*/
|
|
535
|
+
requestGrant(grant: GrantRequestSpec, why: string, options: CallOptions = {}): Promise<GrantRequested> {
|
|
536
|
+
return this.call("POST", `${AGENT}/grants/requests`, { body: { grant, why }, ...signalOf(options) })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// App tokens (user credentials only).
|
|
540
|
+
|
|
541
|
+
createAppToken(input: CallOptions & { readonly label?: string; readonly scopes?: readonly string[] } = {}): Promise<CreatedAppToken> {
|
|
542
|
+
const { signal: _signal, ...body } = input
|
|
543
|
+
return this.call("POST", `${AGENT}/app-tokens`, { body, ...signalOf(input) })
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
appTokens(options: CallOptions = {}): Promise<{ tokens: AppTokenView[] }> {
|
|
547
|
+
return this.call("GET", `${AGENT}/app-tokens`, signalOf(options))
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
revokeAppToken(id: string, options: CallOptions = {}): Promise<void> {
|
|
551
|
+
return this.call("DELETE", `${AGENT}/app-tokens/${segment(id)}`, signalOf(options))
|
|
552
|
+
}
|
|
553
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The message envelope (design §3.3) and its sanitiser: pure functions shared by the hub (which renders what a
|
|
2
|
+
// recipient session sees), the OpenCode plugin's hub tool and `hub mcp` (which render messages they read).
|
|
3
|
+
//
|
|
4
|
+
// - Text and every interpolated label are sanitised: ANSI escape sequences, C0 (except tab and newline), DEL,
|
|
5
|
+
// C1, bidi embeddings/overrides/isolates, zero-width characters, BOM and line/paragraph separators go.
|
|
6
|
+
// - The body sits between two per-message boundary lines; body lines that look like a header, a boundary, a
|
|
7
|
+
// reply hint (or an escaped one) are escaped with a leading backslash, so a body cannot forge any of them.
|
|
8
|
+
// - The sender is the hub's to set (from the authenticated actor); nothing here trusts a request body for it.
|
|
9
|
+
import type { Party } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
/** Interpolated labels (names, harness, machine, the address as written) are cut to this many characters. */
|
|
12
|
+
export const LABEL_MAX = 120
|
|
13
|
+
|
|
14
|
+
export type EnvelopeKind = "msg" | "ask" | "reply" | "decline"
|
|
15
|
+
|
|
16
|
+
export interface EnvelopeInput {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly kind: EnvelopeKind
|
|
19
|
+
/** The sender, from partyFromActor. */
|
|
20
|
+
readonly from: Party
|
|
21
|
+
/** The address as the sender wrote it (`@@reviewer`, `@x`, `ses_…`). */
|
|
22
|
+
readonly to: string
|
|
23
|
+
readonly at: number
|
|
24
|
+
readonly text: string
|
|
25
|
+
/** What a reply or decline answers. */
|
|
26
|
+
readonly replyTo?: string
|
|
27
|
+
/** When an ask expires. */
|
|
28
|
+
readonly expiresAt?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** ANSI escape sequences: CSI, OSC (BEL or ST), DCS/PM/APC strings, other ESC sequences, 8-bit CSI. */
|
|
32
|
+
const ANSI =
|
|
33
|
+
/\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b[P^_][^\u001b]*(?:\u001b\\)?|\u001b[ -/]*[0-~]?|\u009b[0-?]*[ -/]*[@-~]/g
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* C0 except tab/newline, DEL, C1, Arabic letter mark, zero-width and directional marks (U+200B-200F), line and
|
|
37
|
+
* paragraph separators and bidi embeddings/overrides (U+2028-202E), word joiner, invisible operators and bidi
|
|
38
|
+
* isolates (U+2060-206F), BOM.
|
|
39
|
+
*/
|
|
40
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g
|
|
41
|
+
|
|
42
|
+
/** Message text: newlines normalised, escape sequences and invisible/control characters removed. */
|
|
43
|
+
export function sanitizeText(text: string): string {
|
|
44
|
+
return text.replace(/\r\n?/g, "\n").replace(ANSI, "").replace(CONTROL, "")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A label: sanitised, one line (whitespace runs become one space), trimmed, at most `max` characters. */
|
|
48
|
+
export function sanitizeLabel(text: string, max = LABEL_MAX): string {
|
|
49
|
+
return Array.from(sanitizeText(text).replace(/\s+/g, " ").trim()).slice(0, max).join("").trimEnd()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How a party reads in an envelope: `@name (harness · machine)`, `ses_…`, `the user`, `app <label>`. */
|
|
53
|
+
export function partyLabel(party: Party): string {
|
|
54
|
+
switch (party.kind) {
|
|
55
|
+
case "user":
|
|
56
|
+
return "the user"
|
|
57
|
+
case "app":
|
|
58
|
+
return `app ${sanitizeLabel(party.label ?? "") || party.tokenID}`
|
|
59
|
+
case "session": {
|
|
60
|
+
const name = sanitizeLabel(party.name ?? "")
|
|
61
|
+
const details = [party.harness, party.machine].map((value) => sanitizeLabel(value ?? "")).filter(Boolean)
|
|
62
|
+
return `${name ? `@${name}` : party.sessionID}${details.length ? ` (${details.join(" · ")})` : ""}`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `MM-DD HH:MMZ` in UTC. */
|
|
68
|
+
function stamp(at: number): string {
|
|
69
|
+
const iso = new Date(at).toISOString()
|
|
70
|
+
return `${iso.slice(5, 10)} ${iso.slice(11, 16)}Z`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Lines a body must not start with: a header, a boundary or rule, a reply hint, or an escaped one of those. */
|
|
74
|
+
const FORGEABLE = /^\s*\\*(?:\[hub\]|-{3,}|↩)/i
|
|
75
|
+
|
|
76
|
+
/** The sanitised body with forgeable lines escaped by a leading backslash. */
|
|
77
|
+
function escapeBody(text: string): string {
|
|
78
|
+
return sanitizeText(text)
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => (FORGEABLE.test(line) ? `\\${line}` : line))
|
|
81
|
+
.join("\n")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The text a recipient session sees for a message. */
|
|
85
|
+
export function renderEnvelope(input: EnvelopeInput): string {
|
|
86
|
+
const { id, kind } = input
|
|
87
|
+
const to = sanitizeLabel(input.to)
|
|
88
|
+
const target =
|
|
89
|
+
kind === "reply" || kind === "decline" ? `to you · re ${sanitizeLabel(input.replyTo ?? "")}` : to.startsWith("@@") ? `to ${to} → you` : "to you"
|
|
90
|
+
const header = `[hub] ${kind} from ${partyLabel(input.from)} ${target} · ${stamp(input.at)} · ${id}`
|
|
91
|
+
const boundary = `----- ${id} -----`
|
|
92
|
+
const hint =
|
|
93
|
+
kind === "ask"
|
|
94
|
+
? `↩ hub reply ${id} "…" · hub decline ${id} --why "…"${input.expiresAt === undefined ? "" : ` · expires ${stamp(input.expiresAt)}`}`
|
|
95
|
+
: `↩ hub reply ${id} "…"`
|
|
96
|
+
return [header, boundary, escapeBody(input.text), boundary, hint].join("\n")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The one-time identity primer (design §2.3): who the session is, how to message, and that hub messages are not the user's. */
|
|
100
|
+
export function renderPrimer(input: { readonly name?: string; readonly sessionID?: string; readonly roles: readonly string[] }): string {
|
|
101
|
+
const name = sanitizeLabel(input.name ?? "")
|
|
102
|
+
const roles = input.roles.map((role) => sanitizeLabel(role)).filter(Boolean)
|
|
103
|
+
const self = name ? `@${name}` : sanitizeLabel(input.sessionID ?? "") || "this session"
|
|
104
|
+
return (
|
|
105
|
+
`[hub] You are ${self}${roles.length ? ` (roles: ${roles.map((role) => `@@${role}`).join(", ")})` : ""}. Message others with \`hub msg @name\`; see \`hub who\`. ` +
|
|
106
|
+
"Messages headed [hub] come through the hub from other sessions or apps, not from the user's prompt."
|
|
107
|
+
)
|
|
108
|
+
}
|