@huynle/hub 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +309 -0
- package/Dockerfile +5 -1
- package/README.md +153 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +88 -5
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +517 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/push-target.ts +11 -2
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// The app front (docs/app-front.md; contract docs/plans/2026-11-08-app-front-contracts.md) for an app's SERVER (a
|
|
2
|
+
// BFF): connect (OAuth 2.1-style code + PKCE for a confidential client: the authorize URL, the callback checks, the
|
|
3
|
+
// code exchange, refresh and revoke, and ConnectionTokens, a per-connection token keeper whose refresh is
|
|
4
|
+
// single-flight), speech (transcribe, speak), conversation (the stream as an async iterator that resumes,
|
|
5
|
+
// conversation on/off, utterances, interrupt; typed turns are hub.prompt), and the app's Decisions (the answerable
|
|
6
|
+
// list, answer). Tokens never belong in a browser: the hub sends no CORS headers, and the app's page talks to the
|
|
7
|
+
// app's own server only.
|
|
8
|
+
//
|
|
9
|
+
// The wire types mirror packages/protocol (app-front.ts and schema/app-front/v1.schema.json); the SDK imports nothing
|
|
10
|
+
// outside its package.
|
|
11
|
+
import { HubError, HubTransportError } from "./errors.ts"
|
|
12
|
+
import { SseParser } from "./sse.ts"
|
|
13
|
+
import type { Decision, DecisionAnswered, DecisionState } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
const AGENT = "/api/agent"
|
|
16
|
+
|
|
17
|
+
interface Signal {
|
|
18
|
+
readonly signal?: AbortSignal
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The part of fetch the connect helpers use. */
|
|
22
|
+
type Fetch = (input: string, init: RequestInit) => Promise<Response>
|
|
23
|
+
|
|
24
|
+
const sig = (options: Signal = {}) => (options.signal ? { signal: options.signal } : {})
|
|
25
|
+
const seg = (value: string) => encodeURIComponent(value).replace(/%40/g, "@")
|
|
26
|
+
|
|
27
|
+
// ---------- connect ----------
|
|
28
|
+
|
|
29
|
+
/** POST /connect/token's answer. */
|
|
30
|
+
export interface TokenResponse {
|
|
31
|
+
readonly access_token: string
|
|
32
|
+
readonly token_type: "Bearer"
|
|
33
|
+
/** Seconds (3600). */
|
|
34
|
+
readonly expires_in: number
|
|
35
|
+
readonly refresh_token: string
|
|
36
|
+
/** The granted scopes, space-separated. */
|
|
37
|
+
readonly scope: string
|
|
38
|
+
/** The app is `app:<connection_id>` on the hub, stable across refreshes and re-consent. */
|
|
39
|
+
readonly connection_id: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A connection's current token pair, as an app keeps it (exactly one per connection). */
|
|
43
|
+
export interface ConnectionPair {
|
|
44
|
+
readonly accessToken: string
|
|
45
|
+
readonly refreshToken: string
|
|
46
|
+
/** Epoch ms when the access token expires. */
|
|
47
|
+
readonly expiresAt: number
|
|
48
|
+
readonly scopes: readonly string[]
|
|
49
|
+
readonly connectionID: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The pair a token response gives, from `now`. */
|
|
53
|
+
export const pairOf = (token: TokenResponse, now = Date.now()): ConnectionPair => ({
|
|
54
|
+
accessToken: token.access_token,
|
|
55
|
+
refreshToken: token.refresh_token,
|
|
56
|
+
expiresAt: now + token.expires_in * 1000,
|
|
57
|
+
scopes: token.scope.split(" ").filter(Boolean),
|
|
58
|
+
connectionID: token.connection_id,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An OAuth error: from /connect/token or /connect/revoke ({error, error_description}, with the HTTP status), or a
|
|
63
|
+
* callback the app must refuse (status 0: invalid_state, invalid_issuer, the hub's redirect error), or not_connected.
|
|
64
|
+
*/
|
|
65
|
+
export class HubOAuthError extends Error {
|
|
66
|
+
override readonly name = "HubOAuthError"
|
|
67
|
+
constructor(
|
|
68
|
+
readonly status: number,
|
|
69
|
+
/** invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope, temporarily_unavailable, … */
|
|
70
|
+
readonly error: string,
|
|
71
|
+
readonly description?: string,
|
|
72
|
+
/** Seconds (Retry-After of a 429). */
|
|
73
|
+
readonly retryAfter?: number,
|
|
74
|
+
) {
|
|
75
|
+
super(description ? `${error}: ${description}` : error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A PKCE pair: keep `verifier` on the server for the exchange, send `challenge` (S256) in the authorize URL. */
|
|
80
|
+
export async function pkcePair(): Promise<{ readonly verifier: string; readonly challenge: string }> {
|
|
81
|
+
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
|
|
82
|
+
return { verifier, challenge: await pkceChallenge(verifier) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** base64url(SHA-256(verifier)). */
|
|
86
|
+
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
87
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An unguessable `state` (or any random token), 32 base64url characters. */
|
|
91
|
+
export const randomState = (): string => base64url(crypto.getRandomValues(new Uint8Array(24)))
|
|
92
|
+
|
|
93
|
+
function base64url(bytes: Uint8Array): string {
|
|
94
|
+
let text = ""
|
|
95
|
+
for (const b of bytes) text += String.fromCharCode(b)
|
|
96
|
+
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface HubConnectOptions {
|
|
100
|
+
/** The hub's base URL (also the issuer: the callback's `iss` must be it). */
|
|
101
|
+
readonly url: string
|
|
102
|
+
readonly clientID: string
|
|
103
|
+
/** The client secret (hcs_…): server-side only. */
|
|
104
|
+
readonly clientSecret: string
|
|
105
|
+
/** The redirect URI exactly as registered. */
|
|
106
|
+
readonly redirectURI: string
|
|
107
|
+
readonly fetch?: Fetch
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The app server's side of connect: the authorize URL, the callback, the code exchange, refresh and revoke. */
|
|
111
|
+
export class HubConnect {
|
|
112
|
+
/** The hub's URL without a trailing slash (the issuer). */
|
|
113
|
+
readonly url: string
|
|
114
|
+
readonly #options: HubConnectOptions
|
|
115
|
+
readonly #fetch: Fetch
|
|
116
|
+
|
|
117
|
+
constructor(options: HubConnectOptions) {
|
|
118
|
+
this.url = options.url.replace(/\/+$/, "")
|
|
119
|
+
this.#options = options
|
|
120
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Where to send the user's browser (every parameter the hub requires; `challenge` from pkcePair). */
|
|
124
|
+
authorizeURL(input: { readonly scopes: readonly string[]; readonly state: string; readonly challenge: string }): string {
|
|
125
|
+
const query = new URLSearchParams({
|
|
126
|
+
response_type: "code",
|
|
127
|
+
client_id: this.#options.clientID,
|
|
128
|
+
redirect_uri: this.#options.redirectURI,
|
|
129
|
+
scope: input.scopes.join(" "),
|
|
130
|
+
state: input.state,
|
|
131
|
+
code_challenge: input.challenge,
|
|
132
|
+
code_challenge_method: "S256",
|
|
133
|
+
})
|
|
134
|
+
return `${this.url}/connect/authorize?${query}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The callback's query, checked: `state` must be the one you sent and `iss` this hub (RFC 9207). Answers the code,
|
|
139
|
+
* or throws a HubOAuthError (status 0) with the hub's `error` (access_denied, invalid_scope, …) or the mismatch.
|
|
140
|
+
*/
|
|
141
|
+
callback(query: URLSearchParams, expectedState: string): string {
|
|
142
|
+
if (query.get("state") !== expectedState) throw new HubOAuthError(0, "invalid_state", "state does not match")
|
|
143
|
+
const iss = query.get("iss")
|
|
144
|
+
if (iss === null || iss.replace(/\/+$/, "") !== this.url) throw new HubOAuthError(0, "invalid_issuer", "iss is not this hub")
|
|
145
|
+
const error = query.get("error")
|
|
146
|
+
if (error) throw new HubOAuthError(0, error, query.get("error_description") ?? undefined)
|
|
147
|
+
const code = query.get("code")
|
|
148
|
+
if (!code) throw new HubOAuthError(0, "invalid_request", "no code")
|
|
149
|
+
return code
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** code + verifier → the connection's first pair. */
|
|
153
|
+
exchange(code: string, verifier: string, options: Signal = {}): Promise<TokenResponse> {
|
|
154
|
+
return this.#token({ grant_type: "authorization_code", code, redirect_uri: this.#options.redirectURI, code_verifier: verifier }, options)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A new pair for a refresh token (the presented one is spent; `scope` may only narrow). Serialise refreshes per
|
|
159
|
+
* connection and store the new pair before using it: presenting an older refresh token revokes the connection
|
|
160
|
+
* (ConnectionTokens does both).
|
|
161
|
+
*/
|
|
162
|
+
refresh(refreshToken: string, options: Signal & { readonly scope?: readonly string[] } = {}): Promise<TokenResponse> {
|
|
163
|
+
return this.#token({ grant_type: "refresh_token", refresh_token: refreshToken, ...(options.scope ? { scope: options.scope.join(" ") } : {}) }, options)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Ends the whole connection (either token works; RFC 7009: 200 also for an unknown token). */
|
|
167
|
+
async revoke(token: string, options: Signal = {}): Promise<void> {
|
|
168
|
+
await this.#post("/connect/revoke", { token }, options)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async #token(form: Record<string, string>, options: Signal): Promise<TokenResponse> {
|
|
172
|
+
return (await (await this.#post("/connect/token", form, options)).json()) as TokenResponse
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async #post(pathname: string, form: Record<string, string>, options: Signal): Promise<Response> {
|
|
176
|
+
const basic = btoa(`${encodeURIComponent(this.#options.clientID)}:${encodeURIComponent(this.#options.clientSecret)}`)
|
|
177
|
+
let response: Response | undefined
|
|
178
|
+
try {
|
|
179
|
+
response = await this.#fetch(`${this.url}${pathname}`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { authorization: `Basic ${basic}`, "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
182
|
+
body: new URLSearchParams(form).toString(),
|
|
183
|
+
redirect: "manual",
|
|
184
|
+
...sig(options),
|
|
185
|
+
})
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
if (options.signal?.aborted) throw cause
|
|
188
|
+
throw new HubTransportError("unreachable", `cannot reach ${this.url} (${cause instanceof Error ? cause.message : String(cause)})`, { cause })
|
|
189
|
+
}
|
|
190
|
+
if (response.ok) return response
|
|
191
|
+
const body = (await response.json().catch(() => undefined)) as { error?: unknown; error_description?: unknown } | undefined
|
|
192
|
+
if (typeof body?.error !== "string") throw new HubTransportError("http", `HTTP ${response.status} from ${this.url}${pathname}`, { status: response.status })
|
|
193
|
+
const retry = Number(response.headers.get("retry-after"))
|
|
194
|
+
throw new HubOAuthError(response.status, body.error, typeof body.error_description === "string" ? body.error_description : undefined, Number.isFinite(retry) && retry > 0 ? retry : undefined)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Where an app keeps each connection's one current pair (share one store between the app's instances). */
|
|
199
|
+
export interface TokenStore {
|
|
200
|
+
get(key: string): ConnectionPair | undefined | Promise<ConnectionPair | undefined>
|
|
201
|
+
set(key: string, pair: ConnectionPair): void | Promise<void>
|
|
202
|
+
delete(key: string): void | Promise<void>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A TokenStore in memory (one process). */
|
|
206
|
+
export class MemoryTokenStore implements TokenStore {
|
|
207
|
+
readonly #pairs = new Map<string, ConnectionPair>()
|
|
208
|
+
get(key: string): ConnectionPair | undefined {
|
|
209
|
+
return this.#pairs.get(key)
|
|
210
|
+
}
|
|
211
|
+
set(key: string, pair: ConnectionPair): void {
|
|
212
|
+
this.#pairs.set(key, pair)
|
|
213
|
+
}
|
|
214
|
+
delete(key: string): void {
|
|
215
|
+
this.#pairs.delete(key)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ConnectionTokensOptions {
|
|
220
|
+
readonly connect: HubConnect
|
|
221
|
+
readonly store: TokenStore
|
|
222
|
+
/** Refresh this long before the access token expires (default 60 s). */
|
|
223
|
+
readonly skewMs?: number
|
|
224
|
+
readonly now?: () => number
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Keeps each connection's pair (under your own key, e.g. your user's ID) and refreshes it single-flight: parallel
|
|
229
|
+
* callers of one key share one refresh, and the new pair is stored before anyone uses it. A connection the hub ended
|
|
230
|
+
* (refresh answers invalid_grant) is dropped from the store and the error thrown: the user connects again.
|
|
231
|
+
* This serialises within one process; an app running several instances needs one shared store AND a lock around the
|
|
232
|
+
* refresh in it (two instances refreshing with their own copy of the pair revoke the connection).
|
|
233
|
+
*/
|
|
234
|
+
export class ConnectionTokens {
|
|
235
|
+
readonly #options: ConnectionTokensOptions
|
|
236
|
+
readonly #inflight = new Map<string, Promise<ConnectionPair>>()
|
|
237
|
+
|
|
238
|
+
constructor(options: ConnectionTokensOptions) {
|
|
239
|
+
this.#options = options
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#now(): number {
|
|
243
|
+
return this.#options.now?.() ?? Date.now()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stores the first pair of a connection (after HubConnect.exchange), replacing any older one. */
|
|
247
|
+
async save(key: string, token: TokenResponse): Promise<ConnectionPair> {
|
|
248
|
+
const pair = pairOf(token, this.#now())
|
|
249
|
+
await this.#options.store.set(key, pair)
|
|
250
|
+
return pair
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The current pair, or undefined when the key has no connection. */
|
|
254
|
+
async pair(key: string): Promise<ConnectionPair | undefined> {
|
|
255
|
+
return this.#options.store.get(key)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A usable access token: refreshed first (single-flight) when it expires within the skew. */
|
|
259
|
+
async access(key: string): Promise<string> {
|
|
260
|
+
const pair = await this.#options.store.get(key)
|
|
261
|
+
if (!pair) throw notConnected()
|
|
262
|
+
if (pair.expiresAt - (this.#options.skewMs ?? 60_000) > this.#now()) return pair.accessToken
|
|
263
|
+
return (await this.refresh(key, pair.accessToken)).accessToken
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Refreshes now (e.g. after a 401 token_expired). Pass the access token that failed: when the stored pair already
|
|
268
|
+
* holds another one (someone refreshed meanwhile), that pair is answered without a new refresh.
|
|
269
|
+
*/
|
|
270
|
+
refresh(key: string, failedAccess?: string): Promise<ConnectionPair> {
|
|
271
|
+
const running = this.#inflight.get(key)
|
|
272
|
+
if (running) return running
|
|
273
|
+
const run = (async () => {
|
|
274
|
+
const pair = await this.#options.store.get(key)
|
|
275
|
+
if (!pair) throw notConnected()
|
|
276
|
+
if (failedAccess !== undefined && pair.accessToken !== failedAccess) return pair
|
|
277
|
+
try {
|
|
278
|
+
const next = pairOf(await this.#options.connect.refresh(pair.refreshToken), this.#now())
|
|
279
|
+
await this.#options.store.set(key, next)
|
|
280
|
+
return next
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
if (cause instanceof HubOAuthError && cause.error === "invalid_grant") await this.#options.store.delete(key)
|
|
283
|
+
throw cause
|
|
284
|
+
}
|
|
285
|
+
})().finally(() => this.#inflight.delete(key))
|
|
286
|
+
this.#inflight.set(key, run)
|
|
287
|
+
return run
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Disconnects: revokes the connection on the hub, then forgets the pair. When the hub cannot be reached or refuses
|
|
292
|
+
* (other than invalid_token: already gone), the pair is kept and the error thrown, so the app can try again.
|
|
293
|
+
*/
|
|
294
|
+
async revoke(key: string): Promise<void> {
|
|
295
|
+
const pair = await this.#options.store.get(key)
|
|
296
|
+
if (!pair) return
|
|
297
|
+
try {
|
|
298
|
+
await this.#options.connect.revoke(pair.refreshToken)
|
|
299
|
+
} catch (cause) {
|
|
300
|
+
if (!(cause instanceof HubOAuthError && (cause.error === "invalid_token" || cause.error === "invalid_grant"))) throw cause
|
|
301
|
+
}
|
|
302
|
+
await this.#options.store.delete(key)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const notConnected = () => new HubOAuthError(0, "not_connected", "no connection under this key")
|
|
307
|
+
|
|
308
|
+
// ---------- speech, conversation, Decisions (HubClient with an app token) ----------
|
|
309
|
+
|
|
310
|
+
export type AppSessionState = "idle" | "working" | "blocked" | "error"
|
|
311
|
+
export type AppStreamEndReason = "token_expired" | "revoked" | "grant" | "gone" | "slow"
|
|
312
|
+
|
|
313
|
+
/** The stream's frames (contract §5.2; closed allowlists on the wire). Their `text` is model output: untrusted. */
|
|
314
|
+
export type AppStreamFrame =
|
|
315
|
+
| { readonly type: "ready"; readonly at: number; readonly sessionID: string; readonly status: AppSessionState; readonly conversation: { readonly on: boolean; readonly front?: boolean } }
|
|
316
|
+
| { readonly type: "delta"; readonly sessionID: string; readonly messageID?: string; readonly ordinal?: number; readonly text: string }
|
|
317
|
+
| { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly text: string }
|
|
318
|
+
| { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
|
|
319
|
+
| { readonly type: "status"; readonly sessionID: string; readonly state: AppSessionState; readonly blockers: number; readonly since?: number }
|
|
320
|
+
| { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly reason?: "front_failed" }
|
|
321
|
+
| { readonly type: "reset"; readonly at: number; readonly refresh: readonly ("log" | "status" | "conversation" | "decisions")[] }
|
|
322
|
+
| { readonly type: "end"; readonly at: number; readonly reason: AppStreamEndReason }
|
|
323
|
+
|
|
324
|
+
/** One frame and its resume cursor (`id:`), when the hub sent one. */
|
|
325
|
+
export interface AppStreamItem {
|
|
326
|
+
readonly frame: AppStreamFrame
|
|
327
|
+
readonly cursor?: string
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface AppStreamOptions extends Signal {
|
|
331
|
+
/** Resume after this cursor (sent as Last-Event-ID). */
|
|
332
|
+
readonly cursor?: string
|
|
333
|
+
/**
|
|
334
|
+
* Reconnect with the last cursor when the stream drops without `end` (a draining hub, a network break) or ends
|
|
335
|
+
* with `slow` or `token_expired` (the latter helps only with a token function on the client that refreshes); those
|
|
336
|
+
* `end` frames are then not yielded. Default true. With false, every `end` is yielded and the iterator stops.
|
|
337
|
+
* `end` revoked, grant or gone always ends it (yielded last).
|
|
338
|
+
*/
|
|
339
|
+
readonly resume?: boolean
|
|
340
|
+
/**
|
|
341
|
+
* Reconnects in a row without progress before giving up (default 5): then a `stream` HubTransportError ("gave up
|
|
342
|
+
* after N reconnects", the last failure as its cause). Progress is a frame other than `ready`.
|
|
343
|
+
*/
|
|
344
|
+
readonly maxReconnects?: number
|
|
345
|
+
/**
|
|
346
|
+
* The first reconnect's wait (default 1000 ms); it doubles with every miss in a row, up to maxReconnectDelayMs, and
|
|
347
|
+
* each wait is jittered to between half and all of it. A 429's or 503's `retryAfter` is waited at least.
|
|
348
|
+
*/
|
|
349
|
+
readonly reconnectDelayMs?: number
|
|
350
|
+
/** The longest backoff wait (default 30 000 ms). */
|
|
351
|
+
readonly maxReconnectDelayMs?: number
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const RESUMABLE: ReadonlySet<string> = new Set<AppStreamEndReason>(["slow", "token_expired"])
|
|
355
|
+
|
|
356
|
+
/** What the app-front verbs need of the client (HubClient). */
|
|
357
|
+
export interface AppFrontTransport {
|
|
358
|
+
request(method: string, pathname: string, options?: { readonly query?: Readonly<Record<string, string | number | boolean | undefined>>; readonly body?: unknown; readonly raw?: { readonly body: BodyInit; readonly contentType: string }; readonly signal?: AbortSignal; readonly headers?: Readonly<Record<string, string>> }): Promise<Response>
|
|
359
|
+
call<T = unknown>(method: string, pathname: string, options?: { readonly query?: Readonly<Record<string, string | number | boolean | undefined>>; readonly body?: unknown; readonly signal?: AbortSignal }): Promise<T>
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** hub.speech (speech:use): the user's own speech provider; quotas per credential (429 rate_limited). */
|
|
363
|
+
export interface AppSpeech {
|
|
364
|
+
/** Raw audio (typed `type`, else the Blob's type, else audio/webm) → its transcript. */
|
|
365
|
+
transcribe(audio: Blob | ArrayBuffer | Uint8Array, options?: Signal & { readonly type?: string }): Promise<{ text: string }>
|
|
366
|
+
/** Text (1–800 characters once speakable) → audio/mpeg, streamed as the provider streams. */
|
|
367
|
+
speak(text: string, options?: Signal): Promise<ReadableStream<Uint8Array>>
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** hub.conversation (conversation:read for the stream, conversation:write + a prompt grant for the writes). */
|
|
371
|
+
export interface AppConversation {
|
|
372
|
+
/** GET …/stream as frames; resumes by default (AppStreamOptions). Throws the HubError of a refusal (404, 429, …). */
|
|
373
|
+
stream(addr: string, options?: AppStreamOptions): AsyncGenerator<AppStreamItem, void, undefined>
|
|
374
|
+
/** Conversation mode on/off; `front: true` asks for the voice front (the answer says whether one runs). */
|
|
375
|
+
set(addr: string, on: boolean, options?: Signal & { readonly front?: boolean }): Promise<{ on: boolean; agent?: string; front?: boolean }>
|
|
376
|
+
/** A spoken turn (1–4000 characters) to the session, or to its voice front while one is active. */
|
|
377
|
+
utterance(addr: string, text: string, options?: Signal): Promise<{ inboxID: string }>
|
|
378
|
+
/** Barge-in: stops the front's turn (or the session's, without a front). */
|
|
379
|
+
interrupt(addr: string, options?: Signal): Promise<void>
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** hub.appDecisions (decisions:answer): the confirm-tier Decisions of the app's own sessions. */
|
|
383
|
+
export interface AppDecisions {
|
|
384
|
+
/** The Decisions this app may answer (`state: "open"` for the waiting ones), newest first. */
|
|
385
|
+
list(options?: Signal & { readonly state?: DecisionState }): Promise<{ decisions: Decision[] }>
|
|
386
|
+
/** Answers one with a once-off option or text; the first answer wins (410 gone after). */
|
|
387
|
+
answer(id: string, answer: Signal & { readonly option?: string; readonly text?: string }): Promise<DecisionAnswered>
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function speechApi(hub: AppFrontTransport): AppSpeech {
|
|
391
|
+
return {
|
|
392
|
+
async transcribe(audio, options = {}) {
|
|
393
|
+
const type = options.type ?? (audio instanceof Blob && audio.type ? audio.type : "audio/webm")
|
|
394
|
+
const response = await hub.request("POST", `${AGENT}/speech/transcribe`, { raw: { body: audio as BodyInit, contentType: type }, ...sig(options) })
|
|
395
|
+
return (await response.json()) as { text: string }
|
|
396
|
+
},
|
|
397
|
+
async speak(text, options = {}) {
|
|
398
|
+
const response = await hub.request("POST", `${AGENT}/speech/speak`, { body: { text }, ...sig(options) })
|
|
399
|
+
if (!response.body) throw new HubTransportError("bad_response", "the hub sent no audio")
|
|
400
|
+
return response.body
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function conversationApi(hub: AppFrontTransport): AppConversation {
|
|
406
|
+
const at = (addr: string, verb: string) => `${AGENT}/sessions/${seg(addr)}/${verb}`
|
|
407
|
+
return {
|
|
408
|
+
set: (addr, on, options = {}) => hub.call("POST", at(addr, "conversation"), { body: options.front === undefined ? { on } : { on, front: options.front }, ...sig(options) }),
|
|
409
|
+
utterance: (addr, text, options = {}) => hub.call("POST", at(addr, "utterance"), { body: { text }, ...sig(options) }),
|
|
410
|
+
interrupt: async (addr, options = {}) => {
|
|
411
|
+
await hub.request("POST", at(addr, "interrupt"), sig(options))
|
|
412
|
+
},
|
|
413
|
+
stream: (addr, options = {}) => streamFrames(hub, at(addr, "stream"), options),
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function appDecisionsApi(hub: AppFrontTransport): AppDecisions {
|
|
418
|
+
return {
|
|
419
|
+
list: (options = {}) => hub.call("GET", `${AGENT}/decisions`, { query: { answerable: 1, state: options.state }, ...sig(options) }),
|
|
420
|
+
answer: (id, answer) => {
|
|
421
|
+
const { signal: _signal, ...body } = answer
|
|
422
|
+
return hub.call("POST", `${AGENT}/decisions/${seg(id)}/answer`, { body, ...sig(answer) })
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Whether a reconnect that failed with `cause` is worth another try: the network, the hub's 5xx (no envelope or
|
|
429
|
+
* offline/busy/internal) and 429. Never 401/403/404 or any other refusal.
|
|
430
|
+
*/
|
|
431
|
+
function retryable(cause: unknown): boolean {
|
|
432
|
+
if (cause instanceof HubError) return cause.status === 429 || cause.status >= 500
|
|
433
|
+
if (cause instanceof HubTransportError) return cause.kind === "unreachable" || cause.kind === "stream" || (cause.kind === "http" && (cause.status === undefined || cause.status === 429 || cause.status >= 500))
|
|
434
|
+
return false
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const wait = (ms: number, signal?: AbortSignal) =>
|
|
438
|
+
new Promise<void>((resolve) => {
|
|
439
|
+
if (signal?.aborted) return resolve()
|
|
440
|
+
const done = () => {
|
|
441
|
+
clearTimeout(timer)
|
|
442
|
+
signal?.removeEventListener("abort", done)
|
|
443
|
+
resolve()
|
|
444
|
+
}
|
|
445
|
+
const timer = setTimeout(done, ms)
|
|
446
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
async function* streamFrames(hub: AppFrontTransport, pathname: string, options: AppStreamOptions): AsyncGenerator<AppStreamItem, void, undefined> {
|
|
450
|
+
const resume = options.resume ?? true
|
|
451
|
+
const maxReconnects = options.maxReconnects ?? 5
|
|
452
|
+
const base = options.reconnectDelayMs ?? 1000
|
|
453
|
+
const cap = options.maxReconnectDelayMs ?? 30_000
|
|
454
|
+
let cursor = options.cursor
|
|
455
|
+
/** Reconnects in a row without progress. */
|
|
456
|
+
let misses = 0
|
|
457
|
+
let lastFailure: unknown
|
|
458
|
+
let retryAfterMs = 0
|
|
459
|
+
for (let attempt = 0; ; attempt++) {
|
|
460
|
+
if (options.signal?.aborted) return
|
|
461
|
+
let response: Response | undefined
|
|
462
|
+
try {
|
|
463
|
+
response = await hub.request("GET", pathname, { headers: { accept: "text/event-stream", ...(cursor ? { "last-event-id": cursor } : {}) }, ...sig(options) })
|
|
464
|
+
} catch (cause) {
|
|
465
|
+
// The first connect reports every failure; a reconnect retries the network, 5xx and 429.
|
|
466
|
+
if (attempt === 0 || options.signal?.aborted || !retryable(cause)) throw cause
|
|
467
|
+
lastFailure = cause
|
|
468
|
+
retryAfterMs = cause instanceof HubError && cause.retryAfter !== undefined ? cause.retryAfter * 1000 : 0
|
|
469
|
+
}
|
|
470
|
+
if (response) {
|
|
471
|
+
retryAfterMs = 0
|
|
472
|
+
if (!response.body) throw new HubTransportError("bad_response", "the hub sent no stream")
|
|
473
|
+
const reader = response.body.getReader()
|
|
474
|
+
const stop = () => void reader.cancel().catch(() => {})
|
|
475
|
+
options.signal?.addEventListener("abort", stop, { once: true })
|
|
476
|
+
const parser = new SseParser()
|
|
477
|
+
const decoder = new TextDecoder()
|
|
478
|
+
lastFailure = undefined
|
|
479
|
+
try {
|
|
480
|
+
read: for (;;) {
|
|
481
|
+
let chunk: Awaited<ReturnType<typeof reader.read>>
|
|
482
|
+
try {
|
|
483
|
+
chunk = await reader.read()
|
|
484
|
+
} catch (cause) {
|
|
485
|
+
lastFailure = cause
|
|
486
|
+
break // a broken connection: resume
|
|
487
|
+
}
|
|
488
|
+
if (chunk.done || options.signal?.aborted) break
|
|
489
|
+
for (const event of parser.push(decoder.decode(chunk.value, { stream: true }))) {
|
|
490
|
+
if (event.id) cursor = event.id
|
|
491
|
+
if (event.event !== "message") continue
|
|
492
|
+
let frame: AppStreamFrame
|
|
493
|
+
try {
|
|
494
|
+
frame = JSON.parse(event.data) as AppStreamFrame
|
|
495
|
+
} catch {
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
if (frame.type === "end") {
|
|
499
|
+
if (resume && RESUMABLE.has(frame.reason)) {
|
|
500
|
+
lastFailure = new HubTransportError("stream", `the stream ended (${frame.reason})`)
|
|
501
|
+
break read
|
|
502
|
+
}
|
|
503
|
+
yield { frame }
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
// `ready` alone is no progress: a hub that answers ready and drops must not loop forever.
|
|
507
|
+
if (frame.type !== "ready") misses = 0
|
|
508
|
+
yield cursor ? { frame, cursor } : { frame }
|
|
509
|
+
if (options.signal?.aborted) return
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
options.signal?.removeEventListener("abort", stop)
|
|
514
|
+
stop()
|
|
515
|
+
}
|
|
516
|
+
if (options.signal?.aborted || !resume) return
|
|
517
|
+
}
|
|
518
|
+
if (++misses > maxReconnects) {
|
|
519
|
+
throw new HubTransportError("stream", `the conversation stream gave up after ${maxReconnects} reconnects${lastFailure instanceof Error ? ` (${lastFailure.message})` : ""}`, { cause: lastFailure })
|
|
520
|
+
}
|
|
521
|
+
const backoff = Math.min(cap, base * 2 ** (misses - 1))
|
|
522
|
+
await wait(Math.max(retryAfterMs, backoff * (0.5 + Math.random() * 0.5)), options.signal)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
@@ -5,6 +5,7 @@ import { sseEvents } from "./sse.ts"
|
|
|
5
5
|
import { type Artifacts, artifactsApi } from "./artifacts.ts"
|
|
6
6
|
import { type Teams, teamsApi } from "./teams.ts"
|
|
7
7
|
import { type Concierge, conciergeApi } from "./concierge.ts"
|
|
8
|
+
import { type AppConversation, type AppDecisions, type AppSpeech, appDecisionsApi, conversationApi, speechApi } from "./app-front.ts"
|
|
8
9
|
import type {
|
|
9
10
|
Approved,
|
|
10
11
|
AppTokenView,
|
|
@@ -53,6 +54,8 @@ import type {
|
|
|
53
54
|
Whoami,
|
|
54
55
|
WebhookDelivery,
|
|
55
56
|
WebhookView,
|
|
57
|
+
SubscribeInput,
|
|
58
|
+
SubscriptionView,
|
|
56
59
|
ProfileAssignment,
|
|
57
60
|
ProfileContent,
|
|
58
61
|
ProfileCreated,
|
|
@@ -115,8 +118,11 @@ export type Fetch = (input: string, init: RequestInit) => Promise<Response>
|
|
|
115
118
|
export interface HubClientOptions {
|
|
116
119
|
/** The hub's base URL, e.g. https://hub.example.com. */
|
|
117
120
|
readonly url: string
|
|
118
|
-
/**
|
|
119
|
-
|
|
121
|
+
/**
|
|
122
|
+
* A session token (HUB_SESSION_TOKEN), an app token or a device token; or a function asked before every request
|
|
123
|
+
* (a connected app's access token that its server refreshes, e.g. `() => tokens.access(userID)`).
|
|
124
|
+
*/
|
|
125
|
+
readonly token: string | (() => string | Promise<string>)
|
|
120
126
|
/** Defaults to the global fetch. */
|
|
121
127
|
readonly fetch?: Fetch
|
|
122
128
|
}
|
|
@@ -360,7 +366,7 @@ export class HubClient {
|
|
|
360
366
|
/** The URL as given (used in messages). */
|
|
361
367
|
readonly url: string
|
|
362
368
|
readonly #base: string
|
|
363
|
-
readonly #token: string
|
|
369
|
+
readonly #token: string | (() => string | Promise<string>)
|
|
364
370
|
readonly #fetch: Fetch
|
|
365
371
|
|
|
366
372
|
/** Launches (design §6.1): create, list, get, stop. */
|
|
@@ -437,6 +443,12 @@ export class HubClient {
|
|
|
437
443
|
readonly teams: Teams = teamsApi(this)
|
|
438
444
|
/** Concierge (CON-01): its watchlist (get, update, watch/unwatch, mute/unmute). */
|
|
439
445
|
readonly concierge: Concierge = conciergeApi(this)
|
|
446
|
+
/** (App front) Speech with the user's provider (speech:use): transcribe, speak. */
|
|
447
|
+
readonly speech: AppSpeech = speechApi(this)
|
|
448
|
+
/** (App front) Conversation with one of the app's sessions: the stream, conversation on/off, utterance, interrupt. */
|
|
449
|
+
readonly conversation: AppConversation = conversationApi(this)
|
|
450
|
+
/** (App front) The confirm-tier Decisions of the app's own sessions (decisions:answer): list, answer. */
|
|
451
|
+
readonly appDecisions: AppDecisions = appDecisionsApi(this)
|
|
440
452
|
|
|
441
453
|
constructor(options: HubClientOptions) {
|
|
442
454
|
this.url = options.url
|
|
@@ -448,7 +460,8 @@ export class HubClient {
|
|
|
448
460
|
/** Sends a request; resolves the response when OK, else throws HubError / HubTransportError. */
|
|
449
461
|
async request(method: string, pathname: string, options: RequestOptions = {}): Promise<Response> {
|
|
450
462
|
const target = `${pathname}${queryString(options.query)}`
|
|
451
|
-
const
|
|
463
|
+
const token = typeof this.#token === "string" ? this.#token : await this.#token()
|
|
464
|
+
const headers: Record<string, string> = { ...options.headers, authorization: `Bearer ${token}`, accept: "application/json" }
|
|
452
465
|
if (options.body !== undefined) headers["content-type"] = "application/json"
|
|
453
466
|
if (options.raw) headers["content-type"] = options.raw.contentType
|
|
454
467
|
let response: Response
|
|
@@ -568,6 +581,26 @@ export class HubClient {
|
|
|
568
581
|
return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
|
|
569
582
|
}
|
|
570
583
|
|
|
584
|
+
// Session subscriptions (decision 74qawely): a session hears its targets' events as batched hub messages.
|
|
585
|
+
|
|
586
|
+
/** POST /api/agent/subscriptions (a session token subscribes itself; users and apps name the subscriber). */
|
|
587
|
+
subscribe(input: SubscribeInput, options: CallOptions = {}): Promise<{ subscription: SubscriptionView }> {
|
|
588
|
+
return this.call("POST", `${AGENT}/subscriptions`, { body: input, ...signalOf(options) })
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The caller's subscriptions; `session`: what that session subscribes to and what subscribes to it. */
|
|
592
|
+
subscriptions(options: CallOptions & { readonly session?: string } = {}): Promise<{ subscriptions: SubscriptionView[] }> {
|
|
593
|
+
return this.call("GET", `${AGENT}/subscriptions`, { query: { session: options.session }, ...signalOf(options) })
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
subscription(id: string, options: CallOptions = {}): Promise<{ subscription: SubscriptionView }> {
|
|
597
|
+
return this.call("GET", `${AGENT}/subscriptions/${segment(id)}`, signalOf(options))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
unsubscribe(id: string, options: CallOptions = {}): Promise<void> {
|
|
601
|
+
return this.call("DELETE", `${AGENT}/subscriptions/${segment(id)}`, signalOf(options))
|
|
602
|
+
}
|
|
603
|
+
|
|
571
604
|
// Organization (sessions:manage).
|
|
572
605
|
|
|
573
606
|
setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
|
|
@@ -81,6 +81,12 @@ function escapeBody(text: string): string {
|
|
|
81
81
|
.join("\n")
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Hub parties whose messages are notices: nothing reads a reply to them (session subscriptions). */
|
|
85
|
+
export const NOTICE_ONLY_APPS: ReadonlySet<string> = new Set(["subscriptions"])
|
|
86
|
+
|
|
87
|
+
/** Whether a sender's messages take no reply (a reply to one is refused). */
|
|
88
|
+
export const noticeOnly = (from: Party): boolean => from.kind === "app" && NOTICE_ONLY_APPS.has(from.tokenID)
|
|
89
|
+
|
|
84
90
|
/** The text a recipient session sees for a message. */
|
|
85
91
|
export function renderEnvelope(input: EnvelopeInput): string {
|
|
86
92
|
const { id, kind } = input
|
|
@@ -89,8 +95,9 @@ export function renderEnvelope(input: EnvelopeInput): string {
|
|
|
89
95
|
kind === "reply" || kind === "decline" ? `to you · re ${sanitizeLabel(input.replyTo ?? "")}` : to.startsWith("@@") ? `to ${to} → you` : "to you"
|
|
90
96
|
const header = `[hub] ${kind} from ${partyLabel(input.from)} ${target} · ${stamp(input.at)} · ${id}`
|
|
91
97
|
const boundary = `----- ${id} -----`
|
|
92
|
-
const hint =
|
|
93
|
-
|
|
98
|
+
const hint = noticeOnly(input.from)
|
|
99
|
+
? "↩ a notice: no reply needed (hub unsubscribe <sub_…> stops it)"
|
|
100
|
+
: kind === "ask"
|
|
94
101
|
? `↩ hub reply ${id} "…" · hub decline ${id} --why "…"${input.expiresAt === undefined ? "" : ` · expires ${stamp(input.expiresAt)}`}`
|
|
95
102
|
: `↩ hub reply ${id} "…"`
|
|
96
103
|
return [header, boundary, escapeBody(input.text), boundary, hint].join("\n")
|