@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
@@ -16,7 +16,13 @@ import { directoryRoutes } from "./directory-routes"
16
16
  import { type ReadDeps, readRoutes, type ReadRegistry, type SpokeRequester } from "./read-routes"
17
17
  import { agentAllows, type AgentActor, type AgentAllow, type BearerVerifier, type Credential, type Resolver } from "./actor"
18
18
  import { appTokenRoutes, appTokenVerifier } from "./app-tokens"
19
+ import type { AppConnect } from "./connect"
20
+ import { connectRoutes } from "./connect-routes"
21
+ import { speechRoutes, type SpeechRouteDeps } from "./speech-routes"
22
+ import { appSessionCheck, type AppSessionDeps, type AppStreams, conversationRoutes, type ConversationPorts, type StreamSource } from "./conversation"
23
+ import { APP_FRONT_FEATURES, APP_FRONT_SHIPPED } from "../../protocol"
19
24
  import type { Decisions } from "./decisions"
25
+ import { appDecisionRoutes } from "./app-decisions"
20
26
  import type { Delivery } from "./delivery"
21
27
  import { MessageFeed, messageRoutes } from "./message-routes"
22
28
  import type { SessionTokens } from "./session-tokens"
@@ -31,6 +37,7 @@ import { actingRoutes } from "./acting-routes"
31
37
  import type { Actions } from "../actions"
32
38
  import { launchRoutes, type RouteLaunches } from "./launch-routes"
33
39
  import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
40
+ import { type RouteSubscriptions, subscriptionRoutes } from "./subscription-routes"
34
41
  import { pinAuthorizer } from "../profiles/apply"
35
42
  import { profileRoutes } from "../profiles/routes"
36
43
  import { ProfileUsage } from "../profiles/usage"
@@ -61,6 +68,8 @@ import type { ConciergeService } from "../concierge/service"
61
68
  import type { SessionIntent } from "../../protocol"
62
69
  import type { SessionIntents } from "./intents"
63
70
  import { intentRoutes } from "./intent-routes"
71
+ import { contextCardRoutes } from "../context-cards/routes"
72
+ import type { ContextCardService } from "../context-cards/service"
64
73
 
65
74
  export const AGENT_PREFIX = "/api/agent"
66
75
 
@@ -89,6 +98,23 @@ export interface AgentRoute {
89
98
  /** What the agent API needs from the hub. */
90
99
  export interface AgentDeps {
91
100
  readonly store: Store
101
+ /** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
102
+ readonly connect?: AppConnect
103
+ /** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
104
+ readonly speech?: SpeechRouteDeps
105
+ /**
106
+ * (AF3) Conversation for apps: the stream and conversation/utterance/interrupt routes (with grants, acting, audit,
107
+ * registry and directory), the prompt and log widened to conversation:write/read for the app's sessions.
108
+ */
109
+ readonly conversation?: {
110
+ readonly ports: ConversationPorts
111
+ readonly source: StreamSource
112
+ readonly streams: AppStreams
113
+ readonly cookieName: string
114
+ readonly heartbeatMs?: number
115
+ readonly recheckMs?: number
116
+ readonly bufferMaxBytes?: number
117
+ }
92
118
  /** (REC-01) Voice recordings: the Concierge's agent calls (recordConciergeCalls). */
93
119
  readonly recording?: ConciergeCallRecorder
94
120
  /** Hub-minted session tokens (P1-T5). */
@@ -125,6 +151,8 @@ export interface AgentDeps {
125
151
  readonly launches?: RouteLaunches
126
152
  /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
127
153
  readonly webhooks?: RouteWebhooks
154
+ /** Session subscriptions (decision 74qawely): the /api/agent/subscriptions routes when given with the directory. */
155
+ readonly subscriptions?: RouteSubscriptions
128
156
  /** Profiles (HWA-14): the /api/agent/profiles and /sessions/:addr/profile routes when given with the directory. */
129
157
  readonly profiles?: ProfileStore
130
158
  /** Workspaces (HWA-06): the /api/agent/workspaces routes when given. */
@@ -181,9 +209,13 @@ export interface AgentDeps {
181
209
  readonly timeZone?: (userID: string) => Promise<string | undefined>
182
210
  /** (CON-13) The board's inGrant (board-routes.ts BoardRouteDeps.coverage). */
183
211
  readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
212
+ /** (Concierge grant requests) The board's project-level inGrant (board-routes.ts projectGrant). */
213
+ readonly projectGrant?: (userID: string, conciergeSessionID: string, directory: string) => Promise<readonly string[]>
184
214
  }
185
215
  /** (CON-02) Session intents: GET/PUT /api/agent/sessions/:addr/intent with the directory; reads carry `intent`. */
186
216
  readonly intents?: SessionIntents
217
+ /** Context cards (decision 9604b0a1): GET /api/agent/sessions/:addr/context-cards. */
218
+ readonly contextCards?: Pick<ContextCardService, "list" | "registry">
187
219
  }
188
220
 
189
221
  /**
@@ -191,7 +223,7 @@ export interface AgentDeps {
191
223
  * order after the device lookup; see BearerVerifier.
192
224
  */
193
225
  export function agentVerifiers(deps: AgentDeps): BearerVerifier[] {
194
- return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store)]
226
+ return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store), ...(deps.connect ? [deps.connect.verifier] : [])]
195
227
  }
196
228
 
197
229
  const userView = (user: UserRecord) => ({
@@ -204,27 +236,34 @@ const userView = (user: UserRecord) => ({
204
236
  /** The built-in /api/agent routes. */
205
237
  export function agentRoutes(deps: AgentDeps): AgentRoute[] {
206
238
  const feed = deps.feed ?? new MessageFeed()
239
+ const appDecisions = appDecisionParts(deps, feed)
207
240
  const routes: AgentRoute[] = [
208
241
  ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
209
242
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
243
+ ...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
210
244
  ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
211
- ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.messageState ? { state: deps.messageState } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
212
- ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
245
+ ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(appDecisions ? { answerable: appDecisions.answerable } : {}), ...(deps.messageState ? { state: deps.messageState } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
246
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps), ...appSessionReads(deps) }) : []),
213
247
  {
214
248
  method: "GET",
215
249
  path: `${AGENT_PREFIX}/whoami`,
216
250
  allow: { session: true, app: true },
217
251
  handle: ({ actor, user }) => Response.json({ actor, user: userView(user) }, { headers: { "cache-control": "no-store" } }),
218
252
  },
219
- ...appTokenRoutes(deps.store),
253
+ ...appTokenRoutes(deps.store, deps.conversation ? { revoked: (userID) => deps.conversation!.streams.pulse(userID) } : {}),
254
+ ...(deps.connect ? connectRoutes(deps.connect) : []),
255
+ ...(deps.speech ? speechRoutes(deps.speech) : []),
220
256
  ...(deps.audit ? auditRoutes(deps.audit) : []),
221
257
  ...(deps.grants ? grantRoutes(deps.grants, deps.now, deps.grantRequests) : []),
222
258
  ...(deps.acting ? actingSwitchRoutes(deps.acting) : []),
223
259
  ...actVerbRoutes(deps),
260
+ ...conversationVerbRoutes(deps),
261
+ ...(appDecisions?.routes ?? []),
224
262
  ...(deps.launches && deps.grants && deps.acting && deps.audit
225
263
  ? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
226
264
  : []),
227
265
  ...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
266
+ ...(deps.subscriptions && deps.directory ? subscriptionRoutes({ subscriptions: deps.subscriptions, directory: deps.directory, store: deps.store, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
228
267
  ...(deps.profiles && deps.directory
229
268
  ? profileRoutes({
230
269
  profiles: deps.profiles,
@@ -348,6 +387,65 @@ function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs:
348
387
  return intents ? { intents: (userID: string, sessionIDs: readonly string[]) => intents.many(userID, sessionIDs) } : {}
349
388
  }
350
389
 
390
+ /** (AF3) "The app's sessions" (contract §1.2), when grants, registry and directory are given. */
391
+ function appSessionDeps(deps: AgentDeps): AppSessionDeps | undefined {
392
+ const { grants, registry, directory, launches } = deps
393
+ if (!grants || !registry || !directory) return undefined
394
+ return {
395
+ registry,
396
+ directory,
397
+ grants,
398
+ ...(launches ? { launches: { list: (userID: string) => launches.list(userID) } } : {}),
399
+ ...(deps.concierge?.protects ? { protects: deps.concierge.protects } : {}),
400
+ ...(deps.conversation ? { deletedHere: (sessionID: string) => deps.conversation!.streams.wasDeleted(sessionID) } : {}),
401
+ }
402
+ }
403
+
404
+ /** (AF4) Decisions from apps (app-decisions.ts), when Decisions, grants, acting, audit, registry and directory are given. */
405
+ function appDecisionParts(deps: AgentDeps, feed: MessageFeed): ReturnType<typeof appDecisionRoutes> | undefined {
406
+ const owned = appSessionDeps(deps)
407
+ const { decisions, acting, audit } = deps
408
+ if (!owned || !decisions || !acting || !audit || !deps.delivery) return undefined
409
+ return appDecisionRoutes({ ...owned, decisions, acting, audit, store: deps.store, publish: (userID, message) => feed.publish(userID, message) })
410
+ }
411
+
412
+ /** (AF3) The prompt and log widened to conversation:write/read: the app's own sessions only. */
413
+ function appSessionReads(deps: AgentDeps): { appSession?: (actor: AgentActor, userID: string, addr: string) => Promise<unknown> } {
414
+ const owned = appSessionDeps(deps)
415
+ return owned ? { appSession: (actor, userID, addr) => appSessionCheck(owned, actor, userID, addr) } : {}
416
+ }
417
+
418
+ /** (AF3) The conversation routes, when every part they need is given. */
419
+ function conversationVerbRoutes(deps: AgentDeps): AgentRoute[] {
420
+ const owned = appSessionDeps(deps)
421
+ const { conversation, grants, acting, audit } = deps
422
+ if (!conversation || !owned || !grants || !acting || !audit) return []
423
+ const verifiers = agentVerifiers(deps)
424
+ return conversationRoutes({
425
+ ...owned,
426
+ grants,
427
+ acting,
428
+ audit,
429
+ store: deps.store,
430
+ ports: conversation.ports,
431
+ source: conversation.source,
432
+ streams: conversation.streams,
433
+ cookieName: conversation.cookieName,
434
+ verify: async (token) => {
435
+ for (const verifier of verifiers) {
436
+ const result = await verifier.verify(token)
437
+ if (result !== undefined) return result
438
+ }
439
+ return undefined
440
+ },
441
+ ...(deps.draining ? { draining: deps.draining } : {}),
442
+ ...(deps.now ? { now: deps.now } : {}),
443
+ ...(conversation.heartbeatMs !== undefined ? { heartbeatMs: conversation.heartbeatMs } : {}),
444
+ ...(conversation.recheckMs !== undefined ? { recheckMs: conversation.recheckMs } : {}),
445
+ ...(conversation.bufferMaxBytes !== undefined ? { bufferMaxBytes: conversation.bufferMaxBytes } : {}),
446
+ })
447
+ }
448
+
351
449
  /** The act verbs, when every part they need is given. */
352
450
  function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
353
451
  const { actions, acting, grants, audit, registry, directory, spokes } = deps
@@ -366,6 +464,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
366
464
  ...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
367
465
  ...currentConciergeActor(deps),
368
466
  }),
467
+ appSessionReads(deps),
369
468
  )
370
469
  }
371
470
 
@@ -442,7 +541,10 @@ export function agentApi(options: AgentApiOptions): AgentApi {
442
541
  const { actor, credential, user, device } = resolved
443
542
  return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
444
543
  } catch (cause) {
445
- return agentFailure(cause)
544
+ const failed = agentFailure(cause)
545
+ // (AF1) A refused connected-app access token says so the OAuth way (RFC 6750 §3.1).
546
+ if (failed.status === 401 && /^\s*Bearer\s+hca_/i.test(request.headers.get("authorization") ?? "")) failed.headers.set("www-authenticate", 'Bearer error="invalid_token"')
547
+ return failed
446
548
  }
447
549
  }
448
550
 
@@ -463,7 +565,7 @@ export function wellKnown(info: WellKnownInfo): Response {
463
565
  // Pairing codes also work on an OIDC hub (a signed-in device issues them).
464
566
  const auth = info.auth.mode === "oidc" ? { modes: ["oidc", "pairing"], oidc: { issuer: info.auth.issuer } } : { modes: ["pairing"] }
465
567
  return Response.json(
466
- { publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: [] },
568
+ { publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: APP_FRONT_FEATURES.filter((flag) => APP_FRONT_SHIPPED[flag]) },
467
569
  { headers: { "cache-control": "public, max-age=60" } },
468
570
  )
469
571
  }
@@ -0,0 +1,316 @@
1
+ // Speech for apps (AF2, docs/plans/2026-11-08-app-front-contracts.md §4): POST /api/agent/speech/transcribe (raw
2
+ // audio → {text}) and POST /api/agent/speech/speak ({text} → audio/mpeg), for app credentials holding `speech:use`
3
+ // (hand-made hat_ tokens and connected apps) only: the user's devices get 403 and use the console's /api/transcribe
4
+ // and /api/speak. The hub speaks with THAT user's speech settings and key (http.ts speechFor(user.id)); apps never
5
+ // read or change speech settings, models or keys (those routes stay console-only, behind the console gate).
6
+ //
7
+ // - Limits: the console's maxAudioBytes (HUB_MAX_AUDIO_BYTES) and maxSpeechChars (HUB_MAX_SPEECH_CHARS, after
8
+ // control characters are stripped and speakable(); longer text is refused, not cut), plus per-credential quotas
9
+ // (SPEECH_RATE_LIMITS, overridden by hub.json agents.speechQuotas): fixed windows aligned to the clock (a minute,
10
+ // an hour) in the shared Counters, so every pod of a cluster counts together and retryAfter is exact. Fixed
11
+ // windows allow up to twice a limit across a window edge (the end of one minute and the start of the next).
12
+ // Admission is atomic per window: increment first, check the returned count, undo this call's increments when
13
+ // any window is over (SpeechQuota.admit). A request that is refused for its input is not counted; one that
14
+ // reaches the provider is (whatever the provider answers).
15
+ // - Errors: 400 invalid (unknown audio type, empty or oversized audio, nothing or too much to speak, a bad body),
16
+ // 403 forbidden (no speech:use; not an app credential), 429 rate_limited {retryAfter, limit} + Retry-After,
17
+ // 422 unsupported {feature: "speech", kind} when the user has no provider for the kind, 502 upstream_failed
18
+ // {providerStatus?} when the provider fails (a generic message; the provider's text is only logged).
19
+ // - Recording (REC-01) is off for app actors: these handlers never call the recording taps, so x-hub-recording*
20
+ // headers are ignored and neither audio nor text is kept.
21
+ // - Audit per credential, never content: every request is counted per kind, outcome and clock hour; the user's agent
22
+ // audit lists at most one speech.transcribe / speech.speak entry per credential, kind and outcome every
23
+ // AUDIT_SLOT_MS (15 min) with the hour's count so far ("12 this hour so far"), the hour's first one also with the
24
+ // previous hour's total, so use and abuse are visible without speech flooding the audit ring.
25
+ import { SPEECH_PATHS, SPEECH_RATE_LIMITS } from "../../protocol"
26
+ import type { Clock, Counters } from "../backend/types"
27
+ import { audioFormat, SpeechError } from "../speech/provider"
28
+ import { speakable } from "../../speakable"
29
+ import type { AgentActor } from "./actor"
30
+ import type { AuditDecision, AuditInput, AuditOutcome } from "./audit"
31
+ import { bodyError, readBounded } from "./body"
32
+ import { partyFromActor } from "./envelope"
33
+ import { AgentError } from "./errors"
34
+ import type { AgentContext, AgentRoute } from "./routes"
35
+
36
+ export type SpeechQuotaKind = keyof typeof SPEECH_RATE_LIMITS
37
+
38
+ /** Per-credential speech quotas (the contract's SPEECH_RATE_LIMITS shape). */
39
+ export interface SpeechQuotas {
40
+ readonly transcribe: { readonly perMinute: number; readonly perHour: number }
41
+ readonly speak: { readonly perMinute: number; readonly perHour: number; readonly charsPerHour: number }
42
+ }
43
+
44
+ /** hub.json `agents.speechQuotas`: any subset of SpeechQuotas (positive whole numbers). */
45
+ export interface SpeechQuotaOverrides {
46
+ readonly transcribe?: Partial<SpeechQuotas["transcribe"]>
47
+ readonly speak?: Partial<SpeechQuotas["speak"]>
48
+ }
49
+
50
+ /** The defaults with hub.json's overrides on top. */
51
+ export function speechQuotas(overrides: SpeechQuotaOverrides = {}): SpeechQuotas {
52
+ return {
53
+ transcribe: { ...SPEECH_RATE_LIMITS.transcribe, ...overrides.transcribe },
54
+ speak: { ...SPEECH_RATE_LIMITS.speak, ...overrides.speak },
55
+ }
56
+ }
57
+
58
+ const MINUTE_MS = 60_000
59
+ const HOUR_MS = 60 * MINUTE_MS
60
+ /** At most one audit entry per credential, kind and outcome this often (it carries the hour's count so far). */
61
+ export const AUDIT_SLOT_MS = 15 * MINUTE_MS
62
+ /** C0/C1 control characters (tabs and line breaks aside, which speakable() folds): never sent to the provider. */
63
+ const CONTROLS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g
64
+ /** A speak body is {text}: the text is at most maxSpeechChars after speakable(), so 64 KiB is plenty. */
65
+ export const SPEAK_BODY_MAX = 64 * 1024
66
+
67
+ interface Window {
68
+ readonly index: number
69
+ readonly remaining: number
70
+ }
71
+
72
+ const windowOf = (t: number, size: number): Window => {
73
+ const index = Math.floor(t / size)
74
+ return { index, remaining: (index + 1) * size - t }
75
+ }
76
+
77
+ /** Fixed per-credential windows in the shared Counters (cluster-wide). */
78
+ export class SpeechQuota {
79
+ private readonly now: Clock
80
+
81
+ constructor(
82
+ private readonly counters: Counters,
83
+ private readonly quotas: SpeechQuotas,
84
+ now?: Clock,
85
+ ) {
86
+ this.now = now ?? Date.now
87
+ }
88
+
89
+ private checks(credential: string, kind: SpeechQuotaKind, chars: number) {
90
+ const t = this.now()
91
+ const minute = windowOf(t, MINUTE_MS)
92
+ const hour = windowOf(t, HOUR_MS)
93
+ const quota = this.quotas[kind]
94
+ const key = (name: string, window: Window) => `speech:${kind}:${name}:${credential}:${window.index}`
95
+ const checks: { name: string; key: string; window: Window; max: number; by: number }[] = [
96
+ { name: "perMinute", key: key("m", minute), window: minute, max: quota.perMinute, by: 1 },
97
+ { name: "perHour", key: key("h", hour), window: hour, max: quota.perHour, by: 1 },
98
+ ]
99
+ if (kind === "speak") checks.push({ name: "charsPerHour", key: key("c", hour), window: hour, max: this.quotas.speak.charsPerHour, by: chars })
100
+ return checks
101
+ }
102
+
103
+ private refused(kind: SpeechQuotaKind, check: { name: string; window: Window; max: number }): AgentError {
104
+ return new AgentError("rate_limited", `Too many speech requests (${kind} ${check.name} limit: ${check.max})`, {
105
+ hint: "Wait and try again",
106
+ extra: { retryAfter: Math.max(1, Math.ceil(check.window.remaining / 1000)), limit: check.name },
107
+ })
108
+ }
109
+
110
+ /**
111
+ * An early, non-counting refusal before the body is read (a full request window). Advisory only: `admit` decides.
112
+ */
113
+ async peek(credential: string, kind: SpeechQuotaKind): Promise<void> {
114
+ for (const check of this.checks(credential, kind, 0)) {
115
+ if (check.by > 0 && (await this.counters.get(check.key)) >= check.max) throw this.refused(kind, check)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Counts one request (and `chars` characters, speak) of the credential, atomically per window: each window is
121
+ * incremented first and its returned value checked, so parallel requests (on any pod) can never pass a limit. When
122
+ * one window is over, every increment this call made is undone and it throws rate_limited. While an undo is in
123
+ * flight a concurrent request may be refused that would have fit: refusals err on the safe side, admissions never.
124
+ */
125
+ async admit(credential: string, kind: SpeechQuotaKind, chars = 0): Promise<void> {
126
+ const done: { key: string; window: Window; by: number }[] = []
127
+ for (const check of this.checks(credential, kind, chars)) {
128
+ if (check.by <= 0) continue
129
+ const value = await this.counters.incr(check.key, check.window.remaining, check.by)
130
+ done.push(check)
131
+ if (value > check.max) {
132
+ await this.undo(done)
133
+ throw this.refused(kind, check)
134
+ }
135
+ }
136
+ }
137
+
138
+ private async undo(done: readonly { key: string; window: Window; by: number }[]): Promise<void> {
139
+ for (const step of done) {
140
+ try {
141
+ await this.counters.incr(step.key, Math.max(1, step.window.remaining), -step.by)
142
+ } catch (cause) {
143
+ // The window keeps the extra count until it ends: stricter, never looser.
144
+ console.error(`[hub] speech quota undo failed: ${(cause as Error).message}`)
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ /** What the speech routes need from the hub (http.ts): the user's own speech. */
151
+ export interface AppSpeech {
152
+ /** Whether the user has a provider for each kind (an OpenRouter key, or an openai-compatible base URL). */
153
+ ready(userID: string): { readonly stt: boolean; readonly tts: boolean }
154
+ transcribe(userID: string, audio: Uint8Array, format: string): Promise<string>
155
+ /** The audio, streamed when the provider streams. */
156
+ speak(userID: string, text: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>>
157
+ }
158
+
159
+ export interface SpeechRouteDeps {
160
+ readonly speech: AppSpeech
161
+ readonly counters: Counters
162
+ readonly maxAudioBytes: number
163
+ readonly maxSpeechChars: number
164
+ readonly quotas?: SpeechQuotaOverrides
165
+ readonly audit?: { record(userID: string, input: AuditInput): Promise<unknown> }
166
+ readonly now?: Clock
167
+ }
168
+
169
+ /** Runs checks made before the body is read; a refusal then says Connection: close when a body was sent (body.ts). */
170
+ async function unread(request: Request, checks: () => Promise<void>): Promise<void> {
171
+ try {
172
+ await checks()
173
+ } catch (cause) {
174
+ if (!(cause instanceof AgentError) || request.body === null) throw cause
175
+ throw new AgentError(cause.code, cause.message, { ...cause.options, close: true })
176
+ }
177
+ }
178
+
179
+ /** The credential a quota counts against: the app's hand-made token or connection (appOnly ran before). */
180
+ const credentialOf = (actor: AgentActor): string => (actor.kind === "app" ? `app:${actor.tokenID}` : `${actor.kind}:${actor.userID}`)
181
+
182
+ export function speechRoutes(deps: SpeechRouteDeps): AgentRoute[] {
183
+ const now = deps.now ?? Date.now
184
+ const quota = new SpeechQuota(deps.counters, speechQuotas(deps.quotas), now)
185
+
186
+ /**
187
+ * Counts every request per kind and outcome and clock hour; lists at most one entry per AUDIT_SLOT_MS with the
188
+ * hour's count so far, the hour's first one also with the previous hour's total (see the header).
189
+ */
190
+ const audit = async (context: AgentContext, verb: "speech.transcribe" | "speech.speak", decision: AuditDecision, outcome: AuditOutcome) => {
191
+ const { actor, user } = context
192
+ if (!deps.audit || actor.kind !== "app") return
193
+ const t = now()
194
+ const hour = windowOf(t, HOUR_MS)
195
+ const slot = windowOf(t, AUDIT_SLOT_MS)
196
+ const base = `speech:audit:${verb}:${decision}:${outcome}:${credentialOf(actor)}`
197
+ try {
198
+ // Kept one hour longer, so the next hour's first entry can read it.
199
+ const count = await deps.counters.incr(`${base}:n:${hour.index}`, hour.remaining + HOUR_MS)
200
+ if ((await deps.counters.incr(`${base}:s:${slot.index}`, slot.remaining)) !== 1) return
201
+ const firstOfHour = (await deps.counters.incr(`${base}:f:${hour.index}`, hour.remaining)) === 1
202
+ const previous = firstOfHour ? await deps.counters.get(`${base}:n:${hour.index - 1}`) : 0
203
+ const reason = `${count} this hour so far${previous > 0 ? `; previous hour: ${previous}` : ""}`
204
+ await deps.audit.record(user.id, { actor: partyFromActor(actor), verb, target: {}, decision, outcome, reason })
205
+ } catch (cause) {
206
+ console.error(`[hub] speech audit failed: ${(cause as Error).message}`)
207
+ }
208
+ }
209
+
210
+ /** Runs `work`, auditing its rate-limit refusal, provider failure or success; input refusals are not audited. */
211
+ const audited = async (context: AgentContext, verb: "speech.transcribe" | "speech.speak", work: () => Promise<Response>): Promise<Response> => {
212
+ try {
213
+ const response = await work()
214
+ await audit(context, verb, "allow", "ok")
215
+ return response
216
+ } catch (cause) {
217
+ if (cause instanceof AgentError && cause.code === "rate_limited") await audit(context, verb, "rate_limited", "refused")
218
+ else if (cause instanceof AgentError && cause.code === "upstream_failed") await audit(context, verb, "allow", "failed")
219
+ else if (cause instanceof AgentError && cause.code === "unsupported") await audit(context, verb, "allow", "unsupported")
220
+ throw cause
221
+ }
222
+ }
223
+
224
+ const unsupported = (kind: "stt" | "tts") =>
225
+ new AgentError("unsupported", kind === "stt" ? "No speech-to-text provider is set up for this user" : "No text-to-speech provider is set up for this user", {
226
+ hint: "The user sets one up in Settings → Speech",
227
+ extra: { feature: "speech", kind },
228
+ })
229
+
230
+ /**
231
+ * A provider failure as 502 upstream_failed: a generic message and the provider's HTTP status only. The provider's
232
+ * own text (its name, model, masked key fragments) is logged on the hub, never sent to the app.
233
+ */
234
+ const upstream = (cause: unknown, verb: string): AgentError => {
235
+ if (cause instanceof AgentError) return cause
236
+ console.error(`[hub] app speech provider failed (${verb}): ${(cause as Error)?.message ?? cause}`)
237
+ const status = cause instanceof SpeechError && typeof cause.status === "number" && Number.isInteger(cause.status) ? cause.status : undefined
238
+ return new AgentError("upstream_failed", `The speech provider failed${status !== undefined ? ` (HTTP ${status})` : ""}`, {
239
+ hint: "Try again later",
240
+ ...(status !== undefined ? { extra: { providerStatus: status } } : {}),
241
+ })
242
+ }
243
+
244
+ /** (O3) Apps only: the user's devices have the console's own routes. */
245
+ const appOnly = (actor: AgentActor, consoleRoute: string): void => {
246
+ if (actor.kind !== "app") throw new AgentError("forbidden", "These routes are for app credentials (hat_ or a connected app)", { hint: `The console uses POST ${consoleRoute}` })
247
+ }
248
+
249
+ return [
250
+ {
251
+ method: "POST",
252
+ path: SPEECH_PATHS.transcribe,
253
+ allow: { scope: "speech:use" },
254
+ handle: (context) =>
255
+ audited(context, "speech.transcribe", async () => {
256
+ const { request, actor, user } = context
257
+ appOnly(actor, "/api/transcribe")
258
+ const credential = credentialOf(actor)
259
+ // Refused before the audio is read: the cheap checks first, answered with Connection: close (body.ts).
260
+ await unread(request, async () => {
261
+ await quota.peek(credential, "transcribe")
262
+ const type = request.headers.get("content-type")
263
+ if (!audioFormat(type)) throw new AgentError("invalid", `Unsupported audio type: ${(type ?? "none").slice(0, 100)}`, { hint: "Send webm, mp4/m4a, aac, ogg, wav, mpeg or flac" })
264
+ if (!deps.speech.ready(user.id).stt) throw unsupported("stt")
265
+ })
266
+ const format = audioFormat(request.headers.get("content-type"))!
267
+ const read = await readBounded(request, deps.maxAudioBytes)
268
+ if (!read.ok) throw bodyError(read, deps.maxAudioBytes)
269
+ if (read.bytes.length === 0) throw new AgentError("invalid", "Empty recording", { extra: { limit: deps.maxAudioBytes } })
270
+ await quota.admit(credential, "transcribe")
271
+ let text: string
272
+ try {
273
+ text = await deps.speech.transcribe(user.id, read.bytes, format)
274
+ } catch (cause) {
275
+ throw upstream(cause, "transcribe")
276
+ }
277
+ return Response.json({ text }, { headers: { "cache-control": "no-store" } })
278
+ }),
279
+ },
280
+ {
281
+ method: "POST",
282
+ path: SPEECH_PATHS.speak,
283
+ allow: { scope: "speech:use" },
284
+ handle: (context) =>
285
+ audited(context, "speech.speak", async () => {
286
+ const { request, actor, user } = context
287
+ appOnly(actor, "/api/speak")
288
+ const credential = credentialOf(actor)
289
+ await unread(request, () => quota.peek(credential, "speak"))
290
+ const read = await readBounded(request, SPEAK_BODY_MAX)
291
+ if (!read.ok) throw bodyError(read, SPEAK_BODY_MAX)
292
+ let input: unknown
293
+ try {
294
+ input = JSON.parse(Buffer.from(read.bytes).toString("utf8"))
295
+ } catch {
296
+ throw new AgentError("invalid", "Invalid JSON")
297
+ }
298
+ if (typeof input !== "object" || input === null || Array.isArray(input) || typeof (input as { text?: unknown }).text !== "string") {
299
+ throw new AgentError("invalid", "Body must be {text: string}")
300
+ }
301
+ const text = speakable((input as { text: string }).text.replace(CONTROLS, ""))
302
+ if (!text) throw new AgentError("invalid", "Nothing to speak", { extra: { limit: deps.maxSpeechChars } })
303
+ if (text.length > deps.maxSpeechChars) throw new AgentError("invalid", `The text is longer than ${deps.maxSpeechChars} characters (after speakable())`, { extra: { limit: deps.maxSpeechChars } })
304
+ if (!deps.speech.ready(user.id).tts) throw unsupported("tts")
305
+ await quota.admit(credential, "speak", text.length)
306
+ let audio: ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>
307
+ try {
308
+ audio = await deps.speech.speak(user.id, text, request.signal)
309
+ } catch (cause) {
310
+ throw upstream(cause, "speak")
311
+ }
312
+ return new Response(audio, { headers: { "content-type": "audio/mpeg", "cache-control": "no-store" } })
313
+ }),
314
+ },
315
+ ]
316
+ }