@huynle/hub 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -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
- /** A session token (HUB_SESSION_TOKEN), an app token or a device token. */
119
- readonly token: string
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 headers: Record<string, string> = { ...options.headers, authorization: `Bearer ${this.#token}`, accept: "application/json" }
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
- kind === "ask"
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")
@@ -21,6 +21,7 @@ export type ErrorCode =
21
21
  | "paused"
22
22
  | "rate_limited"
23
23
  | "internal"
24
+ | "upstream_failed"
24
25
  | "offline"
25
26
  | "busy"
26
27
  | (string & {})
@@ -2,6 +2,7 @@
2
2
  // errors (HubError, HubTransportError), the answer types, the SSE parser of the watch stream, and the pure
3
3
  // envelope renderer/sanitiser and address parser shared with the hub, launches, and webhook verification.
4
4
  export * from "./address.ts"
5
+ export * from "./app-front.ts"
5
6
  export * from "./artifacts.ts"
6
7
  export * from "./artifact-jobs.ts"
7
8
  export * from "./team-intake.ts"