@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.
Files changed (143) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +153 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -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 +69 -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/webhooks.ts +5 -2
  60. package/src/central/agent-data.ts +43 -1
  61. package/src/central/backend/memory-counters.ts +2 -2
  62. package/src/central/backend/types.ts +5 -2
  63. package/src/central/backend/valkey-counters.ts +5 -4
  64. package/src/central/client-address.ts +16 -0
  65. package/src/central/concierge/board-render.ts +52 -1
  66. package/src/central/concierge/board-routes.ts +25 -2
  67. package/src/central/concierge/board.ts +11 -1
  68. package/src/central/concierge/context.ts +29 -8
  69. package/src/central/concierge/grant-request.ts +216 -0
  70. package/src/central/concierge/prompt.ts +49 -2
  71. package/src/central/concierge/session-routes.ts +3 -0
  72. package/src/central/config.ts +88 -5
  73. package/src/central/context-cards/budget.ts +121 -0
  74. package/src/central/context-cards/changes.ts +29 -0
  75. package/src/central/context-cards/config.ts +145 -0
  76. package/src/central/context-cards/engine.ts +379 -0
  77. package/src/central/context-cards/inputs.ts +111 -0
  78. package/src/central/context-cards/pickup.ts +134 -0
  79. package/src/central/context-cards/registry.ts +89 -0
  80. package/src/central/context-cards/routes.ts +37 -0
  81. package/src/central/context-cards/service.ts +367 -0
  82. package/src/central/context-cards/store.ts +67 -0
  83. package/src/central/context-cards/types.ts +88 -0
  84. package/src/central/fetcher.ts +10 -2
  85. package/src/central/front-relays.ts +423 -0
  86. package/src/central/http.ts +517 -56
  87. package/src/central/human-activity.ts +453 -0
  88. package/src/central/llm/chat.ts +133 -0
  89. package/src/central/push-target.ts +11 -2
  90. package/src/central/registry.ts +162 -9
  91. package/src/central/speech/service.ts +9 -0
  92. package/src/central/spoke-ws.ts +13 -0
  93. package/src/central/store.ts +9 -12
  94. package/src/config.ts +30 -13
  95. package/src/index.ts +45 -43
  96. package/src/rpc.ts +33 -4
  97. package/src/spoke/concierge-agent.ts +1 -1
  98. package/src/spoke/conversation.ts +6 -75
  99. package/src/spoke/deliver.ts +18 -8
  100. package/src/spoke/hub-rpc.ts +7 -2
  101. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  102. package/src/spoke/hub-tool-title.ts +89 -0
  103. package/src/spoke/hub-tool.ts +56 -18
  104. package/src/spoke/session-context.ts +10 -2
  105. package/src/spoke/spoke.ts +209 -238
  106. package/src/spoke/summary.ts +36 -1
  107. package/src/spoke/taskboard.ts +5 -6
  108. package/src/spoke/voice-front.ts +323 -30
  109. package/src/spoke/who-teams.ts +51 -2
  110. package/src/tui-messaging.ts +24 -10
  111. package/src/tui.ts +6 -6
  112. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  113. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  114. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  115. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  116. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  117. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  118. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  119. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  120. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  121. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  122. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  123. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  124. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  125. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  126. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  127. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  128. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  129. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  130. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  131. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  132. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  133. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  134. package/web/dist/index.html +6 -6
  135. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  136. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  137. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  138. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  139. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  140. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  141. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  142. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  143. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
8
8
  import path from "node:path"
9
9
  import { promisify } from "node:util"
10
10
  import { brotliCompress, constants as zlib, gzip } from "node:zlib"
11
- import { formAnswerProblem, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor } from "../protocol"
11
+ import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor, type SessionModelPrefs } from "../protocol"
12
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
13
13
  import { speakable } from "../speakable"
14
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -22,7 +22,11 @@ import { createUnfurl } from "./media-unfurl"
22
22
  import { createFetchBudget } from "./fetch-budget"
23
23
  import { createTimelineMedia } from "./media-timeline"
24
24
  import { Webhooks } from "./agent/webhooks"
25
- import { issueAppToken } from "./agent/app-tokens"
25
+ import { subscriptionActor, Subscriptions } from "./agent/subscriptions"
26
+ import { subscriptionResolver } from "./agent/subscription-routes"
27
+ import { APP_SCOPES, issueAppToken } from "./agent/app-tokens"
28
+ import { AppConnect } from "./agent/connect"
29
+ import { clientAddress } from "./client-address"
26
30
  import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
27
31
  import { EventWriters } from "./agent/event-writers"
28
32
  import { MemoryEventLog } from "./backend/memory-events"
@@ -36,6 +40,7 @@ import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from
36
40
  import { Acting, type Escalate } from "./agent/acting"
37
41
  import { Escalations } from "./agent/escalation"
38
42
  import { GrantRequests } from "./agent/grant-requests"
43
+ import { ConciergeGrantRequests, conciergeGrantRequestRoutes, watchedProjects } from "./concierge/grant-request"
39
44
  import { partyFromActor } from "./agent/envelope"
40
45
  import { Delivery } from "./agent/delivery"
41
46
  import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
@@ -45,12 +50,14 @@ import { hubDirectory } from "./agent/directory-routes"
45
50
  import type { Directory } from "./agent/directory"
46
51
  import { installResponse } from "./install/render"
47
52
  import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
53
+ import type { AppSpeech } from "./agent/speech-routes"
48
54
  import { MCP_PATH, mcpHttp } from "./mcp-http"
49
55
  import { SessionTokens } from "./agent/session-tokens"
50
56
  import { SessionTokenPush } from "./session-token-push"
51
57
  import { TokenKeys } from "./token-keys"
52
58
  import { EventLog } from "./events-log"
53
- import { BusyError, Registry, type Timers } from "./registry"
59
+ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./registry"
60
+ import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
54
61
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
55
62
  import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
56
63
  import { audioFormat, SpeechError } from "./speech/provider"
@@ -95,11 +102,17 @@ import { TeamStaffingService } from "./teams/staffing"
95
102
  import { intakeActor, TeamIntakeService } from "./teams/intake"
96
103
  import { TeamStore } from "./teams/store"
97
104
  import { ActivityTracker } from "./concierge/activity"
105
+ import { HumanActivity, withHumanActivity } from "./human-activity"
98
106
  import { ResultTracker } from "./concierge/board-results"
99
107
  import { BoardRevisions } from "./concierge/board"
100
108
  import { conciergeBoard } from "./concierge/board-routes"
109
+ import { renderFrontBoard } from "./concierge/board-render"
101
110
  import { ContextPusher } from "./concierge/context"
102
111
  import { C1_ENTITLEMENTS } from "./concierge/entitlements"
112
+ import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
113
+ import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
114
+ import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
115
+ import type { ChatClient } from "./llm/chat"
103
116
  import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge/inbound"
104
117
  import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
105
118
  import { watchedSessions } from "./concierge/notifier"
@@ -113,11 +126,16 @@ import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Sup
113
126
  import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
114
127
  import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
115
128
  import { grantState } from "./agent/grants"
116
- import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
129
+ import { AppStreams, MAX_UTTERANCE_CHARS, type ConversationPorts, type StreamSource } from "./agent/conversation"
130
+ import { newMessageID } from "./agent/messages"
131
+ import { catalogHarnesses, eventVisibility, TeamDiscovery } from "./teams/discovery"
117
132
  import { inRevertWindow, RevertWindows } from "./revert-windows"
118
133
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
119
134
  import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
120
135
 
136
+ /** How long shutdown waits for buffered session activity to be written (HumanActivity.stop). */
137
+ const HUMAN_ACTIVITY_STOP_MS = 3_000
138
+
121
139
  export interface Speech {
122
140
  transcribe(audio: Uint8Array, format: string): Promise<string>
123
141
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
@@ -151,8 +169,12 @@ export interface CentralOptions {
151
169
  readonly now?: () => number
152
170
  /** Shared state for new code (default: in memory, see ./backend). */
153
171
  readonly backends?: Backends
172
+ /** Context cards: a fake model client and the trigger debounce (tests). */
173
+ readonly contextCards?: { readonly chat?: ChatClient; readonly debounceMs?: number }
154
174
  /** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
155
175
  readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
176
+ /** (AF3) App conversation streams: heartbeat, re-check interval and unsent-bytes limit (tests shorten them). */
177
+ readonly appStreams?: { readonly heartbeatMs?: number; readonly recheckMs?: number; readonly bufferMaxBytes?: number }
156
178
  readonly timing?: {
157
179
  readonly helloTimeoutMs?: number
158
180
  readonly requestTimeoutMs?: number
@@ -162,6 +184,10 @@ export interface CentralOptions {
162
184
  /** (S4 M3) The longest one timeline image may take from the spoke, all chunks together (default 60 s). */
163
185
  readonly mediaDeadlineMs?: number
164
186
  readonly heartbeatMs?: number
187
+ /** (Session findability) How often buffered activity is checked for a due write (default 15 s). */
188
+ readonly humanActivityFlushMs?: number
189
+ /** (Session findability) At most one activity write per session per this long (default 60 s). */
190
+ readonly humanActivityWriteMs?: number
165
191
  readonly staleMs?: number
166
192
  /** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
167
193
  readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
@@ -210,6 +236,8 @@ export interface Central {
210
236
  readonly launchTokens: LaunchTokens
211
237
  /** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
212
238
  readonly webhooks: Webhooks
239
+ /** Session subscriptions (decision 74qawely): records and the leader's dispatcher (tests run a pass with tick). */
240
+ readonly subscriptions: Subscriptions
213
241
  /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
214
242
  readonly agentEvents: AgentEvents
215
243
  /** The agent directory (names, roles, address resolution). */
@@ -220,6 +248,8 @@ export interface Central {
220
248
  readonly agentEventWriters: EventWriters
221
249
  /** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
222
250
  readonly intentRetention: IntentRetention
251
+ /** Context cards (decision 9604b0a1; docs/context-cards.md): providers, engine, store, budget, settings. */
252
+ readonly contextCards: ContextCardService
223
253
  /** (REC-01) Voice & Concierge recordings (developer): the owner-only store, taps, export and retention. */
224
254
  readonly recordings: RecordingService
225
255
  /** The JSON API's route table (method, path, access), for audits and tests. */
@@ -350,7 +380,7 @@ const VIEW_PREF_MAX_ENTRIES = 500
350
380
  const VIEW_PREF_MAX_CHARS = 4096
351
381
  const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
352
382
  /** Longest utterance accepted for conversation mode (characters, after trimming). */
353
- export const MAX_UTTERANCE_CHARS = 4000
383
+ export { MAX_UTTERANCE_CHARS }
354
384
  /** Longest typed prompt or steer accepted (characters, after trimming); a bigger one would not fit in a frame. */
355
385
  export const MAX_PROMPT_CHARS = 100_000
356
386
  /** The shell's answer when the hub has no built web app (e.g. the plugin installed from GitHub). */
@@ -366,6 +396,8 @@ const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to yo
366
396
  const SPEECH_TEST_MAX_CHARS = 300
367
397
  // Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
368
398
  // redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
399
+ /** (AF1) The consent page: the console's SPA, never framed. */
400
+ const CONSENT_PAGE = /^\/connect\/consent\/hcf_[0-9a-f]{24}$/
369
401
  const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
370
402
  const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
371
403
  /** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
@@ -419,6 +451,8 @@ class HttpError extends Error {
419
451
  }
420
452
 
421
453
  /** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
454
+ /** (AF3) The same, as an /api/agent error. */
455
+ const unsupportedAgentError = (feature: string, harness: string) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
422
456
  const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
423
457
 
424
458
  const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
@@ -454,6 +488,8 @@ export function startCentral(options: CentralOptions): Central {
454
488
  const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
455
489
  /** SSE subscribers, one user each. */
456
490
  const listeners = new Set<Listener>()
491
+ /** (AF3) Open app conversation streams of this process: caps and revocation pulses. */
492
+ const appStreams = new AppStreams()
457
493
  /** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
458
494
  const eventLog = new EventLog()
459
495
  /** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
@@ -582,6 +618,29 @@ export function startCentral(options: CentralOptions): Central {
582
618
  const secure = publicURL.startsWith("https:")
583
619
 
584
620
  auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
621
+ // (AF1) Connect an app: registered clients, the consent flow, connected-app tokens. Ending a connection deletes the
622
+ // grants it holds and the webhooks it registered (app:<connection>); its launches keep running.
623
+ const appConnect = new AppConnect({
624
+ store,
625
+ kv: backends.kv,
626
+ counters: backends.counters,
627
+ publicURL: () => publicURL,
628
+ scopes: APP_SCOPES,
629
+ now,
630
+ ended: async (userID, connectionID) => {
631
+ const holder = `app:${connectionID}` as const
632
+ for (const grant of await grants.list(userID)) if (grant.holder === holder && grant.state === "active") await grants.revoke(userID, grant.id)
633
+ for (const hook of await webhooks.list(userID, holder)) await webhooks.remove(userID, hook.id, holder)
634
+ // (AF3) Its open streams end now (end revoked).
635
+ appStreams.pulse(userID)
636
+ },
637
+ })
638
+ /** The client address for per-address limits (HUB_TRUSTED_PROXIES: client-address.ts). */
639
+ const trustedProxies = parseDenyCidrs((config.trustedProxies ?? []).join(","), "HUB_TRUSTED_PROXIES")
640
+ const addressOf = (request: Request, server: Server<SpokeSocketData>) => clientAddress(request, server.requestIP(request)?.address, trustedProxies)
641
+ /** An app credential of the user: a hand-made token or a connected app (launch flags, webhooks, grants). */
642
+ const appCredential = (userID: string, id: string) => (store.agentData(userID).appTokens ?? []).some((t) => t.id === id) || appConnect.alive(userID, id)
643
+ const appCredentialScopes = (userID: string, id: string) => store.agentData(userID).appTokens?.find((t) => t.id === id)?.scopes ?? appConnect.scopesOf(userID, id)
585
644
  // options.fetch is for speech providers only; sign-in always talks to the real issuer.
586
645
  const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
587
646
  /** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
@@ -608,6 +667,8 @@ export function startCentral(options: CentralOptions): Central {
608
667
  ...options.timing,
609
668
  })
610
669
  spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
670
+ // (voice front P2) The Concierge front's relays: checked and run by the hub (relayToSession below).
671
+ spokes.relayToSession = (owner, connID, req) => relayToSession(owner, connID, req)
611
672
  // Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
612
673
  // registries' reads cover the whole cluster (design §9).
613
674
  const view = config.cluster
@@ -926,6 +987,19 @@ export function startCentral(options: CentralOptions): Central {
926
987
  }
927
988
  })
928
989
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
990
+ // (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
991
+ // owner's consoles get each change as {type: "activity", sessionID, activity}.
992
+ const humanActivity = new HumanActivity({
993
+ state: backends.state,
994
+ now,
995
+ ...(options.timing?.humanActivityFlushMs !== undefined ? { flushEveryMs: options.timing.humanActivityFlushMs } : {}),
996
+ ...(options.timing?.humanActivityWriteMs !== undefined ? { writeEveryMs: options.timing.humanActivityWriteMs } : {}),
997
+ onChange: (userID, sessionID, activity) => broadcast({ type: "activity", sessionID, activity }, { tenant: userID }),
998
+ })
999
+ // A prompt typed in the terminal (exact: what the hub carried is marked so, see Registry.onTerminalPrompt).
1000
+ registry.onTerminalPrompt((rootID, tenant) => humanActivity.human(tenant, rootID, "prompt-tui"))
1001
+ registry.onAgentWork((rootID, tenant, what) => humanActivity.agent(tenant, rootID, what))
1002
+ humanActivity.start()
929
1003
  // (S4 M3) Timeline images, cached per user on this pod; a deleted session or a committed revert forgets them.
930
1004
  const timelineMedia = createTimelineMedia({
931
1005
  request: (connID, req) => spokes.request(connID, req),
@@ -940,9 +1014,12 @@ export function startCentral(options: CentralOptions): Central {
940
1014
  timelineMedia.forgetSession(sessionID)
941
1015
  }
942
1016
  })
943
- registry.onSessionDeleted((sessionID) => {
1017
+ registry.onSessionDeleted((sessionID, tenant) => {
944
1018
  mediaRevertCommits.delete(sessionID)
945
1019
  timelineMedia.forgetSession(sessionID)
1020
+ // (AF3) Its app streams end now (gone); the directory remembers it for every pod (their re-checks, new connects).
1021
+ appStreams.deleted(tenant, sessionID)
1022
+ void directory.markDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] directory delete mark failed: ${(cause as Error).message}`))
946
1023
  })
947
1024
  // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
948
1025
  const intents = new SessionIntents({
@@ -956,14 +1033,17 @@ export function startCentral(options: CentralOptions): Central {
956
1033
  * Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
957
1034
  * prompt the hub carries derives the session's intent once (CON-02).
958
1035
  */
959
- const actions = withPromptIntents(createActions({
1036
+ const actions = withHumanActivity(withPromptIntents(createActions({
960
1037
  registry,
961
1038
  spokes,
962
1039
  humanTurn,
963
1040
  kv: backends.kv,
964
1041
  // Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
965
1042
  ...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
966
- }), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
1043
+ }), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title), humanActivity, {
1044
+ tenant: (sessionID) => registry.sessionTenant(sessionID),
1045
+ root: (sessionID) => registry.rootOf(sessionID) ?? sessionID,
1046
+ })
967
1047
  leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
968
1048
  // Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
969
1049
  // service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
@@ -1006,7 +1086,7 @@ export function startCentral(options: CentralOptions): Central {
1006
1086
  ...(options.timing?.launchSettleMs !== undefined ? { settleMs: options.timing.launchSettleMs } : {}),
1007
1087
  ...(options.timing?.launchTickMs !== undefined ? { tickMs: options.timing.launchTickMs } : {}),
1008
1088
  // FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
1009
- launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
1089
+ launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !appCredential(userID, by.slice(4)) },
1010
1090
  profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
1011
1091
  // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
1012
1092
  ...(config.cluster
@@ -1061,8 +1141,12 @@ export function startCentral(options: CentralOptions): Central {
1061
1141
  users: () => store.users().map((u) => u.id),
1062
1142
  resolve: options.resolveHost ?? systemResolveHost,
1063
1143
  ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
1064
- appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && WEBHOOK_APP_SCOPES.some((scope) => t.scopes!.includes(scope))) === true,
1065
- appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
1144
+ ...(config.webhooksPrivateHosts?.length ? { privateHosts: config.webhooksPrivateHosts } : {}),
1145
+ appAlive: (userID, tokenID) => {
1146
+ const scopes = appCredentialScopes(userID, tokenID)
1147
+ return Array.isArray(scopes) && WEBHOOK_APP_SCOPES.some((scope) => scopes.includes(scope))
1148
+ },
1149
+ appScopes: appCredentialScopes,
1066
1150
  userAgent: `hub/${options.version ?? "0.0.0"}`,
1067
1151
  })
1068
1152
  const webhookLeader = leader("webhooks", {
@@ -1075,16 +1159,20 @@ export function startCentral(options: CentralOptions): Central {
1075
1159
  let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
1076
1160
  /** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
1077
1161
  let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
1162
+ /** Session subscriptions (set below, once team discovery exists): matching appends schedule a pass. */
1163
+ let subscriptions: Subscriptions | undefined
1078
1164
  // CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
1079
1165
  // rejects, and the catch keeps it that way: an unhandled rejection would end the process).
1080
1166
  const conciergeActivity = new ActivityTracker({ state: backends.state, now })
1081
1167
  // CON-03: the board's last result per root session, from appends (best effort, as above).
1082
1168
  const conciergeResults = new ResultTracker({ state: backends.state, now })
1169
+ /** Context cards (set below, once intents and the journal exist); journal appends trigger them. */
1170
+ let contextCards: ContextCardService | undefined
1083
1171
  const agentEvents = new AgentEvents({
1084
1172
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
1085
1173
  kv: backends.kv,
1086
1174
  now,
1087
- onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
1175
+ onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
1088
1176
  })
1089
1177
  const agentEventWriters = new EventWriters({
1090
1178
  events: agentEvents,
@@ -1144,6 +1232,58 @@ export function startCentral(options: CentralOptions): Central {
1144
1232
  })
1145
1233
  leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
1146
1234
  if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
1235
+ // Context cards (decision 9604b0a1; docs/context-cards.md): the hub calls a light model for a per-session
1236
+ // pick-up card. Automatic runs happen on the "context-cards" leader (other pods forward their triggers to it).
1237
+ const ownSession = (userID: string, sessionID: string) => registry.sessionTenant(sessionID) === userID
1238
+ const cards = new ContextCardService({
1239
+ state: backends.state,
1240
+ config: config.contextCards ?? {},
1241
+ keySources: () => ({ ...(config.apiKey ? { envKey: config.apiKey } : {}), speech: [store.speechSettings(), ...(config.speech ? [config.speech] : [])] }),
1242
+ ...(options.contextCards?.chat ? { chat: options.contextCards.chat } : {}),
1243
+ ...(options.fetch ? { fetch: options.fetch } : {}),
1244
+ inputs: {
1245
+ session: (userID, sessionID) => (ownSession(userID, sessionID) ? registry.session(sessionID) : undefined),
1246
+ intent: async (userID, sessionID) => (await intents.read(userID, sessionID))?.text,
1247
+ timeline: async (userID, sessionID) => {
1248
+ if (!ownSession(userID, sessionID)) return []
1249
+ const page = (await spokes.request(ownerOf(sessionID), { op: "timeline", sessionID, limit: TRANSCRIPT_ROWS, order: "desc" })) as { items?: unknown[] }
1250
+ return page?.items ?? []
1251
+ },
1252
+ summary: async (userID, sessionID) => (ownSession(userID, sessionID) ? spokes.request(ownerOf(sessionID), { op: "summary", sessionID }) : undefined),
1253
+ },
1254
+ isLeader: () => cardsLeader.isLeader(),
1255
+ forward: (userID, sessionID, trigger, humanAt) => void cardsLeader.kick({ userID, sessionID, trigger, ...(humanAt !== undefined ? { humanAt } : {}) }),
1256
+ onCard: (userID, card) => {
1257
+ broadcast({ type: "contextCard", sessionID: card.sessionID, provider: card.provider, ...(card.primary ? { primary: true } : {}), generatedAt: card.generatedAt }, { tenant: userID })
1258
+ void agentEvents.append(userID, contextCardEvent(card)).catch((cause) => console.error(`[hub] session.contextCard event failed: ${(cause as Error).message}`))
1259
+ },
1260
+ autoName: async (userID, sessionID) => {
1261
+ if (ownSession(userID, sessionID)) await autoName(sessionID)
1262
+ },
1263
+ // A session deleted while its card was made gets none (the directory marks it; the registry may still list it).
1264
+ exists: async (userID, sessionID) => !(store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt !== undefined),
1265
+ title: (userID, sessionID) => (ownSession(userID, sessionID) ? registry.session(sessionID)?.title : undefined),
1266
+ // (Session findability) The automatic cards' gate: the persisted human activity of the root session.
1267
+ lastHumanAt: (userID, sessionID) => humanActivity.lastAt(userID, registry.rootOf(sessionID) ?? sessionID),
1268
+ users: allUsers,
1269
+ now,
1270
+ ...(options.contextCards?.debounceMs !== undefined ? { debounceMs: options.contextCards.debounceMs } : {}),
1271
+ })
1272
+ contextCards = cards
1273
+ const cardsLeader = leader("context-cards", {
1274
+ onElected: () => cards.start(),
1275
+ onDeposed: () => cards.stop(),
1276
+ onKick: (message) => {
1277
+ const m = (message ?? {}) as Record<string, unknown>
1278
+ const trigger = m.trigger as AutoTrigger
1279
+ if (typeof m.userID !== "string" || typeof m.sessionID !== "string" || !CONTEXT_CARD_TRIGGERS.includes(trigger) || (trigger as string) === "manual") return
1280
+ cards.forwarded(m.userID, m.sessionID, trigger, typeof m.humanAt === "number" ? m.humanAt : undefined)
1281
+ },
1282
+ })
1283
+ // User-return cards follow the user's own touches (typed or sent prompts, Voice, answers, explicit opens; rooted),
1284
+ // never a prompt the hub carried for a launch or a Concierge proposal, nor the console's automatic read marks.
1285
+ humanActivity.onTouch((userID, rootID) => cards.human(userID, rootID))
1286
+ registry.onSessionDeleted((sessionID, tenant) => void cards.forget(tenant, sessionID).catch((cause) => console.error(`[hub] context card cleanup failed: ${(cause as Error).message}`)))
1147
1287
  for (const loop of leaders) void loop.start()
1148
1288
 
1149
1289
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
@@ -1178,7 +1318,7 @@ export function startCentral(options: CentralOptions): Central {
1178
1318
  registry.recordingTap = recordingService.registryTap
1179
1319
  registry.onSessionDeleted((sessionID, tenant) => void recordingService.sessionDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] recording session end failed: ${(cause as Error).message}`)))
1180
1320
  void leader("voice-recordings", { onElected: () => recordingService.retention.start(), onDeposed: () => recordingService.retention.stop() }).start()
1181
- const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
1321
+ const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs, connections: (owner, id) => appConnect.alive(owner, id), changed: (owner) => appStreams.pulse(owner) })
1182
1322
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1183
1323
  /** An agent as envelopes name it (a session by its directory name). */
1184
1324
  const describeActor = (userID: string, actor: AgentActor) => {
@@ -1220,6 +1360,8 @@ export function startCentral(options: CentralOptions): Central {
1220
1360
  now,
1221
1361
  describe: describeActor,
1222
1362
  published: (userID, message) => feed.publish(userID, message),
1363
+ // (Concierge grant requests) A new @@concierge grant changes what the Concierge may do: refresh its context soon.
1364
+ granted: (userID, grant) => void (grant.holder === "@@concierge" && conciergeTouch(userID, true)),
1223
1365
  })
1224
1366
  registry.onPermissionClosed((requestID, _sessionID, tenant) => {
1225
1367
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
@@ -1288,6 +1430,26 @@ export function startCentral(options: CentralOptions): Central {
1288
1430
  })
1289
1431
  void coordinatorLeader.start() // created after the other leaders were started
1290
1432
  const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
1433
+ // Session subscriptions (decision 74qawely, docs/subscriptions.md): journal events of a subscriber's targets, batched
1434
+ // into hub messages through Delivery (as app:subscriptions), from the `subscriptions` leader.
1435
+ const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
1436
+ subscriptions = new Subscriptions({
1437
+ state: backends.state,
1438
+ events: agentEvents,
1439
+ send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
1440
+ hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
1441
+ entries: (userID, sessionIDs) => directory.entries(userID, sessionIDs),
1442
+ resolveTarget: subscriptionResolver(directory, teamDiscovery),
1443
+ scope: async (userID, actor) => eventVisibility(await teamDiscovery.census(userID, actor)),
1444
+ appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes("events:subscribe")) === true,
1445
+ // The Concierge admits messages from the user and its own services only (CON-04): it follows sessions through its watchlist.
1446
+ refuseSubscriber: async (userID, sessionID) => ((await isConciergeSession(conciergeTimedIdentity, userID, sessionID)) ? "A Concierge session follows sessions through its watchlist, not subscriptions (hub watchlist watch <addr>)" : undefined),
1447
+ users: allUsers,
1448
+ isLeader: () => subscriptionLeader.isLeader(),
1449
+ now,
1450
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs, observeDelayMs: loopTickMs } : {}),
1451
+ })
1452
+ void subscriptionLeader.start() // created after the other leaders were started
1291
1453
 
1292
1454
  // ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
1293
1455
  const conciergeSettings = new SettingsStore(backends.state, now)
@@ -1304,12 +1466,30 @@ export function startCentral(options: CentralOptions): Central {
1304
1466
  let conciergeActing: Acting | undefined
1305
1467
  const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
1306
1468
  conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
1307
- const conciergeBoardDeps = { coverage: conciergeCoverage, identity: conciergeIdentity, state: backends.state, activity: conciergeActivity, directory, store, registry, decisions, labels: conciergeLabels, intents, results: conciergeResults, armed: conciergeArmed, timeZone: async (userID: string) => (await conciergeSettings.get(userID)).timeZone, now }
1469
+ // (Concierge grant requests) The board's project-level inGrant: the verbs the current Concierge's grants allow there.
1470
+ const conciergeProjectGrant = async (userID: string, conciergeSessionID: string, directory: string) => {
1471
+ const actor = { kind: "session" as const, userID, machineID: "", sessionID: conciergeSessionID, via: "act" as const }
1472
+ const verbs = ["approve", "deny", "answer", "prompt", "stop"] as const
1473
+ const usable = await Promise.all(verbs.map((verb) => grants.usable(actor, verb, { directory })))
1474
+ return verbs.filter((_, i) => usable[i]!.length > 0)
1475
+ }
1476
+ const conciergeBoardDeps = { cards, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant, identity: conciergeIdentity, state: backends.state, activity: conciergeActivity, directory, store, registry, decisions, labels: conciergeLabels, intents, results: conciergeResults, armed: conciergeArmed, timeZone: async (userID: string) => (await conciergeSettings.get(userID)).timeZone, now }
1308
1477
  const conciergeRevisions = new BoardRevisions(backends.state)
1478
+ // (Concierge grant requests) The Concierge asks for a scoped grant through the grant-request path (tap Decision).
1479
+ const conciergeGrantRequests = new ConciergeGrantRequests({
1480
+ requests: grantRequests,
1481
+ grants,
1482
+ settings: (userID) => conciergeSettings.get(userID),
1483
+ watched: async (userID) => watchedProjects(conciergeBoardDeps, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs),
1484
+ })
1309
1485
  const conciergeContext = new ContextPusher({
1310
1486
  registry,
1311
1487
  send: (connID, req) => spokes.request(connID, req),
1312
- board: async (userID) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)).rendered,
1488
+ // (P2) The compact board for the Concierge's voice front goes along (to spokes that take it).
1489
+ board: async (userID) => {
1490
+ const view = await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)
1491
+ return { rendered: view.rendered, front: renderFrontBoard(view.board) }
1492
+ },
1313
1493
  notes: conciergeNotes,
1314
1494
  settings: conciergeSettings,
1315
1495
  grant: async (userID, grantID) => {
@@ -1320,6 +1500,8 @@ export function startCentral(options: CentralOptions): Central {
1320
1500
  return undefined
1321
1501
  }
1322
1502
  },
1503
+ // (Concierge grant requests) The @@concierge grants the user approved at its request (named after the grant line).
1504
+ requested: async (userID) => (await grants.list(userID)).filter((g) => g.holder === "@@concierge"),
1323
1505
  // (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
1324
1506
  voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
1325
1507
  now,
@@ -1510,7 +1692,59 @@ export function startCentral(options: CentralOptions): Central {
1510
1692
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1511
1693
  })
1512
1694
  void conciergeLeader.start()
1513
- const agentDeps = { recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1695
+ /** (AF2) Speech for apps: the user's own speech (speechFor), never the recording taps (REC-01 off for apps). */
1696
+ const appSpeech: AppSpeech = {
1697
+ ready: (userID) => (options.speech ? { stt: true, tts: true } : speechSettings.ready(speechScope(userID))),
1698
+ transcribe: (userID, audio, format) => speechFor(userID).transcribe(audio, format),
1699
+ speak: (userID, text, signal) => speakAudio(speechFor(userID), text, signal),
1700
+ }
1701
+ const speechDeps = { speech: appSpeech, counters: backends.counters, maxAudioBytes: config.maxAudioBytes, maxSpeechChars: config.maxSpeechChars, ...(config.speechQuotas ? { quotas: config.speechQuotas } : {}), audit, now }
1702
+ /**
1703
+ * (AF3) The app conversation stream's events: this process's live stream (the console's listeners and EventLog,
1704
+ * which keeps streamed text too) or, in a cluster, the live fan-out and the shared EventLog (no streamed text).
1705
+ */
1706
+ const appStreamSource: StreamSource = {
1707
+ clustered: live !== undefined,
1708
+ subscribe(userID, handler) {
1709
+ if (live) {
1710
+ const subscription = live.subscribe(userID, (event) => handler({ ...(event.id !== undefined ? { id: event.id } : {}), message: event.message }))
1711
+ return { ready: subscription.ready, close: () => subscription.close() }
1712
+ }
1713
+ eventLog.open(userID)
1714
+ const listener: Listener = {
1715
+ userID,
1716
+ // Never a device: no message is ever "not for" it.
1717
+ deviceID: "",
1718
+ lite: false,
1719
+ send: (message, id) => {
1720
+ if (message.type !== "catalog") handler({ ...(id !== undefined ? { id } : {}), message })
1721
+ },
1722
+ // The user was disabled or removed: the stream's re-check ends it (end revoked).
1723
+ close: () => appStreams.pulse(userID),
1724
+ // Draining: the stream ends itself on the drain signal (retry:).
1725
+ end: () => {},
1726
+ }
1727
+ listeners.add(listener)
1728
+ return { ready: Promise.resolve(), close: () => void listeners.delete(listener) }
1729
+ },
1730
+ async since(userID, lastID) {
1731
+ if (live) {
1732
+ const missed = await backends.events.since(userID, lastID)
1733
+ return missed && "events" in missed ? missed.events.map(({ id, message }) => ({ id, message })) : undefined
1734
+ }
1735
+ return eventLog.since(userID, lastID)?.map(({ id, message }) => ({ id, message }))
1736
+ },
1737
+ latest: async (userID) => (live ? backends.events.latest(userID) : eventLog.latest(userID)),
1738
+ }
1739
+ /** (AF3) The console's conversation machinery, for the app conversation routes. */
1740
+ const conversationPorts: ConversationPorts = {
1741
+ conversation: (sessionID, on, front, { taps }) => setConversation(sessionID, on, front, undefined, taps),
1742
+ utterance: (sessionID, text, prompt) => sendUtterance(sessionID, text, prompt),
1743
+ interrupt: (sessionID) => interruptSession(sessionID, false),
1744
+ conversationOf: (sessionID) => registry.conversationOf(sessionID),
1745
+ stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
1746
+ }
1747
+ const agentDeps = { contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => [...conciergeParts.tiers.routes(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
1514
1748
  const resolveActor = createResolver({
1515
1749
  cookieName: COOKIE,
1516
1750
  findDevice: (token) => deviceOfToken(token),
@@ -1585,6 +1819,12 @@ export function startCentral(options: CentralOptions): Central {
1585
1819
  }
1586
1820
  if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
1587
1821
  if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
1822
+ // (AF1) Connect an app: public OAuth endpoints (never behind the console gate) and the consent page.
1823
+ if (method === "GET" && pathname === CONNECT_PATHS.metadata) return appConnect.metadata()
1824
+ if (method === "GET" && pathname === CONNECT_PATHS.authorize) return await appConnect.authorize(url, addressOf(request, server))
1825
+ if (method === "POST" && pathname === CONNECT_PATHS.token) return await appConnect.token(request, addressOf(request, server))
1826
+ if (method === "POST" && pathname === CONNECT_PATHS.revoke) return await appConnect.revoke(request, addressOf(request, server))
1827
+ if (method === "GET" && CONSENT_PAGE.test(pathname)) return await consentPage(request.headers.get("accept-encoding"))
1588
1828
  if (method === "GET" && pathname === "/.well-known/hub") {
1589
1829
  return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
1590
1830
  }
@@ -1742,6 +1982,16 @@ export function startCentral(options: CentralOptions): Central {
1742
1982
  return Response.json({ prefs }, { headers: NO_STORE })
1743
1983
  }),
1744
1984
  route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
1985
+ // (Session findability) The user's touched sessions: {activity: {sessionID: {at?, kind?, buckets}}}, and the
1986
+ // stored intents of the user's sessions for the list's second line: {intents: {sessionID: {text, source}}}.
1987
+ route("GET", "/api/activity/mine", "user", async ({ user }) => Response.json({ activity: await humanActivity.list(user.id) }, { headers: NO_STORE })),
1988
+ route("GET", "/api/intents/mine", "user", async ({ user }) =>
1989
+ Response.json({ intents: Object.fromEntries([...(await intents.all(user.id))].map(([id, intent]) => [id, { text: intent.text, source: intent.source }])) }, { headers: NO_STORE })),
1990
+ // (AF1) The consent page's flow: read it, approve with the scopes kept, or deny. Approve and deny need the device
1991
+ // cookie (same origin, checked above), never a device token; a personal client's flow exists only for its owner.
1992
+ route("GET", "/api/connect/flows/:flow", "user", async ({ params, user }) => connectAnswer(async () => Response.json(await appConnect.flowView(user, params.flow!), { headers: NO_STORE }))),
1993
+ route("POST", "/api/connect/flows/:flow/approve", "user", async ({ request, params, user }) => connectDecision(request, user, params.flow!, "approve")),
1994
+ route("POST", "/api/connect/flows/:flow/deny", "user", async ({ request, params, user }) => connectDecision(request, user, params.flow!, "deny")),
1745
1995
  // A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
1746
1996
  route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
1747
1997
  const login = await auth.cliLogin(params.code!)
@@ -1783,8 +2033,7 @@ export function startCentral(options: CentralOptions): Central {
1783
2033
  const speech = speechFor(user.id)
1784
2034
  // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
1785
2035
  const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
1786
- // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1787
- const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
2036
+ const audio = await speakAudio(speech, text, request.signal)
1788
2037
  return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
1789
2038
  }),
1790
2039
  // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
@@ -1858,6 +2107,20 @@ export function startCentral(options: CentralOptions): Central {
1858
2107
  return Response.json(view, { headers: NO_STORE })
1859
2108
  }),
1860
2109
  // (RF2) The user's media allowlist (Settings → Media) and whether this hub fetches web images at all.
2110
+ // Context cards: the primary card of each listed session (the session list's second line), ≤ 200 IDs.
2111
+ route("GET", "/api/context-cards/primary", "user", async ({ url, user }) => {
2112
+ const ids = (url.searchParams.get("sessions") ?? "").split(",").filter(Boolean)
2113
+ if (ids.length > 200 || ids.some((id) => id.length > 200)) return error(400, "At most 200 session IDs")
2114
+ const found = await cards.primaries(user.id, ids.filter((id) => registry.sessionTenant(id) === undefined || registry.sessionTenant(id) === user.id))
2115
+ return Response.json({ cards: Object.fromEntries(found) }, { headers: NO_STORE })
2116
+ }),
2117
+ route("GET", "/api/settings/context-cards", "user", async ({ user }) => Response.json(await cards.settingsView(user.id), { headers: NO_STORE })),
2118
+ route("PUT", "/api/settings/context-cards", "user", async ({ request, user }) => {
2119
+ const input = await body(request)
2120
+ if (typeof input.enabled !== "boolean" || Object.keys(input).some((k) => k !== "enabled")) return error(400, "Expected {enabled: true | false}")
2121
+ await store.audit({ actor: user.id, action: "context-cards.setting", target: user.id, detail: input.enabled ? "on" : "off" })
2122
+ return Response.json(await cards.setEnabled(user.id, input.enabled), { headers: NO_STORE })
2123
+ }),
1861
2124
  route("GET", "/api/settings/media", "user", ({ user }) => Response.json(mediaSettingsView(user.id), { headers: NO_STORE })),
1862
2125
  route("PUT", "/api/settings/media", "user", async ({ request, user }) => {
1863
2126
  await store.setMediaAllowlist(user.id, mediaSettingsInput(await jsonBody(request)))
@@ -1889,19 +2152,13 @@ export function startCentral(options: CentralOptions): Central {
1889
2152
  return new Response(null, { status: 204 })
1890
2153
  }),
1891
2154
  route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session!)),
1892
- route("POST", "/api/sessions/:session/utterance", "session", ({ request, params }) => utterance(request, params.session!), true),
2155
+ route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
1893
2156
  route(
1894
2157
  "POST",
1895
2158
  "/api/sessions/:session/interrupt",
1896
2159
  "session",
1897
2160
  async ({ params }) => {
1898
- // (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
1899
- const front = registry.frontRoute(params.session!)
1900
- // A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
1901
- const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(params.session!), false)) : false
1902
- if (!viaFront) await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1903
- registry.cancelTurns(params.session!)
1904
- recordingTap(params.session!, "interrupt", {})
2161
+ await interruptSession(params.session!)
1905
2162
  return new Response(null, { status: 204 })
1906
2163
  },
1907
2164
  ),
@@ -1931,25 +2188,18 @@ export function startCentral(options: CentralOptions): Central {
1931
2188
  route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
1932
2189
  route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
1933
2190
  route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
1934
- route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
2191
+ route("POST", "/api/sessions/:session/conversation", "session", ({ request, params, user }) => conversation(request, params.session!, user.id), true),
1935
2192
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
1936
2193
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
1937
- route("GET", "/api/sessions/:session/model-prefs", "session", async ({ params, user }) => {
1938
- const prefs = store.modelPrefs(user.id, params.session!)
1939
- const defaults = await spokes.request(ownerOf(params.session!), {
1940
- op: "setModelPrefs",
1941
- sessionID: params.session!,
1942
- voice: prefs.voice ?? null,
1943
- worker: prefs.worker ?? null,
1944
- })
1945
- return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
1946
- }),
2194
+ route("GET", "/api/sessions/:session/model-prefs", "session", ({ params, user }) => getModelPrefs(params.session!, user.id)),
1947
2195
  route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
1948
2196
  const change = modelPrefsInput(await jsonBody(request))
2197
+ // Only an obsolete Worker model (an older console): nothing changes.
2198
+ if (!("voice" in change)) return getModelPrefs(params.session!, user.id)
1949
2199
  await checkModelSelections(ownerOf(params.session!), params.session!, change)
1950
2200
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
1951
2201
  const prefs = await store.setModelPrefs(user.id, params.session!, change)
1952
- return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
2202
+ return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
1953
2203
  }),
1954
2204
  route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
1955
2205
  const limitRaw = url.searchParams.get("limit")
@@ -2012,15 +2262,32 @@ export function startCentral(options: CentralOptions): Central {
2012
2262
  route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
2013
2263
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
2014
2264
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
2265
+ // Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
2266
+ route("GET", "/api/sessions/:session/context-cards", "session", async ({ params, user }) => Response.json(await cards.view(user.id, params.session!), { headers: NO_STORE })),
2267
+ route("POST", "/api/sessions/:session/context-cards/:provider/refresh", "session", async ({ params, user, request }) => {
2268
+ if (!cards.registry.get(params.provider!)) return error(404, "No such context card")
2269
+ // {force: true} regenerates even when nothing changed (still deduped and rate limited).
2270
+ const input = request.headers.get("content-type")?.includes("json") ? await body(request) : {}
2271
+ const outcome = await cards.refresh(user.id, params.session!, params.provider!, { force: input.force === true })
2272
+ if (outcome.status === "skipped" && outcome.reason === "rate_limited") {
2273
+ const seconds = Math.max(1, Math.ceil((outcome.retryAfterMs ?? 1000) / 1000))
2274
+ return Response.json({ outcome: "skipped", reason: "rate_limited", retryAfter: seconds }, { status: 429, headers: { ...NO_STORE, "retry-after": String(seconds) } })
2275
+ }
2276
+ return Response.json(outcome.status === "generated" ? { outcome: "generated", card: outcome.card } : outcome.status === "skipped" ? { outcome: "skipped", reason: outcome.reason } : { outcome: "failed", error: "The context card could not be made" }, { headers: NO_STORE })
2277
+ }),
2015
2278
  route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
2016
2279
  const file = url.searchParams.get("file")
2017
2280
  if (!file || file.length > 4096) return error(400, "Expected ?file=<path>")
2018
2281
  const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
2019
2282
  return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
2020
2283
  }),
2021
- route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
2284
+ route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device, request }) => {
2022
2285
  const reads = await store.markRead(user.id, params.session!, Date.now())
2023
2286
  toUser(user.id, { type: "reads", reads }, device.id)
2287
+ // Only an explicit open (navigating to it, the page or pane shown again) touches the session, never the
2288
+ // console's automatic read marks of a visible pane (the touch also feeds the context cards' user-return).
2289
+ const input = await body(request).catch(() => ({}) as Record<string, unknown>)
2290
+ if (input.open === true) humanActivity.human(user.id, registry.rootOf(params.session!) ?? params.session!, "view")
2024
2291
  return Response.json({ reads }, { headers: NO_STORE })
2025
2292
  }),
2026
2293
 
@@ -2165,6 +2432,22 @@ export function startCentral(options: CentralOptions): Central {
2165
2432
  return new Response(null, { status: 204 })
2166
2433
  }
2167
2434
 
2435
+ /** A connect-flow handler's AgentError as the console's error envelope. */
2436
+ async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
2437
+ try {
2438
+ return await run()
2439
+ } catch (cause) {
2440
+ if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
2441
+ throw cause
2442
+ }
2443
+ }
2444
+
2445
+ async function connectDecision(request: Request, user: UserRecord, flow: string, decision: "approve" | "deny"): Promise<Response> {
2446
+ if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Approve or deny on the hub's consent page", "forbidden")
2447
+ const input = await body(request)
2448
+ return connectAnswer(async () => Response.json(await appConnect.decide(user, flow, decision, input.scopes), { headers: NO_STORE }))
2449
+ }
2450
+
2168
2451
  async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
2169
2452
  const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
2170
2453
  if (clientName === undefined) return error(404, "No pending CLI login with that code")
@@ -2385,6 +2668,13 @@ export function startCentral(options: CentralOptions): Central {
2385
2668
  })
2386
2669
  }
2387
2670
 
2671
+ /** A session's Voice model preference, re-sent to its spoke (which answers with its defaults). */
2672
+ async function getModelPrefs(sessionID: string, userID: string): Promise<Response> {
2673
+ const prefs = store.modelPrefs(userID, sessionID)
2674
+ const reply = await spokes.request(ownerOf(sessionID), { op: "setModelPrefs", sessionID, voice: prefs.voice ?? null })
2675
+ return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
2676
+ }
2677
+
2388
2678
  /**
2389
2679
  * GET …/timeline/messages/:message/media/:ref (S4 M3): 400 for a malformed ref or message ID, 409 offline, 422
2390
2680
  * unsupported {feature, harness} when the owning spoke did not announce timelineMedia; else ./media-timeline.ts.
@@ -2416,8 +2706,8 @@ export function startCentral(options: CentralOptions): Central {
2416
2706
  * Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
2417
2707
  * the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
2418
2708
  */
2419
- async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<void> {
2420
- const selections = [change.voice, change.worker].filter((m): m is ModelSelection => !!m)
2709
+ async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null }): Promise<void> {
2710
+ const selections = [change.voice].filter((m): m is ModelSelection => !!m)
2421
2711
  if (selections.length === 0) return
2422
2712
  let models: readonly AvailableModel[]
2423
2713
  try {
@@ -2513,19 +2803,55 @@ export function startCentral(options: CentralOptions): Central {
2513
2803
  if (tenant !== undefined) recordingService.hub(tenant, sessionID, kind, fields)
2514
2804
  }
2515
2805
 
2806
+ /** Barge-in; `taps` false (AF3, an app): REC-01's recording taps are left out. */
2807
+ async function interruptSession(sessionID: string, taps = true): Promise<void> {
2808
+ // (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
2809
+ const front = registry.frontRoute(sessionID)
2810
+ // A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
2811
+ const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(sessionID), false)) : false
2812
+ if (!viaFront) await spokes.request(ownerOf(sessionID, "interrupt"), { op: "interrupt", sessionID })
2813
+ registry.cancelTurns(sessionID)
2814
+ if (taps) recordingTap(sessionID, "interrupt", {})
2815
+ }
2816
+
2516
2817
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
2517
- async function utterance(request: Request, sessionID: string): Promise<Response> {
2818
+ async function utterance(request: Request, sessionID: string, deviceID?: string): Promise<Response> {
2518
2819
  const raw = (await body(request)).text
2519
2820
  const text = typeof raw === "string" ? raw.trim() : ""
2520
2821
  if (!text) return error(400, "Empty utterance")
2521
2822
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
2823
+ return Response.json({ inboxID: await sendUtterance(sessionID, text, undefined, deviceID) }, { status: 202 })
2824
+ }
2825
+
2826
+ /**
2827
+ * An utterance to the session, or to its voice front while one is active (it relays it); tracked for `heard`.
2828
+ * Without `prompt`: the console's steer (the user's own voice: a human turn, recorded by REC-01's taps). (AF3) With
2829
+ * `prompt`: an app's, delivered as an agent prompt with its hub-prompt provenance (from app:<id>, the grant),
2830
+ * steered into the running turn; no human turn, nothing recorded (REC-01 is off for apps).
2831
+ */
2832
+ async function sendUtterance(sessionID: string, text: string, prompt?: { readonly from: string; readonly to: string; readonly grant: string }, deviceID?: string): Promise<string> {
2833
+ const send = async (connID: string, target: string): Promise<string> => {
2834
+ if (!prompt) return (await spokes.request(connID, { op: "steer", sessionID: target, text })).inboxID
2835
+ for (const feature of ["deliver", "hubPrompt"] as const) {
2836
+ if (!registry.supports(connID, feature)) throw unsupportedAgentError(feature, registry.harnessOf(connID) ?? "unknown")
2837
+ }
2838
+ const messageID = newMessageID()
2839
+ const result = await spokes.request(connID, { op: "deliver", sessionID: target, messageID, text, wake: true, meta: { kind: "prompt", from: prompt.from, to: prompt.to, grant: prompt.grant, delivery: "steer" } })
2840
+ if (!isDeliverResult(result)) throw new Error("The spoke answered a deliver malformed")
2841
+ return result.inboxID ?? messageID
2842
+ }
2522
2843
  // (voice front P1) While a front is active, utterances go to the front; it relays them to the session.
2523
2844
  const front = registry.frontRoute(sessionID)
2524
- await humanTurn(sessionID)
2845
+ if (!prompt) {
2846
+ await humanTurn(sessionID)
2847
+ // The steer comes back from the spoke marked carried: it is recorded here once, as Voice.
2848
+ const speaker = registry.sessionTenant(sessionID)
2849
+ if (speaker !== undefined) humanActivity.human(speaker, registry.rootOf(sessionID) ?? sessionID, "voice")
2850
+ }
2525
2851
  // A front that cannot take it failed: the session talks directly (front_failed), never a 502.
2526
2852
  let inboxID = front
2527
- ? await spokes.request(front.connID, { op: "steer", sessionID: front.sessionID, text }).then(
2528
- (result) => result.inboxID,
2853
+ ? await send(front.connID, front.sessionID).then(
2854
+ (id) => id,
2529
2855
  (cause) => {
2530
2856
  console.warn(`[hub] voice front of ${sessionID} did not take the utterance: ${(cause as Error).message}`)
2531
2857
  registry.frontLost(sessionID)
@@ -2533,10 +2859,13 @@ export function startCentral(options: CentralOptions): Central {
2533
2859
  },
2534
2860
  )
2535
2861
  : undefined
2536
- if (inboxID === undefined) inboxID = (await spokes.request(ownerOf(sessionID, "steer"), { op: "steer", sessionID, text })).inboxID
2537
- recordingTap(sessionID, "utterance.received", { inboxID, text })
2862
+ // (P2) What the user said to the front: the only thing its relay_to_session may answer. Never an app's text
2863
+ // (AF3): an app's words never become the user's own for a relay.
2864
+ if (inboxID !== undefined && !prompt) registry.frontUtterance(sessionID, inboxID, deviceID, text)
2865
+ if (inboxID === undefined) inboxID = await send(prompt ? ownerOf(sessionID) : ownerOf(sessionID, "steer"), sessionID)
2866
+ if (!prompt) recordingTap(sessionID, "utterance.received", { inboxID, text })
2538
2867
  registry.trackUtterance(sessionID, inboxID)
2539
- return Response.json({ inboxID }, { status: 202 })
2868
+ return inboxID
2540
2869
  }
2541
2870
 
2542
2871
  /** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
@@ -2587,12 +2916,27 @@ export function startCentral(options: CentralOptions): Central {
2587
2916
  throw failure
2588
2917
  }
2589
2918
 
2590
- async function conversation(request: Request, sessionID: string): Promise<Response> {
2919
+ async function conversation(request: Request, sessionID: string, userID: string): Promise<Response> {
2591
2920
  const input = await body(request)
2592
2921
  const on = input.on
2593
2922
  if (typeof on !== "boolean" || (input.front !== undefined && typeof input.front !== "boolean")) return error(400, "Expected {on: boolean, front?: boolean}")
2594
- // (voice front P1) `front`: put a hidden voice front in front of the session, when its spoke serves it.
2595
- const wantsFront = on && input.front === true
2923
+ return Response.json(await setConversation(sessionID, on, input.front, userID))
2924
+ }
2925
+
2926
+ /**
2927
+ * Conversation mode on/off. Console (`userID` given): Voice mode always asks for the voice front (when the
2928
+ * session's spoke serves it), unless the device opted out with `front: false`; a console that sends no `front`
2929
+ * (older ones, the TUI's /voice link) gets the default. (AF3) An app (`userID` undefined, `taps` false): the front
2930
+ * only when asked for (`front: true`), never the Concierge's front (its relay_to_session acts as the user), and
2931
+ * REC-01's recording taps are left out (off for apps).
2932
+ */
2933
+ async function setConversation(sessionID: string, on: boolean, frontInput: boolean | undefined, userID?: string, taps = true): Promise<{ on: boolean; agent?: string; front?: boolean }> {
2934
+ const app = userID === undefined
2935
+ const asked = app ? frontInput === true : frontInput !== false
2936
+ const owner = userID ?? registry.sessionTenant(sessionID)
2937
+ // (P2) The Concierge's front only from a spoke that serves it ("conciergeFront"); else today's path, no front.
2938
+ const concierge = on && asked && owner !== undefined && (await conciergeTimedIdentity.current(owner).then((c) => c.sessionID === sessionID, () => false))
2939
+ const wantsFront = on && asked && (!concierge || (!app && registry.supports(ownerOf(sessionID), "conciergeFront")))
2596
2940
  const hadFront = registry.wantsFront(sessionID)
2597
2941
  // Turning off always clears the hub's state, so a later reconnect does not switch it back on.
2598
2942
  if (!on) registry.setConversation(sessionID, false)
@@ -2612,8 +2956,95 @@ export function startCentral(options: CentralOptions): Central {
2612
2956
  if (sendFront) registry.frontOpEnded(connID)
2613
2957
  }
2614
2958
  const front = registry.frontOf(sessionID) !== undefined
2615
- if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
2616
- return Response.json({ on, agent: result.agent, ...(wantsFront || (!on && hadFront) ? { front } : {}) })
2959
+ if (taps && on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
2960
+ return { on, ...(typeof result.agent === "string" ? { agent: result.agent } : {}), ...(wantsFront || (!on && hadFront) ? { front } : {}) }
2961
+ }
2962
+
2963
+ /**
2964
+ * (voice front P2) A Concierge front's relay_to_session (spoke request relayToSession from `connID`, a machine of
2965
+ * `owner`): enforced here, not only by the front's prompt. The front must be the active front of the owner's
2966
+ * current Concierge, linked by `connID`, answering the user's latest utterance to it (RELAYS_PER_UTTERANCE at
2967
+ * most); the target one of the owner's own, live, visible root sessions (no front, no Concierge session). It runs
2968
+ * as the user's own prompt (no grant, no Decision): steered into a busy session, a turn in an idle one; text only,
2969
+ * so the target's own permissions apply. Audited as the user's prompt with RELAY_REASON. A refusal throws (the
2970
+ * spoke's ok:false, read back to the front).
2971
+ */
2972
+ async function relayToSession(owner: string, connID: string, req: { readonly frontID: string; readonly session: string; readonly text: string; readonly previous?: boolean; readonly confirm?: boolean }): Promise<ResultFor["relayToSession"]> {
2973
+ const refuse = (message: string): never => {
2974
+ throw new RelayRefused(message)
2975
+ }
2976
+ // (hardening) Every refusal of the hub's checks is in the audit (Activity shows attempts), as the user's refused
2977
+ // prompt with the reason, the target when it was resolved and the device when the utterance is known.
2978
+ let audited: { sessionID?: string; deviceID?: string } = {}
2979
+ const refused = async (cause: unknown) => {
2980
+ if (!(cause instanceof RelayRefused)) return
2981
+ await audit
2982
+ .record(owner, {
2983
+ actor: { kind: "user" },
2984
+ verb: "prompt",
2985
+ target: audited.sessionID ? { sessionID: audited.sessionID } : {},
2986
+ decision: /at most \d+/.test(cause.message) ? "rate_limited" : "forbidden",
2987
+ reason: `Relay by voice refused: ${cause.message}`,
2988
+ outcome: "refused",
2989
+ ...(audited.deviceID ? { deviceID: audited.deviceID } : {}),
2990
+ })
2991
+ .catch((e: unknown) => console.error(`[hub] relay audit failed: ${(e as Error).message}`))
2992
+ }
2993
+ let binding: ReturnType<typeof registry.takeRelay>
2994
+ try {
2995
+ binding = registry.takeRelay(connID, req.frontID)
2996
+ } catch (cause) {
2997
+ if (cause instanceof RelayRefused && cause.deviceID) audited = { deviceID: cause.deviceID }
2998
+ await refused(cause)
2999
+ throw cause
3000
+ }
3001
+ audited = binding.deviceID ? { deviceID: binding.deviceID } : {}
3002
+ try {
3003
+ if (registry.sessionTenant(binding.mainID) !== owner) refuse("Not an active voice front")
3004
+ const identity = await conciergeTimedIdentity.current(owner)
3005
+ if (identity.sessionID !== binding.mainID) refuse("Only the Concierge's voice front relays to other sessions")
3006
+ let sessionID = ""
3007
+ try {
3008
+ sessionID = directory.resolveOne(owner, req.session).sessionID
3009
+ } catch (cause) {
3010
+ refuse(cause instanceof Error ? cause.message : `No session ${req.session}`)
3011
+ }
3012
+ if (registry.sessionTenant(sessionID) !== owner) refuse(`${req.session} is not one of yours`)
3013
+ audited = { ...audited, sessionID }
3014
+ if (sessionID === binding.mainID || registry.hidden(sessionID) || identity.hiddenSessionIDs.includes(sessionID)) refuse("That session takes no relays (a voice front or the Concierge): pass the request to the Concierge instead")
3015
+ const live = registry.session(sessionID) ?? refuse(`${req.session} is offline`)
3016
+ if ((registry.rootOf(sessionID) ?? sessionID) !== sessionID) refuse("Relays go to root sessions, not to subagent sessions")
3017
+ const engine = conciergeActing ?? refuse("This hub cannot relay right now")
3018
+ const actor = { kind: "user" as const, userID: owner, deviceID: binding.deviceID ?? "", via: "cookie" as const }
3019
+ // (hardening, rounds 4 and 5) Only the user's own words, from the hub's record of the utterance: the front's text
3020
+ // is never delivered (board text steering the front, or a restated or inverted request, never reaches the session).
3021
+ if (!binding.words.trim()) refuse("The hub has no words of the user's to relay")
3022
+ const previous = req.previous && binding.previous ? binding.previous : undefined
3023
+ // (hands-on) A question ABOUT a session is not a request FOR it: the front answers or asks the Concierge. For a
3024
+ // redirect ("no, the other one?") the earlier request is the one checked.
3025
+ const question = relayQuestionProblem(previous ?? binding.words)
3026
+ // (guard tuning) A question with a work verb is refused softly: the front may retry ONCE with confirm, for the same
3027
+ // utterance and the same session (after saying aloud what it sends). A hard refusal is never confirmable.
3028
+ let confirmed = false
3029
+ if (question && req.confirm) {
3030
+ if (binding.softTarget !== sessionID) refuse("confirm: true is only for one retry of the same request to the same session, right after the hub's question check offered it. Answer the user or pass it to the Concierge")
3031
+ binding.useConfirm()
3032
+ confirmed = true
3033
+ } else if (question) {
3034
+ if (question === STATUS_QUESTION_SOFT_REFUSAL) binding.markSoft(sessionID)
3035
+ refuse(question)
3036
+ }
3037
+ const { message } = relayMessage({ words: binding.words, ...(previous ? { previous } : {}) })
3038
+ const prompted = await engine.prompt({ actor, userID: owner, auditDevice: true }, sessionID, { text: message, why: confirmed ? RELAY_REASON_CONFIRMED : RELAY_REASON })
3039
+ const title = live.title?.trim() || "The session"
3040
+ const steered = "mode" in prompted && prompted.mode === "steer"
3041
+ registry.noteRelay(binding.mainID, sessionID, title, steered && "inboxID" in prompted ? prompted.inboxID : undefined)
3042
+ return { sessionID, title, mode: steered ? "steer" : "turn" }
3043
+ } catch (cause) {
3044
+ binding.release()
3045
+ await refused(cause)
3046
+ throw cause
3047
+ }
2617
3048
  }
2618
3049
 
2619
3050
  /** Enables or disables voice for a session, through a process that reported it (service first). */
@@ -2680,6 +3111,7 @@ export function startCentral(options: CentralOptions): Central {
2680
3111
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
2681
3112
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2682
3113
  if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
3114
+ if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
2683
3115
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2684
3116
  }
2685
3117
 
@@ -2699,6 +3131,7 @@ export function startCentral(options: CentralOptions): Central {
2699
3131
  )
2700
3132
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2701
3133
  if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
3134
+ if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
2702
3135
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2703
3136
  }
2704
3137
 
@@ -2892,6 +3325,16 @@ export function startCentral(options: CentralOptions): Central {
2892
3325
  return done
2893
3326
  }
2894
3327
 
3328
+ /** The consent page (the console's SPA) can never be framed, whatever HUB_CSP says. */
3329
+ async function consentPage(acceptEncoding: string | null): Promise<Response> {
3330
+ const page = await staticFile("/", acceptEncoding)
3331
+ const headers = new Headers(page.headers)
3332
+ headers.set("x-frame-options", "DENY")
3333
+ headers.set("cache-control", "no-store")
3334
+ if (!headers.has("content-security-policy")) headers.set("content-security-policy", "frame-ancestors 'none'")
3335
+ return new Response(page.body, { status: page.status, headers })
3336
+ }
3337
+
2895
3338
  async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
2896
3339
  const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
2897
3340
  const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
@@ -2939,11 +3382,13 @@ export function startCentral(options: CentralOptions): Central {
2939
3382
  launchFeed,
2940
3383
  launchTokens,
2941
3384
  webhooks,
3385
+ subscriptions,
2942
3386
  agentEvents,
2943
3387
  directory,
2944
3388
  concierge: conciergeService,
2945
3389
  agentEventWriters,
2946
3390
  intentRetention,
3391
+ contextCards: cards,
2947
3392
  recordings: recordingService,
2948
3393
  teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2949
3394
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
@@ -2955,6 +3400,8 @@ export function startCentral(options: CentralOptions): Central {
2955
3400
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
2956
3401
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
2957
3402
  launches.shutdown()
3403
+ // Bounded: a hanging store never holds the shutdown (HumanActivity.stop gives up after its timeout).
3404
+ await humanActivity.stop(HUMAN_ACTIVITY_STOP_MS)
2958
3405
  conciergeSupervisor.stop()
2959
3406
  conciergeContext.stop()
2960
3407
  conciergeParts.tiers.stop?.()
@@ -3097,8 +3544,17 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
3097
3544
  }
3098
3545
 
3099
3546
  const MODEL_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
3100
- function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worker?: ModelSelection | null } {
3101
- const invalid = () => new HttpError(400, "Expected Voice/Worker model selections")
3547
+ /** A spoke's model defaults without the obsolete Worker model (older spokes still report one). */
3548
+ function voiceOnly(defaults: SessionModelPrefs): SessionModelPrefs {
3549
+ return defaults.voice ? { voice: defaults.voice } : {}
3550
+ }
3551
+
3552
+ /**
3553
+ * The Voice model selection (the voice front's). `worker` (the removed voice agent's worker model, sent by older
3554
+ * consoles) is validated and dropped.
3555
+ */
3556
+ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null } {
3557
+ const invalid = () => new HttpError(400, "Expected a Voice model selection")
3102
3558
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
3103
3559
  const record = value as Record<string, unknown>
3104
3560
  if (Object.keys(record).some((key) => key !== "voice" && key !== "worker")) throw invalid()
@@ -3113,7 +3569,7 @@ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worke
3113
3569
  out[key] = { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" ? { variant: m.variant } : {}) }
3114
3570
  }
3115
3571
  if (!("voice" in out) && !("worker" in out)) throw invalid()
3116
- return out
3572
+ return "voice" in out ? { voice: out.voice! } : {}
3117
3573
  }
3118
3574
 
3119
3575
  function param(raw: string): string {
@@ -3140,6 +3596,11 @@ async function boundedBytes(request: Request, max: number, tooLarge: string): Pr
3140
3596
  throw new HttpError(413, tooLarge, undefined, read.close)
3141
3597
  }
3142
3598
 
3599
+ /** Passed through as the provider sends it, so the phone (or app) can start playing before synthesis ends. */
3600
+ function speakAudio(speech: Speech, text: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>> {
3601
+ return speech.speakStream ? speech.speakStream(text, signal) : speech.speak(text)
3602
+ }
3603
+
3143
3604
  /** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
3144
3605
  async function jsonBody(request: Request): Promise<unknown> {
3145
3606
  return JSON.parse(Buffer.from(await boundedBytes(request, CONSOLE_JSON_BODY_MAX, `Request body too large (at most ${CONSOLE_JSON_BODY_MAX} bytes)`)).toString("utf8"))