@huynle/hub 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -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, 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 } from "../protocol"
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"
12
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
13
13
  import { speakable } from "../speakable"
14
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -16,12 +16,17 @@ import { emailAllowed, type CentralConfig } from "./config"
16
16
  import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
17
17
  import { isNotificationKind, Push, type PushSender } from "./push"
18
18
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
19
+ import { createFetcher, parseDenyCidrs, type Fetcher } from "./fetcher"
20
+ import { createRemoteMedia } from "./media-remote"
21
+ import { createUnfurl } from "./media-unfurl"
22
+ import { createFetchBudget } from "./fetch-budget"
23
+ import { createTimelineMedia } from "./media-timeline"
19
24
  import { Webhooks } from "./agent/webhooks"
20
25
  import { issueAppToken } from "./agent/app-tokens"
21
26
  import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
22
27
  import { EventWriters } from "./agent/event-writers"
23
28
  import { MemoryEventLog } from "./backend/memory-events"
24
- import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
29
+ import { WEBHOOK_APP_SCOPES } from "./agent/webhook-routes"
25
30
  import { hubBackends, type Backends } from "./backend"
26
31
  import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
27
32
  import { ActingSwitch } from "./agent/acting-switch"
@@ -45,7 +50,7 @@ import { SessionTokens } from "./agent/session-tokens"
45
50
  import { SessionTokenPush } from "./session-token-push"
46
51
  import { TokenKeys } from "./token-keys"
47
52
  import { EventLog } from "./events-log"
48
- import { BusyError, Registry } from "./registry"
53
+ import { BusyError, Registry, type Timers } from "./registry"
49
54
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
50
55
  import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
51
56
  import { audioFormat, SpeechError } from "./speech/provider"
@@ -78,6 +83,8 @@ import { recordingRoutes } from "./recording/routes"
78
83
  import { wrapConciergeSend } from "./recording/concierge-taps"
79
84
  import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
80
85
  import { ArtifactStore } from "./artifacts/store"
86
+ import { messageTexts, pickHtmlBlock, PREVIEW_LIMITS } from "./artifacts/previews"
87
+ import { BundleProtocolError, fetchPreviewBundle } from "./artifacts/preview-bundle"
81
88
  import { artifactHost, isArtifactHostPath } from "./artifacts/host"
82
89
  import { CONSOLE_CSP, cspHeader } from "./csp"
83
90
  import { ArtifactHostCaps } from "./artifacts/host-caps"
@@ -120,6 +127,10 @@ export interface Speech {
120
127
 
121
128
  export interface CentralOptions {
122
129
  readonly config: CentralConfig
130
+ /** HTML preview retention (default: archived 24 h after the last view, purged 7 days later). */
131
+ readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
132
+ /** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
133
+ readonly registryTimers?: Timers
123
134
  /** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
124
135
  readonly webDir?: string
125
136
  readonly web?: WebAssets
@@ -134,6 +145,8 @@ export interface CentralOptions {
134
145
  readonly pushSender?: PushSender
135
146
  /** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
136
147
  readonly resolveHost?: ResolveHost
148
+ /** The outbound fetcher of /api/media/remote and /api/media/unfurl (tests); default: resolveHost, the built-in deny list and HUB_FETCH_DENY_CIDRS. */
149
+ readonly remoteFetcher?: Fetcher
137
150
  /** Clock for sign-in expiry, the store, the registry and the backends (tests). */
138
151
  readonly now?: () => number
139
152
  /** Shared state for new code (default: in memory, see ./backend). */
@@ -146,6 +159,8 @@ export interface CentralOptions {
146
159
  readonly turnGraceMs?: number
147
160
  /** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
148
161
  readonly turnAcceptMs?: number
162
+ /** (S4 M3) The longest one timeline image may take from the spoke, all chunks together (default 60 s). */
163
+ readonly mediaDeadlineMs?: number
149
164
  readonly heartbeatMs?: number
150
165
  readonly staleMs?: number
151
166
  /** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
@@ -162,6 +177,9 @@ export interface CentralOptions {
162
177
  /** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
163
178
  readonly drainTimeoutMs?: number
164
179
  readonly drainRetryAfterMs?: number
180
+ /** Launches: the end watcher's settle and the engine's tick (see END_SETTLE_MS, END_TICK_MS; test fixtures). */
181
+ readonly launchSettleMs?: number
182
+ readonly launchTickMs?: number
165
183
  }
166
184
  }
167
185
 
@@ -288,6 +306,13 @@ interface Route extends RouteInfo {
288
306
 
289
307
  const NO_STORE = { "cache-control": "no-store" }
290
308
 
309
+ /** Who asks for a timeline image, and how (If-None-Match, ?download=1). */
310
+ interface MediaCaller {
311
+ readonly request: Request
312
+ readonly url: URL
313
+ readonly userID: string
314
+ }
315
+
291
316
  export const COOKIE = "voice_device"
292
317
  const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
293
318
  const SSE_HEARTBEAT_MS = 20_000
@@ -309,6 +334,8 @@ export const TURN_ACCEPT_MS = 1500
309
334
  const NO_OWNER = "No OpenCode process for this session is connected"
310
335
  /** A message ID in a fork or revert request (as the spoke protocol bounds it). */
311
336
  const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
337
+ /** A message ID in a media URL: the same characters without `/`, and never `..` (it names a message, never a path). */
338
+ const MEDIA_MESSAGE_ID = /^(?!.*\.\.)[A-Za-z0-9_.:@+~-]{1,300}$/
312
339
  const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
313
340
  const NOT_EXPOSED = "Voice is not enabled for this session"
314
341
  /** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
@@ -411,7 +438,7 @@ export function startCentral(options: CentralOptions): Central {
411
438
  ? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
412
439
  : Store.open(config.stateDir, now, { mode: config.auth.mode })
413
440
  // A machine's tenant is its owner (read live: ownership moves on migration).
414
- const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
441
+ const registry = new Registry(now, options.registryTimers, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
415
442
  const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
416
443
  const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
417
444
  // CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
@@ -558,6 +585,7 @@ export function startCentral(options: CentralOptions): Central {
558
585
  // options.fetch is for speech providers only; sign-in always talks to the real issuer.
559
586
  const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
560
587
  /** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
588
+ const mediaSettingsView = (userID: string) => ({ allowlist: store.mediaAllowlist(userID), remoteFetch: config.remoteFetch !== false })
561
589
  const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
562
590
  const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
563
591
  const tokenKeys = TokenKeys.open(config.stateDir)
@@ -631,6 +659,12 @@ export function startCentral(options: CentralOptions): Central {
631
659
  // Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
632
660
  // goes to the tenant's streams on every pod.
633
661
  registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
662
+ // (voice front P1) A front that failed is recorded with the session's recordings (the fallback is visible there).
663
+ registry.subscribe((message, tenant) => {
664
+ if (message.type === "conversation" && message.reason === "front_failed" && tenant !== undefined) {
665
+ recordings?.hub(tenant, message.sessionID, "conversation", { on: true, front: false, reason: "front_failed" })
666
+ }
667
+ })
634
668
  registry.subscribeAlerts((alert) => {
635
669
  // Push goes to the session owner's devices only; an alert without an owner goes nowhere.
636
670
  if (!alert.owner) return
@@ -892,6 +926,24 @@ export function startCentral(options: CentralOptions): Central {
892
926
  }
893
927
  })
894
928
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
929
+ // (S4 M3) Timeline images, cached per user on this pod; a deleted session or a committed revert forgets them.
930
+ const timelineMedia = createTimelineMedia({
931
+ request: (connID, req) => spokes.request(connID, req),
932
+ now,
933
+ ...(options.timing?.mediaDeadlineMs !== undefined ? { deadlineMs: options.timing.mediaDeadlineMs } : {}),
934
+ })
935
+ const mediaRevertCommits = new Map<string, number>()
936
+ registry.onSessions((connID) => {
937
+ for (const { sessionID, committed } of registry.revertCommits(connID)) {
938
+ if (mediaRevertCommits.get(sessionID) === committed.at) continue
939
+ mediaRevertCommits.set(sessionID, committed.at)
940
+ timelineMedia.forgetSession(sessionID)
941
+ }
942
+ })
943
+ registry.onSessionDeleted((sessionID) => {
944
+ mediaRevertCommits.delete(sessionID)
945
+ timelineMedia.forgetSession(sessionID)
946
+ })
895
947
  // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
896
948
  const intents = new SessionIntents({
897
949
  state: backends.state,
@@ -951,6 +1003,8 @@ export function startCentral(options: CentralOptions): Central {
951
1003
  if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
952
1004
  },
953
1005
  intents,
1006
+ ...(options.timing?.launchSettleMs !== undefined ? { settleMs: options.timing.launchSettleMs } : {}),
1007
+ ...(options.timing?.launchTickMs !== undefined ? { tickMs: options.timing.launchTickMs } : {}),
954
1008
  // FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
955
1009
  launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
956
1010
  profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
@@ -1007,7 +1061,7 @@ export function startCentral(options: CentralOptions): Central {
1007
1061
  users: () => store.users().map((u) => u.id),
1008
1062
  resolve: options.resolveHost ?? systemResolveHost,
1009
1063
  ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
1010
- appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
1064
+ appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && WEBHOOK_APP_SCOPES.some((scope) => t.scopes!.includes(scope))) === true,
1011
1065
  appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
1012
1066
  userAgent: `hub/${options.version ?? "0.0.0"}`,
1013
1067
  })
@@ -1052,6 +1106,8 @@ export function startCentral(options: CentralOptions): Central {
1052
1106
  blobs: backends.blobs,
1053
1107
  now,
1054
1108
  workspaces: artifactWorkspacesFromState(backends.state),
1109
+ ...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
1110
+ ...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
1055
1111
  onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
1056
1112
  })
1057
1113
  : undefined
@@ -1183,8 +1239,30 @@ export function startCentral(options: CentralOptions): Central {
1183
1239
  const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1184
1240
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1185
1241
  launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1186
- // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
1187
- teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text, options = {}) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, options.hop === undefined ? {} : { chainHop: options.hop })).message.id }), hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }) })
1242
+ // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
1243
+ // message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
1244
+ teamIntake = new TeamIntakeService({
1245
+ state: backends.state,
1246
+ teams: teamStore,
1247
+ seats: teamSeats,
1248
+ staffing: teamStaffing,
1249
+ members: workspaceMembers,
1250
+ presence: teamPresence,
1251
+ events: agentEvents,
1252
+ now,
1253
+ users: allUsers,
1254
+ deliver: async (userID, sessionID, text, options = {}) => ({
1255
+ messageID: (
1256
+ await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, {
1257
+ ...(options.hop === undefined ? {} : { chainHop: options.hop }),
1258
+ ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
1259
+ })
1260
+ ).message.id,
1261
+ }),
1262
+ hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }),
1263
+ })
1264
+ const intakeService = teamIntake
1265
+ void leader("team-intake", { onElected: () => intakeService.start(), onDeposed: () => intakeService.stop() }).start() // created after the other leaders were started
1188
1266
  // Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
1189
1267
  // supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
1190
1268
  const workspaceCursors = journalCursors(tokenKeys, agentEvents)
@@ -1432,7 +1510,7 @@ export function startCentral(options: CentralOptions): Central {
1432
1510
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1433
1511
  })
1434
1512
  void conciergeLeader.start()
1435
- 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, 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 } }
1513
+ const agentDeps = { recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1436
1514
  const resolveActor = createResolver({
1437
1515
  cookieName: COOKIE,
1438
1516
  findDevice: (token) => deviceOfToken(token),
@@ -1586,10 +1664,16 @@ export function startCentral(options: CentralOptions): Central {
1586
1664
  store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
1587
1665
 
1588
1666
  /** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
1589
- const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
1667
+ const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages", "/api/sessions/:session/previews"])
1590
1668
  /** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
1591
1669
  const knownOffline = (userID: string, sessionID: string) =>
1592
1670
  registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
1671
+ // (RF1, RF3) Web images and link previews for the console through the safe fetcher (design §4): signed-in users
1672
+ // only, one fetcher and ONE per-user budget (30 a minute, 4 at once) for both routes together.
1673
+ const remoteFetcher = options.remoteFetcher ?? createFetcher({ resolve: options.resolveHost ?? systemResolveHost, denyCidrs: parseDenyCidrs((config.fetchDenyCidrs ?? []).join(",")) })
1674
+ const fetchBudget = createFetchBudget({ now })
1675
+ const remoteMedia = createRemoteMedia({ fetcher: remoteFetcher, enabled: config.remoteFetch !== false, now, audit: (entry) => store.audit(entry), budget: fetchBudget })
1676
+ const unfurl = createUnfurl({ fetcher: remoteFetcher, enabled: config.remoteFetch !== false, now, audit: (entry) => store.audit(entry), budget: fetchBudget })
1593
1677
  const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
1594
1678
  const keys: string[] = []
1595
1679
  const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
@@ -1628,6 +1712,8 @@ export function startCentral(options: CentralOptions): Central {
1628
1712
  }),
1629
1713
  ),
1630
1714
  route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
1715
+ route("GET", "/api/media/remote", "user", ({ user, request, url }) => remoteMedia.handle(user.id, request, url)),
1716
+ route("GET", "/api/media/unfurl", "user", ({ user, request, url }) => unfurl.handle(user.id, request, url)),
1631
1717
  route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
1632
1718
  const shown = streamSessionsQuery(url)
1633
1719
  if ("problem" in shown) return error(400, shown.problem)
@@ -1771,6 +1857,12 @@ export function startCentral(options: CentralOptions): Central {
1771
1857
  conciergeTouch(user.id)
1772
1858
  return Response.json(view, { headers: NO_STORE })
1773
1859
  }),
1860
+ // (RF2) The user's media allowlist (Settings → Media) and whether this hub fetches web images at all.
1861
+ route("GET", "/api/settings/media", "user", ({ user }) => Response.json(mediaSettingsView(user.id), { headers: NO_STORE })),
1862
+ route("PUT", "/api/settings/media", "user", async ({ request, user }) => {
1863
+ await store.setMediaAllowlist(user.id, mediaSettingsInput(await jsonBody(request)))
1864
+ return Response.json(mediaSettingsView(user.id), { headers: NO_STORE })
1865
+ }),
1774
1866
  route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
1775
1867
  const kind = url.searchParams.get("kind")
1776
1868
  if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
@@ -1803,7 +1895,11 @@ export function startCentral(options: CentralOptions): Central {
1803
1895
  "/api/sessions/:session/interrupt",
1804
1896
  "session",
1805
1897
  async ({ params }) => {
1806
- await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1898
+ // (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
1899
+ const front = registry.frontRoute(params.session!)
1900
+ // A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
1901
+ const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(params.session!), false)) : false
1902
+ if (!viaFront) await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1807
1903
  registry.cancelTurns(params.session!)
1808
1904
  recordingTap(params.session!, "interrupt", {})
1809
1905
  return new Response(null, { status: 204 })
@@ -1900,7 +1996,12 @@ export function startCentral(options: CentralOptions): Central {
1900
1996
  // harnesses) never ships image bytes to the console in a tool detail.
1901
1997
  route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
1902
1998
  Response.json(redactEmbeddedMedia(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! })), { headers: NO_STORE })),
1903
- route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params }) => timelineFile(params.session!, params.message!, params.index!)),
1999
+ route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params, request, url, user }) => timelineFile(params.session!, params.message!, params.index!, { request, url, userID: user.id })),
2000
+ // (S4 M3) A timeline image by ref, pulled from the spoke in chunks (./media-timeline.ts; files/:index is an alias).
2001
+ route("GET", "/api/sessions/:session/timeline/messages/:message/media/:ref", "session", ({ params, request, url, user }) =>
2002
+ timelineMediaRoute({ request, url, userID: user.id }, params.session!, params.message!, params.ref!)),
2003
+ // (session media §3) An HTML preview of the message's n-th ```html block, read from the spoke, never the console.
2004
+ route("POST", "/api/sessions/:session/previews", "session", ({ params, request, user }) => createPreview(user.id, params.session!, request)),
1904
2005
  route(
1905
2006
  "GET",
1906
2007
  "/api/sessions/:session/context",
@@ -2179,11 +2280,102 @@ export function startCentral(options: CentralOptions): Central {
2179
2280
  return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
2180
2281
  }
2181
2282
 
2182
- /** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
2183
- async function timelineFile(sessionID: string, messageID: string, rawIndex: string): Promise<Response> {
2283
+ /**
2284
+ * POST /api/sessions/:session/previews {messageID, block}: the message's `block`-th (0-based) ```html block, from
2285
+ * the spoke's timeline (newest-first pages), stored as a private preview artifact (ArtifactStore.savePreview: the
2286
+ * same content reuses its revision). Answers {artifact, revision, reused, host}: POST `host` (the artifact host
2287
+ * mint route) opens it. 404 no such message or block (code too_old: older than the newest PREVIEW_LIMITS.pages
2288
+ * timeline pages), 409 spoke offline, 413 over PREVIEW_LIMITS.htmlBytes.
2289
+ * {messageID, path} (session media §3 4b): the local HTML page the message names and its files, through the spoke's
2290
+ * previewBundle op (createPagePreview).
2291
+ */
2292
+ async function createPreview(userID: string, sessionID: string, request: Request): Promise<Response> {
2293
+ const input = await body(request)
2294
+ const extra = Object.keys(input).find((k) => k !== "messageID" && k !== "block" && k !== "path")
2295
+ if (extra !== undefined) return error(400, `Unknown field ${JSON.stringify(extra.slice(0, 50))}`)
2296
+ const { messageID, block, path } = input
2297
+ if (typeof messageID !== "string" || !/^[A-Za-z0-9_.:-]{1,200}$/.test(messageID)) return error(400, "messageID must be a message ID")
2298
+ if (path !== undefined) {
2299
+ if (block !== undefined) return error(400, "Send either block or path, not both")
2300
+ if (!isPreviewPath(path)) return error(400, `path must be 1–${PREVIEW_BUNDLE_LIMITS.pathMax} characters on one line`)
2301
+ return createPagePreview(userID, sessionID, messageID, path)
2302
+ }
2303
+ if (typeof block !== "number" || !Number.isSafeInteger(block) || block < 0 || block > 9999) return error(400, "block must be a whole number 0–9999")
2304
+ if (!artifacts) return error(422, "This hub does not store artifacts", "unsupported")
2305
+ const connID = ownerOf(sessionID)
2306
+ const texts = await messageTexts((page) => spokes.request(connID, { op: "timeline", sessionID, order: "desc", ...page }), messageID)
2307
+ if ("error" in texts) {
2308
+ return texts.error === "too_old" ? error(404, "This message is too old to preview", "too_old") : error(404, "No such message", "not_found")
2309
+ }
2310
+ const picked = pickHtmlBlock(texts.texts, block)
2311
+ if ("error" in picked) {
2312
+ return picked.error === "not_found" ? error(404, "No HTML block at that index", "not_found") : error(413, `The HTML block is larger than ${PREVIEW_LIMITS.htmlBytes} bytes`, "too_large")
2313
+ }
2314
+ try {
2315
+ const saved = await artifacts.savePreview(userID, { sessionID, messageID, block }, picked.html)
2316
+ const host = `/api/agent/artifacts/${saved.artifact.id}/revisions/${saved.revision.id}/host`
2317
+ return Response.json({ ...saved, host }, { status: saved.reused ? 200 : 201, headers: NO_STORE })
2318
+ } catch (cause) {
2319
+ return decisionFailure(cause)
2320
+ }
2321
+ }
2322
+
2323
+ /**
2324
+ * POST …/previews {messageID, path}: the page `path` names (as written in the message) and the files it refers to,
2325
+ * read by the owning spoke under its media path policy (op previewBundle, feature "previewBundle"; the hub reads the
2326
+ * files the manifest does not inline chunk by chunk and checks each against it), stored as a preview artifact
2327
+ * revision (savePreview: unchanged files reuse it, a changed file is a new revision). Answers as for a block plus
2328
+ * `skipped` (files left out, with reasons) and `skippedMore`. 404 refused (not a page of this message under the
2329
+ * policy) or not_found (no such message, or hidden by a revert), 409 spoke offline or the page kept changing
2330
+ * (changed), 413 the page is over PREVIEW_BUNDLE_LIMITS.fileBytes ({bytes, max}), 422 a spoke without the feature
2331
+ * ({feature, harness}) or a hub without artifacts, 502 a malformed bundle.
2332
+ */
2333
+ async function createPagePreview(userID: string, sessionID: string, messageID: string, path: string): Promise<Response> {
2334
+ if (!artifacts) return error(422, "This hub does not store artifacts", "unsupported")
2335
+ const connID = ownerOf(sessionID)
2336
+ if (!registry.supports(connID, "previewBundle")) {
2337
+ const lacking = { feature: "previewBundle", harness: registry.harnessOf(connID) ?? "unknown" }
2338
+ return Response.json({ error: unsupportedMessage(lacking), code: "unsupported", ...lacking }, { status: 422, headers: NO_STORE })
2339
+ }
2340
+ let fetched
2341
+ try {
2342
+ fetched = await fetchPreviewBundle((chunk) => spokes.request(connID, { op: "previewBundle", sessionID, messageID, path, ...chunk }))
2343
+ } catch (cause) {
2344
+ if (cause instanceof BundleProtocolError) return error(502, cause.message, "bad_bundle")
2345
+ throw cause
2346
+ }
2347
+ if ("error" in fetched) {
2348
+ switch (fetched.error) {
2349
+ case "refused":
2350
+ return error(404, "This page isn't one of this session's files", "refused")
2351
+ case "too_large":
2352
+ return Response.json({ error: `The page is larger than ${fetched.max} bytes`, code: "too_large", bytes: fetched.bytes, max: fetched.max }, { status: 413, headers: NO_STORE })
2353
+ case "changed":
2354
+ return error(409, "The page kept changing while it was read; try again", "changed")
2355
+ default:
2356
+ return error(404, "No such message", "not_found")
2357
+ }
2358
+ }
2359
+ try {
2360
+ const saved = await artifacts.savePreview(userID, { sessionID, messageID, path }, fetched.bundle)
2361
+ const host = `/api/agent/artifacts/${saved.artifact.id}/revisions/${saved.revision.id}/host`
2362
+ const skipped = { skipped: fetched.skipped, ...(fetched.skippedMore ? { skippedMore: fetched.skippedMore } : {}) }
2363
+ return Response.json({ ...saved, host, ...skipped }, { status: saved.reused ? 200 : 201, headers: NO_STORE })
2364
+ } catch (cause) {
2365
+ return decisionFailure(cause)
2366
+ }
2367
+ }
2368
+
2369
+ /**
2370
+ * GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. On a spoke with
2371
+ * "timelineMedia" it is media/f<index> (chunked, verified, cached); else the older single-answer timelineFile op.
2372
+ */
2373
+ async function timelineFile(sessionID: string, messageID: string, rawIndex: string, caller: MediaCaller): Promise<Response> {
2184
2374
  const index = /^\d{1,3}$/.test(rawIndex) ? Number(rawIndex) : NaN
2185
2375
  if (!Number.isInteger(index) || messageID.length > 300) return error(400, "Expected an attachment index 0–999")
2186
- const { file, tooLarge } = await spokes.request(ownerOf(sessionID), { op: "timelineFile", sessionID, messageID, index })
2376
+ const connID = ownerOf(sessionID)
2377
+ if (registry.supports(connID, "timelineMedia")) return timelineMediaRoute(caller, sessionID, messageID, `f${index}`)
2378
+ const { file, tooLarge } = await spokes.request(connID, { op: "timelineFile", sessionID, messageID, index })
2187
2379
  if (tooLarge) return error(413, "This image is too large to show here")
2188
2380
  // Only image types that are safe to render inline are served (never SVG or HTML).
2189
2381
  if (!file || typeof file.data !== "string" || !PROMPT_IMAGE_MIMES.includes(file.mime)) return error(404, "No such image")
@@ -2193,6 +2385,33 @@ export function startCentral(options: CentralOptions): Central {
2193
2385
  })
2194
2386
  }
2195
2387
 
2388
+ /**
2389
+ * GET …/timeline/messages/:message/media/:ref (S4 M3): 400 for a malformed ref or message ID, 409 offline, 422
2390
+ * unsupported {feature, harness} when the owning spoke did not announce timelineMedia; else ./media-timeline.ts.
2391
+ */
2392
+ async function timelineMediaRoute(caller: MediaCaller, sessionID: string, messageID: string, ref: string): Promise<Response> {
2393
+ const parsed = parseMediaRef(ref)
2394
+ if (!parsed) return error(400, "Expected a media ref (f<n>, t<part>.<item> or p:<path>)")
2395
+ if (!MEDIA_MESSAGE_ID.test(messageID)) return error(400, "Expected a message ID")
2396
+ const connID = ownerOf(sessionID)
2397
+ if (!registry.supports(connID, "timelineMedia")) {
2398
+ const lacking = { feature: "timelineMedia", harness: registry.harnessOf(connID) ?? "unknown" }
2399
+ return Response.json({ error: unsupportedMessage(lacking), code: "unsupported", ...lacking }, { status: 422, headers: NO_STORE })
2400
+ }
2401
+ return timelineMedia.serve({
2402
+ userID: caller.userID,
2403
+ connID,
2404
+ sessionID,
2405
+ messageID,
2406
+ ref,
2407
+ parsed,
2408
+ ifNoneMatch: caller.request.headers.get("if-none-match"),
2409
+ download: caller.url.searchParams.get("download") === "1",
2410
+ bypassCache: registry.revertStaged(sessionID),
2411
+ revertAt: registry.revertCommits(connID).find((c) => c.sessionID === sessionID)?.committed.at,
2412
+ })
2413
+ }
2414
+
2196
2415
  /**
2197
2416
  * Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
2198
2417
  * the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
@@ -2300,9 +2519,21 @@ export function startCentral(options: CentralOptions): Central {
2300
2519
  const text = typeof raw === "string" ? raw.trim() : ""
2301
2520
  if (!text) return error(400, "Empty utterance")
2302
2521
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
2303
- const connID = ownerOf(sessionID, "steer")
2522
+ // (voice front P1) While a front is active, utterances go to the front; it relays them to the session.
2523
+ const front = registry.frontRoute(sessionID)
2304
2524
  await humanTurn(sessionID)
2305
- const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
2525
+ // A front that cannot take it failed: the session talks directly (front_failed), never a 502.
2526
+ let inboxID = front
2527
+ ? await spokes.request(front.connID, { op: "steer", sessionID: front.sessionID, text }).then(
2528
+ (result) => result.inboxID,
2529
+ (cause) => {
2530
+ console.warn(`[hub] voice front of ${sessionID} did not take the utterance: ${(cause as Error).message}`)
2531
+ registry.frontLost(sessionID)
2532
+ return undefined
2533
+ },
2534
+ )
2535
+ : undefined
2536
+ if (inboxID === undefined) inboxID = (await spokes.request(ownerOf(sessionID, "steer"), { op: "steer", sessionID, text })).inboxID
2306
2537
  recordingTap(sessionID, "utterance.received", { inboxID, text })
2307
2538
  registry.trackUtterance(sessionID, inboxID)
2308
2539
  return Response.json({ inboxID }, { status: 202 })
@@ -2357,18 +2588,32 @@ export function startCentral(options: CentralOptions): Central {
2357
2588
  }
2358
2589
 
2359
2590
  async function conversation(request: Request, sessionID: string): Promise<Response> {
2360
- const on = (await body(request)).on
2361
- if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
2591
+ const input = await body(request)
2592
+ const on = input.on
2593
+ if (typeof on !== "boolean" || (input.front !== undefined && typeof input.front !== "boolean")) return error(400, "Expected {on: boolean, front?: boolean}")
2594
+ // (voice front P1) `front`: put a hidden voice front in front of the session, when its spoke serves it.
2595
+ const wantsFront = on && input.front === true
2596
+ const hadFront = registry.wantsFront(sessionID)
2362
2597
  // Turning off always clears the hub's state, so a later reconnect does not switch it back on.
2363
2598
  if (!on) registry.setConversation(sessionID, false)
2364
2599
  // (REC-01) Off ends the session's recordings (with a grace for the console's last batches); best effort.
2365
2600
  const tenant = registry.sessionTenant(sessionID)
2366
2601
  if (!on && tenant !== undefined) await recordingService.conversationChanged(tenant, sessionID, false).catch((cause) => console.error(`[hub] recording end failed: ${(cause as Error).message}`))
2367
2602
  const connID = ownerOf(sessionID)
2368
- const { agent } = await spokes.request(connID, { op: "conversation", sessionID, on })
2369
- if (on) registry.setConversation(sessionID, true, connID)
2370
- if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true })
2371
- return Response.json({ on, agent })
2603
+ const sendFront = wantsFront && registry.supports(connID, "voiceFront")
2604
+ // (voice front P1) Until the op answers, a "Voice: " session the spoke newly reports is not listed (the front).
2605
+ if (sendFront) registry.frontOpStarted(connID)
2606
+ let result: ResultFor["conversation"]
2607
+ try {
2608
+ result = await spokes.request(connID, { op: "conversation", sessionID, on, ...(sendFront ? { front: true } : {}) })
2609
+ const frontID = sendFront && typeof result.front?.sessionID === "string" ? result.front.sessionID : null
2610
+ if (on) registry.setConversation(sessionID, true, connID, wantsFront ? frontID : undefined)
2611
+ } finally {
2612
+ if (sendFront) registry.frontOpEnded(connID)
2613
+ }
2614
+ const front = registry.frontOf(sessionID) !== undefined
2615
+ if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
2616
+ return Response.json({ on, agent: result.agent, ...(wantsFront || (!on && hadFront) ? { front } : {}) })
2372
2617
  }
2373
2618
 
2374
2619
  /** Enables or disables voice for a session, through a process that reported it (service first). */
@@ -2819,6 +3064,16 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
2819
3064
  return Object.keys(prefs).length ? prefs : undefined
2820
3065
  }
2821
3066
 
3067
+ /** PUT /api/settings/media body: {allowlist: hostnames or `*.suffix`es}, normalised (packages/protocol media-allowlist). */
3068
+ function mediaSettingsInput(value: unknown): string[] {
3069
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new HttpError(400, "Expected {allowlist: [hostnames]}")
3070
+ const record = value as Record<string, unknown>
3071
+ if (Object.keys(record).some((key) => key !== "allowlist")) throw new HttpError(400, "Expected {allowlist: [hostnames]}")
3072
+ const parsed = parseMediaAllowlist(record.allowlist)
3073
+ if (!parsed.ok) throw new HttpError(400, parsed.reason)
3074
+ return parsed.allowlist
3075
+ }
3076
+
2822
3077
  /** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
2823
3078
  function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
2824
3079
  const invalid = () =>