@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
@@ -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,7 +451,9 @@ 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. */
422
- const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
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 } })
456
+ const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert" | "setModel"; 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>) =>
425
459
  Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
@@ -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
@@ -1062,8 +1142,11 @@ export function startCentral(options: CentralOptions): Central {
1062
1142
  resolve: options.resolveHost ?? systemResolveHost,
1063
1143
  ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
1064
1144
  ...(config.webhooksPrivateHosts?.length ? { privateHosts: config.webhooksPrivateHosts } : {}),
1065
- 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,
1066
- appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
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,
1067
1150
  userAgent: `hub/${options.version ?? "0.0.0"}`,
1068
1151
  })
1069
1152
  const webhookLeader = leader("webhooks", {
@@ -1076,16 +1159,20 @@ export function startCentral(options: CentralOptions): Central {
1076
1159
  let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
1077
1160
  /** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
1078
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
1079
1164
  // CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
1080
1165
  // rejects, and the catch keeps it that way: an unhandled rejection would end the process).
1081
1166
  const conciergeActivity = new ActivityTracker({ state: backends.state, now })
1082
1167
  // CON-03: the board's last result per root session, from appends (best effort, as above).
1083
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
1084
1171
  const agentEvents = new AgentEvents({
1085
1172
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
1086
1173
  kv: backends.kv,
1087
1174
  now,
1088
- 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))),
1089
1176
  })
1090
1177
  const agentEventWriters = new EventWriters({
1091
1178
  events: agentEvents,
@@ -1145,6 +1232,58 @@ export function startCentral(options: CentralOptions): Central {
1145
1232
  })
1146
1233
  leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
1147
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}`)))
1148
1287
  for (const loop of leaders) void loop.start()
1149
1288
 
1150
1289
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
@@ -1179,7 +1318,7 @@ export function startCentral(options: CentralOptions): Central {
1179
1318
  registry.recordingTap = recordingService.registryTap
1180
1319
  registry.onSessionDeleted((sessionID, tenant) => void recordingService.sessionDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] recording session end failed: ${(cause as Error).message}`)))
1181
1320
  void leader("voice-recordings", { onElected: () => recordingService.retention.start(), onDeposed: () => recordingService.retention.stop() }).start()
1182
- 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) })
1183
1322
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1184
1323
  /** An agent as envelopes name it (a session by its directory name). */
1185
1324
  const describeActor = (userID: string, actor: AgentActor) => {
@@ -1221,6 +1360,8 @@ export function startCentral(options: CentralOptions): Central {
1221
1360
  now,
1222
1361
  describe: describeActor,
1223
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)),
1224
1365
  })
1225
1366
  registry.onPermissionClosed((requestID, _sessionID, tenant) => {
1226
1367
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
@@ -1289,6 +1430,26 @@ export function startCentral(options: CentralOptions): Central {
1289
1430
  })
1290
1431
  void coordinatorLeader.start() // created after the other leaders were started
1291
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
1292
1453
 
1293
1454
  // ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
1294
1455
  const conciergeSettings = new SettingsStore(backends.state, now)
@@ -1305,12 +1466,30 @@ export function startCentral(options: CentralOptions): Central {
1305
1466
  let conciergeActing: Acting | undefined
1306
1467
  const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
1307
1468
  conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
1308
- 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 }
1309
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
+ })
1310
1485
  const conciergeContext = new ContextPusher({
1311
1486
  registry,
1312
1487
  send: (connID, req) => spokes.request(connID, req),
1313
- 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
+ },
1314
1493
  notes: conciergeNotes,
1315
1494
  settings: conciergeSettings,
1316
1495
  grant: async (userID, grantID) => {
@@ -1321,6 +1500,8 @@ export function startCentral(options: CentralOptions): Central {
1321
1500
  return undefined
1322
1501
  }
1323
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"),
1324
1505
  // (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
1325
1506
  voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
1326
1507
  now,
@@ -1511,7 +1692,59 @@ export function startCentral(options: CentralOptions): Central {
1511
1692
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1512
1693
  })
1513
1694
  void conciergeLeader.start()
1514
- 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 } }
1515
1748
  const resolveActor = createResolver({
1516
1749
  cookieName: COOKIE,
1517
1750
  findDevice: (token) => deviceOfToken(token),
@@ -1586,6 +1819,12 @@ export function startCentral(options: CentralOptions): Central {
1586
1819
  }
1587
1820
  if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
1588
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"))
1589
1828
  if (method === "GET" && pathname === "/.well-known/hub") {
1590
1829
  return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
1591
1830
  }
@@ -1743,6 +1982,16 @@ export function startCentral(options: CentralOptions): Central {
1743
1982
  return Response.json({ prefs }, { headers: NO_STORE })
1744
1983
  }),
1745
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")),
1746
1995
  // A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
1747
1996
  route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
1748
1997
  const login = await auth.cliLogin(params.code!)
@@ -1784,8 +2033,7 @@ export function startCentral(options: CentralOptions): Central {
1784
2033
  const speech = speechFor(user.id)
1785
2034
  // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
1786
2035
  const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
1787
- // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1788
- const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
2036
+ const audio = await speakAudio(speech, text, request.signal)
1789
2037
  return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
1790
2038
  }),
1791
2039
  // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
@@ -1859,6 +2107,20 @@ export function startCentral(options: CentralOptions): Central {
1859
2107
  return Response.json(view, { headers: NO_STORE })
1860
2108
  }),
1861
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
+ }),
1862
2124
  route("GET", "/api/settings/media", "user", ({ user }) => Response.json(mediaSettingsView(user.id), { headers: NO_STORE })),
1863
2125
  route("PUT", "/api/settings/media", "user", async ({ request, user }) => {
1864
2126
  await store.setMediaAllowlist(user.id, mediaSettingsInput(await jsonBody(request)))
@@ -1890,19 +2152,13 @@ export function startCentral(options: CentralOptions): Central {
1890
2152
  return new Response(null, { status: 204 })
1891
2153
  }),
1892
2154
  route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session!)),
1893
- 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),
1894
2156
  route(
1895
2157
  "POST",
1896
2158
  "/api/sessions/:session/interrupt",
1897
2159
  "session",
1898
2160
  async ({ params }) => {
1899
- // (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
1900
- const front = registry.frontRoute(params.session!)
1901
- // A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
1902
- const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(params.session!), false)) : false
1903
- if (!viaFront) await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1904
- registry.cancelTurns(params.session!)
1905
- recordingTap(params.session!, "interrupt", {})
2161
+ await interruptSession(params.session!)
1906
2162
  return new Response(null, { status: 204 })
1907
2163
  },
1908
2164
  ),
@@ -1932,26 +2188,22 @@ export function startCentral(options: CentralOptions): Central {
1932
2188
  route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
1933
2189
  route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
1934
2190
  route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
1935
- 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),
1936
2192
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
1937
2193
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
1938
- route("GET", "/api/sessions/:session/model-prefs", "session", async ({ params, user }) => {
1939
- const prefs = store.modelPrefs(user.id, params.session!)
1940
- const defaults = await spokes.request(ownerOf(params.session!), {
1941
- op: "setModelPrefs",
1942
- sessionID: params.session!,
1943
- voice: prefs.voice ?? null,
1944
- worker: prefs.worker ?? null,
1945
- })
1946
- return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
1947
- }),
2194
+ route("GET", "/api/sessions/:session/model-prefs", "session", ({ params, user }) => getModelPrefs(params.session!, user.id)),
1948
2195
  route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
1949
2196
  const change = modelPrefsInput(await jsonBody(request))
1950
- await checkModelSelections(ownerOf(params.session!), params.session!, change)
2197
+ // Only an obsolete Worker model (an older console): nothing changes.
2198
+ if (!("voice" in change)) return getModelPrefs(params.session!, user.id)
2199
+ await checkModelSelections(ownerOf(params.session!), params.session!, [change.voice])
1951
2200
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
1952
2201
  const prefs = await store.setModelPrefs(user.id, params.session!, change)
1953
- return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
2202
+ return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
1954
2203
  }),
2204
+ // The session's own model (feature "setModel"): every later turn runs on it, whoever prompts. Same access as a
2205
+ // prompt (the session's owner); the Voice model above is separate.
2206
+ route("PUT", "/api/sessions/:session/model", "session", async ({ request, params, user }) => setSessionModel(request, params.session!, user.id)),
1955
2207
  route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
1956
2208
  const limitRaw = url.searchParams.get("limit")
1957
2209
  const limit = limitRaw === null ? undefined : Number(limitRaw)
@@ -2013,15 +2265,32 @@ export function startCentral(options: CentralOptions): Central {
2013
2265
  route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
2014
2266
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
2015
2267
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
2268
+ // Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
2269
+ route("GET", "/api/sessions/:session/context-cards", "session", async ({ params, user }) => Response.json(await cards.view(user.id, params.session!), { headers: NO_STORE })),
2270
+ route("POST", "/api/sessions/:session/context-cards/:provider/refresh", "session", async ({ params, user, request }) => {
2271
+ if (!cards.registry.get(params.provider!)) return error(404, "No such context card")
2272
+ // {force: true} regenerates even when nothing changed (still deduped and rate limited).
2273
+ const input = request.headers.get("content-type")?.includes("json") ? await body(request) : {}
2274
+ const outcome = await cards.refresh(user.id, params.session!, params.provider!, { force: input.force === true })
2275
+ if (outcome.status === "skipped" && outcome.reason === "rate_limited") {
2276
+ const seconds = Math.max(1, Math.ceil((outcome.retryAfterMs ?? 1000) / 1000))
2277
+ return Response.json({ outcome: "skipped", reason: "rate_limited", retryAfter: seconds }, { status: 429, headers: { ...NO_STORE, "retry-after": String(seconds) } })
2278
+ }
2279
+ 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 })
2280
+ }),
2016
2281
  route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
2017
2282
  const file = url.searchParams.get("file")
2018
2283
  if (!file || file.length > 4096) return error(400, "Expected ?file=<path>")
2019
2284
  const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
2020
2285
  return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
2021
2286
  }),
2022
- route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
2287
+ route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device, request }) => {
2023
2288
  const reads = await store.markRead(user.id, params.session!, Date.now())
2024
2289
  toUser(user.id, { type: "reads", reads }, device.id)
2290
+ // Only an explicit open (navigating to it, the page or pane shown again) touches the session, never the
2291
+ // console's automatic read marks of a visible pane (the touch also feeds the context cards' user-return).
2292
+ const input = await body(request).catch(() => ({}) as Record<string, unknown>)
2293
+ if (input.open === true) humanActivity.human(user.id, registry.rootOf(params.session!) ?? params.session!, "view")
2025
2294
  return Response.json({ reads }, { headers: NO_STORE })
2026
2295
  }),
2027
2296
 
@@ -2166,6 +2435,22 @@ export function startCentral(options: CentralOptions): Central {
2166
2435
  return new Response(null, { status: 204 })
2167
2436
  }
2168
2437
 
2438
+ /** A connect-flow handler's AgentError as the console's error envelope. */
2439
+ async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
2440
+ try {
2441
+ return await run()
2442
+ } catch (cause) {
2443
+ if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
2444
+ throw cause
2445
+ }
2446
+ }
2447
+
2448
+ async function connectDecision(request: Request, user: UserRecord, flow: string, decision: "approve" | "deny"): Promise<Response> {
2449
+ if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Approve or deny on the hub's consent page", "forbidden")
2450
+ const input = await body(request)
2451
+ return connectAnswer(async () => Response.json(await appConnect.decide(user, flow, decision, input.scopes), { headers: NO_STORE }))
2452
+ }
2453
+
2169
2454
  async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
2170
2455
  const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
2171
2456
  if (clientName === undefined) return error(404, "No pending CLI login with that code")
@@ -2386,6 +2671,27 @@ export function startCentral(options: CentralOptions): Central {
2386
2671
  })
2387
2672
  }
2388
2673
 
2674
+ /**
2675
+ * PUT /api/sessions/:id/model {model}: 400 for a malformed body or a model (variant) the session's catalog lacks,
2676
+ * 409 unsupported when its spoke did not announce setModel (an older plugin: the console's chip stays display only).
2677
+ */
2678
+ async function setSessionModel(request: Request, sessionID: string, userID: string): Promise<Response> {
2679
+ const model = sessionModelInput(await jsonBody(request))
2680
+ const connID = ownerOf(sessionID)
2681
+ if (!registry.supports(connID, "setModel")) throw unsupportedError({ feature: "setModel", harness: registry.harnessOf(connID) ?? "unknown" })
2682
+ await checkModelSelections(connID, sessionID, [model])
2683
+ const result = await spokes.request(connID, { op: "setModel", sessionID, model })
2684
+ await store.audit({ actor: userID, action: "session.model", target: sessionID, detail: `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ""}` })
2685
+ return Response.json({ model: result.model }, { headers: NO_STORE })
2686
+ }
2687
+
2688
+ /** A session's Voice model preference, re-sent to its spoke (which answers with its defaults). */
2689
+ async function getModelPrefs(sessionID: string, userID: string): Promise<Response> {
2690
+ const prefs = store.modelPrefs(userID, sessionID)
2691
+ const reply = await spokes.request(ownerOf(sessionID), { op: "setModelPrefs", sessionID, voice: prefs.voice ?? null })
2692
+ return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
2693
+ }
2694
+
2389
2695
  /**
2390
2696
  * GET …/timeline/messages/:message/media/:ref (S4 M3): 400 for a malformed ref or message ID, 409 offline, 422
2391
2697
  * unsupported {feature, harness} when the owning spoke did not announce timelineMedia; else ./media-timeline.ts.
@@ -2417,8 +2723,8 @@ export function startCentral(options: CentralOptions): Central {
2417
2723
  * Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
2418
2724
  * the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
2419
2725
  */
2420
- async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<void> {
2421
- const selections = [change.voice, change.worker].filter((m): m is ModelSelection => !!m)
2726
+ async function checkModelSelections(connID: string, sessionID: string, picked: ReadonlyArray<ModelSelection | null | undefined>): Promise<void> {
2727
+ const selections = picked.filter((m): m is ModelSelection => !!m)
2422
2728
  if (selections.length === 0) return
2423
2729
  let models: readonly AvailableModel[]
2424
2730
  try {
@@ -2514,19 +2820,55 @@ export function startCentral(options: CentralOptions): Central {
2514
2820
  if (tenant !== undefined) recordingService.hub(tenant, sessionID, kind, fields)
2515
2821
  }
2516
2822
 
2823
+ /** Barge-in; `taps` false (AF3, an app): REC-01's recording taps are left out. */
2824
+ async function interruptSession(sessionID: string, taps = true): Promise<void> {
2825
+ // (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
2826
+ const front = registry.frontRoute(sessionID)
2827
+ // A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
2828
+ const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(sessionID), false)) : false
2829
+ if (!viaFront) await spokes.request(ownerOf(sessionID, "interrupt"), { op: "interrupt", sessionID })
2830
+ registry.cancelTurns(sessionID)
2831
+ if (taps) recordingTap(sessionID, "interrupt", {})
2832
+ }
2833
+
2517
2834
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
2518
- async function utterance(request: Request, sessionID: string): Promise<Response> {
2835
+ async function utterance(request: Request, sessionID: string, deviceID?: string): Promise<Response> {
2519
2836
  const raw = (await body(request)).text
2520
2837
  const text = typeof raw === "string" ? raw.trim() : ""
2521
2838
  if (!text) return error(400, "Empty utterance")
2522
2839
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
2840
+ return Response.json({ inboxID: await sendUtterance(sessionID, text, undefined, deviceID) }, { status: 202 })
2841
+ }
2842
+
2843
+ /**
2844
+ * An utterance to the session, or to its voice front while one is active (it relays it); tracked for `heard`.
2845
+ * Without `prompt`: the console's steer (the user's own voice: a human turn, recorded by REC-01's taps). (AF3) With
2846
+ * `prompt`: an app's, delivered as an agent prompt with its hub-prompt provenance (from app:<id>, the grant),
2847
+ * steered into the running turn; no human turn, nothing recorded (REC-01 is off for apps).
2848
+ */
2849
+ async function sendUtterance(sessionID: string, text: string, prompt?: { readonly from: string; readonly to: string; readonly grant: string }, deviceID?: string): Promise<string> {
2850
+ const send = async (connID: string, target: string): Promise<string> => {
2851
+ if (!prompt) return (await spokes.request(connID, { op: "steer", sessionID: target, text })).inboxID
2852
+ for (const feature of ["deliver", "hubPrompt"] as const) {
2853
+ if (!registry.supports(connID, feature)) throw unsupportedAgentError(feature, registry.harnessOf(connID) ?? "unknown")
2854
+ }
2855
+ const messageID = newMessageID()
2856
+ 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" } })
2857
+ if (!isDeliverResult(result)) throw new Error("The spoke answered a deliver malformed")
2858
+ return result.inboxID ?? messageID
2859
+ }
2523
2860
  // (voice front P1) While a front is active, utterances go to the front; it relays them to the session.
2524
2861
  const front = registry.frontRoute(sessionID)
2525
- await humanTurn(sessionID)
2862
+ if (!prompt) {
2863
+ await humanTurn(sessionID)
2864
+ // The steer comes back from the spoke marked carried: it is recorded here once, as Voice.
2865
+ const speaker = registry.sessionTenant(sessionID)
2866
+ if (speaker !== undefined) humanActivity.human(speaker, registry.rootOf(sessionID) ?? sessionID, "voice")
2867
+ }
2526
2868
  // A front that cannot take it failed: the session talks directly (front_failed), never a 502.
2527
2869
  let inboxID = front
2528
- ? await spokes.request(front.connID, { op: "steer", sessionID: front.sessionID, text }).then(
2529
- (result) => result.inboxID,
2870
+ ? await send(front.connID, front.sessionID).then(
2871
+ (id) => id,
2530
2872
  (cause) => {
2531
2873
  console.warn(`[hub] voice front of ${sessionID} did not take the utterance: ${(cause as Error).message}`)
2532
2874
  registry.frontLost(sessionID)
@@ -2534,10 +2876,13 @@ export function startCentral(options: CentralOptions): Central {
2534
2876
  },
2535
2877
  )
2536
2878
  : undefined
2537
- if (inboxID === undefined) inboxID = (await spokes.request(ownerOf(sessionID, "steer"), { op: "steer", sessionID, text })).inboxID
2538
- recordingTap(sessionID, "utterance.received", { inboxID, text })
2879
+ // (P2) What the user said to the front: the only thing its relay_to_session may answer. Never an app's text
2880
+ // (AF3): an app's words never become the user's own for a relay.
2881
+ if (inboxID !== undefined && !prompt) registry.frontUtterance(sessionID, inboxID, deviceID, text)
2882
+ if (inboxID === undefined) inboxID = await send(prompt ? ownerOf(sessionID) : ownerOf(sessionID, "steer"), sessionID)
2883
+ if (!prompt) recordingTap(sessionID, "utterance.received", { inboxID, text })
2539
2884
  registry.trackUtterance(sessionID, inboxID)
2540
- return Response.json({ inboxID }, { status: 202 })
2885
+ return inboxID
2541
2886
  }
2542
2887
 
2543
2888
  /** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
@@ -2588,12 +2933,27 @@ export function startCentral(options: CentralOptions): Central {
2588
2933
  throw failure
2589
2934
  }
2590
2935
 
2591
- async function conversation(request: Request, sessionID: string): Promise<Response> {
2936
+ async function conversation(request: Request, sessionID: string, userID: string): Promise<Response> {
2592
2937
  const input = await body(request)
2593
2938
  const on = input.on
2594
2939
  if (typeof on !== "boolean" || (input.front !== undefined && typeof input.front !== "boolean")) return error(400, "Expected {on: boolean, front?: boolean}")
2595
- // (voice front P1) `front`: put a hidden voice front in front of the session, when its spoke serves it.
2596
- const wantsFront = on && input.front === true
2940
+ return Response.json(await setConversation(sessionID, on, input.front, userID))
2941
+ }
2942
+
2943
+ /**
2944
+ * Conversation mode on/off. Console (`userID` given): Voice mode always asks for the voice front (when the
2945
+ * session's spoke serves it), unless the device opted out with `front: false`; a console that sends no `front`
2946
+ * (older ones, the TUI's /voice link) gets the default. (AF3) An app (`userID` undefined, `taps` false): the front
2947
+ * only when asked for (`front: true`), never the Concierge's front (its relay_to_session acts as the user), and
2948
+ * REC-01's recording taps are left out (off for apps).
2949
+ */
2950
+ async function setConversation(sessionID: string, on: boolean, frontInput: boolean | undefined, userID?: string, taps = true): Promise<{ on: boolean; agent?: string; front?: boolean }> {
2951
+ const app = userID === undefined
2952
+ const asked = app ? frontInput === true : frontInput !== false
2953
+ const owner = userID ?? registry.sessionTenant(sessionID)
2954
+ // (P2) The Concierge's front only from a spoke that serves it ("conciergeFront"); else today's path, no front.
2955
+ const concierge = on && asked && owner !== undefined && (await conciergeTimedIdentity.current(owner).then((c) => c.sessionID === sessionID, () => false))
2956
+ const wantsFront = on && asked && (!concierge || (!app && registry.supports(ownerOf(sessionID), "conciergeFront")))
2597
2957
  const hadFront = registry.wantsFront(sessionID)
2598
2958
  // Turning off always clears the hub's state, so a later reconnect does not switch it back on.
2599
2959
  if (!on) registry.setConversation(sessionID, false)
@@ -2613,8 +2973,95 @@ export function startCentral(options: CentralOptions): Central {
2613
2973
  if (sendFront) registry.frontOpEnded(connID)
2614
2974
  }
2615
2975
  const front = registry.frontOf(sessionID) !== undefined
2616
- if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
2617
- return Response.json({ on, agent: result.agent, ...(wantsFront || (!on && hadFront) ? { front } : {}) })
2976
+ if (taps && on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
2977
+ return { on, ...(typeof result.agent === "string" ? { agent: result.agent } : {}), ...(wantsFront || (!on && hadFront) ? { front } : {}) }
2978
+ }
2979
+
2980
+ /**
2981
+ * (voice front P2) A Concierge front's relay_to_session (spoke request relayToSession from `connID`, a machine of
2982
+ * `owner`): enforced here, not only by the front's prompt. The front must be the active front of the owner's
2983
+ * current Concierge, linked by `connID`, answering the user's latest utterance to it (RELAYS_PER_UTTERANCE at
2984
+ * most); the target one of the owner's own, live, visible root sessions (no front, no Concierge session). It runs
2985
+ * as the user's own prompt (no grant, no Decision): steered into a busy session, a turn in an idle one; text only,
2986
+ * so the target's own permissions apply. Audited as the user's prompt with RELAY_REASON. A refusal throws (the
2987
+ * spoke's ok:false, read back to the front).
2988
+ */
2989
+ 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"]> {
2990
+ const refuse = (message: string): never => {
2991
+ throw new RelayRefused(message)
2992
+ }
2993
+ // (hardening) Every refusal of the hub's checks is in the audit (Activity shows attempts), as the user's refused
2994
+ // prompt with the reason, the target when it was resolved and the device when the utterance is known.
2995
+ let audited: { sessionID?: string; deviceID?: string } = {}
2996
+ const refused = async (cause: unknown) => {
2997
+ if (!(cause instanceof RelayRefused)) return
2998
+ await audit
2999
+ .record(owner, {
3000
+ actor: { kind: "user" },
3001
+ verb: "prompt",
3002
+ target: audited.sessionID ? { sessionID: audited.sessionID } : {},
3003
+ decision: /at most \d+/.test(cause.message) ? "rate_limited" : "forbidden",
3004
+ reason: `Relay by voice refused: ${cause.message}`,
3005
+ outcome: "refused",
3006
+ ...(audited.deviceID ? { deviceID: audited.deviceID } : {}),
3007
+ })
3008
+ .catch((e: unknown) => console.error(`[hub] relay audit failed: ${(e as Error).message}`))
3009
+ }
3010
+ let binding: ReturnType<typeof registry.takeRelay>
3011
+ try {
3012
+ binding = registry.takeRelay(connID, req.frontID)
3013
+ } catch (cause) {
3014
+ if (cause instanceof RelayRefused && cause.deviceID) audited = { deviceID: cause.deviceID }
3015
+ await refused(cause)
3016
+ throw cause
3017
+ }
3018
+ audited = binding.deviceID ? { deviceID: binding.deviceID } : {}
3019
+ try {
3020
+ if (registry.sessionTenant(binding.mainID) !== owner) refuse("Not an active voice front")
3021
+ const identity = await conciergeTimedIdentity.current(owner)
3022
+ if (identity.sessionID !== binding.mainID) refuse("Only the Concierge's voice front relays to other sessions")
3023
+ let sessionID = ""
3024
+ try {
3025
+ sessionID = directory.resolveOne(owner, req.session).sessionID
3026
+ } catch (cause) {
3027
+ refuse(cause instanceof Error ? cause.message : `No session ${req.session}`)
3028
+ }
3029
+ if (registry.sessionTenant(sessionID) !== owner) refuse(`${req.session} is not one of yours`)
3030
+ audited = { ...audited, sessionID }
3031
+ 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")
3032
+ const live = registry.session(sessionID) ?? refuse(`${req.session} is offline`)
3033
+ if ((registry.rootOf(sessionID) ?? sessionID) !== sessionID) refuse("Relays go to root sessions, not to subagent sessions")
3034
+ const engine = conciergeActing ?? refuse("This hub cannot relay right now")
3035
+ const actor = { kind: "user" as const, userID: owner, deviceID: binding.deviceID ?? "", via: "cookie" as const }
3036
+ // (hardening, rounds 4 and 5) Only the user's own words, from the hub's record of the utterance: the front's text
3037
+ // is never delivered (board text steering the front, or a restated or inverted request, never reaches the session).
3038
+ if (!binding.words.trim()) refuse("The hub has no words of the user's to relay")
3039
+ const previous = req.previous && binding.previous ? binding.previous : undefined
3040
+ // (hands-on) A question ABOUT a session is not a request FOR it: the front answers or asks the Concierge. For a
3041
+ // redirect ("no, the other one?") the earlier request is the one checked.
3042
+ const question = relayQuestionProblem(previous ?? binding.words)
3043
+ // (guard tuning) A question with a work verb is refused softly: the front may retry ONCE with confirm, for the same
3044
+ // utterance and the same session (after saying aloud what it sends). A hard refusal is never confirmable.
3045
+ let confirmed = false
3046
+ if (question && req.confirm) {
3047
+ 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")
3048
+ binding.useConfirm()
3049
+ confirmed = true
3050
+ } else if (question) {
3051
+ if (question === STATUS_QUESTION_SOFT_REFUSAL) binding.markSoft(sessionID)
3052
+ refuse(question)
3053
+ }
3054
+ const { message } = relayMessage({ words: binding.words, ...(previous ? { previous } : {}) })
3055
+ const prompted = await engine.prompt({ actor, userID: owner, auditDevice: true }, sessionID, { text: message, why: confirmed ? RELAY_REASON_CONFIRMED : RELAY_REASON })
3056
+ const title = live.title?.trim() || "The session"
3057
+ const steered = "mode" in prompted && prompted.mode === "steer"
3058
+ registry.noteRelay(binding.mainID, sessionID, title, steered && "inboxID" in prompted ? prompted.inboxID : undefined)
3059
+ return { sessionID, title, mode: steered ? "steer" : "turn" }
3060
+ } catch (cause) {
3061
+ binding.release()
3062
+ await refused(cause)
3063
+ throw cause
3064
+ }
2618
3065
  }
2619
3066
 
2620
3067
  /** Enables or disables voice for a session, through a process that reported it (service first). */
@@ -2681,6 +3128,7 @@ export function startCentral(options: CentralOptions): Central {
2681
3128
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
2682
3129
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2683
3130
  if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
3131
+ if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
2684
3132
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2685
3133
  }
2686
3134
 
@@ -2700,6 +3148,7 @@ export function startCentral(options: CentralOptions): Central {
2700
3148
  )
2701
3149
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2702
3150
  if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
3151
+ if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
2703
3152
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2704
3153
  }
2705
3154
 
@@ -2893,6 +3342,16 @@ export function startCentral(options: CentralOptions): Central {
2893
3342
  return done
2894
3343
  }
2895
3344
 
3345
+ /** The consent page (the console's SPA) can never be framed, whatever HUB_CSP says. */
3346
+ async function consentPage(acceptEncoding: string | null): Promise<Response> {
3347
+ const page = await staticFile("/", acceptEncoding)
3348
+ const headers = new Headers(page.headers)
3349
+ headers.set("x-frame-options", "DENY")
3350
+ headers.set("cache-control", "no-store")
3351
+ if (!headers.has("content-security-policy")) headers.set("content-security-policy", "frame-ancestors 'none'")
3352
+ return new Response(page.body, { status: page.status, headers })
3353
+ }
3354
+
2896
3355
  async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
2897
3356
  const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
2898
3357
  const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
@@ -2940,11 +3399,13 @@ export function startCentral(options: CentralOptions): Central {
2940
3399
  launchFeed,
2941
3400
  launchTokens,
2942
3401
  webhooks,
3402
+ subscriptions,
2943
3403
  agentEvents,
2944
3404
  directory,
2945
3405
  concierge: conciergeService,
2946
3406
  agentEventWriters,
2947
3407
  intentRetention,
3408
+ contextCards: cards,
2948
3409
  recordings: recordingService,
2949
3410
  teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2950
3411
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
@@ -2956,6 +3417,8 @@ export function startCentral(options: CentralOptions): Central {
2956
3417
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
2957
3418
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
2958
3419
  launches.shutdown()
3420
+ // Bounded: a hanging store never holds the shutdown (HumanActivity.stop gives up after its timeout).
3421
+ await humanActivity.stop(HUMAN_ACTIVITY_STOP_MS)
2959
3422
  conciergeSupervisor.stop()
2960
3423
  conciergeContext.stop()
2961
3424
  conciergeParts.tiers.stop?.()
@@ -3098,8 +3561,17 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
3098
3561
  }
3099
3562
 
3100
3563
  const MODEL_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
3101
- function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worker?: ModelSelection | null } {
3102
- const invalid = () => new HttpError(400, "Expected Voice/Worker model selections")
3564
+ /** A spoke's model defaults without the obsolete Worker model (older spokes still report one). */
3565
+ function voiceOnly(defaults: SessionModelPrefs): SessionModelPrefs {
3566
+ return defaults.voice ? { voice: defaults.voice } : {}
3567
+ }
3568
+
3569
+ /**
3570
+ * The Voice model selection (the voice front's). `worker` (the removed voice agent's worker model, sent by older
3571
+ * consoles) is validated and dropped.
3572
+ */
3573
+ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null } {
3574
+ const invalid = () => new HttpError(400, "Expected a Voice model selection")
3103
3575
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
3104
3576
  const record = value as Record<string, unknown>
3105
3577
  if (Object.keys(record).some((key) => key !== "voice" && key !== "worker")) throw invalid()
@@ -3114,7 +3586,19 @@ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worke
3114
3586
  out[key] = { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" ? { variant: m.variant } : {}) }
3115
3587
  }
3116
3588
  if (!("voice" in out) && !("worker" in out)) throw invalid()
3117
- return out
3589
+ return "voice" in out ? { voice: out.voice! } : {}
3590
+ }
3591
+
3592
+ /** The body of PUT /api/sessions/:id/model: {model: {providerID, modelID, variant?}}; other keys inside `model` are dropped. */
3593
+ function sessionModelInput(value: unknown): ModelSelection {
3594
+ const invalid = () => new HttpError(400, "Expected {model: {providerID, modelID, variant?}}")
3595
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
3596
+ const record = value as Record<string, unknown>
3597
+ if (Object.keys(record).some((key) => key !== "model")) throw invalid()
3598
+ const m = record.model as Record<string, unknown> | null | undefined
3599
+ if (typeof m !== "object" || m === null || Array.isArray(m)) throw invalid()
3600
+ if (typeof m.providerID !== "string" || !MODEL_ID.test(m.providerID) || typeof m.modelID !== "string" || !MODEL_ID.test(m.modelID) || (m.variant !== undefined && (typeof m.variant !== "string" || !MODEL_ID.test(m.variant)))) throw invalid()
3601
+ return { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" && m.variant !== "default" ? { variant: m.variant } : {}) }
3118
3602
  }
3119
3603
 
3120
3604
  function param(raw: string): string {
@@ -3141,6 +3625,11 @@ async function boundedBytes(request: Request, max: number, tooLarge: string): Pr
3141
3625
  throw new HttpError(413, tooLarge, undefined, read.close)
3142
3626
  }
3143
3627
 
3628
+ /** Passed through as the provider sends it, so the phone (or app) can start playing before synthesis ends. */
3629
+ function speakAudio(speech: Speech, text: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>> {
3630
+ return speech.speakStream ? speech.speakStream(text, signal) : speech.speak(text)
3631
+ }
3632
+
3144
3633
  /** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
3145
3634
  async function jsonBody(request: Request): Promise<unknown> {
3146
3635
  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"))