@huynle/hub 0.6.0 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -1,26 +1,66 @@
1
1
  // Hub: HTTP routes (web UI, JSON API, SSE, enrollment) and the spoke WebSocket.
2
- // Phones authenticate with the `voice_device` cookie; spokes with machine bearer tokens. Source IPs
2
+ // Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
3
+ // token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
3
4
  // (including loopback) are never trusted.
4
5
  import type { Server } from "bun"
5
6
  import { diskAssets, type WebAssets } from "./assets"
6
7
  import path from "node:path"
7
- import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type TurnResult } from "../protocol"
8
+ import { promisify } from "node:util"
9
+ import { brotliCompress, constants as zlib, gzip } from "node:zlib"
10
+ import { formAnswerProblem, PROMPT_IMAGE_MIMES, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
8
11
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
9
12
  import { speakable } from "../speakable"
10
13
  import { Auth, RateLimitError, ValidationError } from "./auth"
11
14
  import { emailAllowed, type CentralConfig } from "./config"
12
15
  import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError } from "./oidc"
13
- import { Push, type PushSender } from "./push"
16
+ import { isNotificationKind, Push, type PushSender } from "./push"
14
17
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
15
- import { BusyError, Registry } from "./registry"
18
+ import { Webhooks } from "./agent/webhooks"
19
+ import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
20
+ import { hubBackends, type Backends } from "./backend"
21
+ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
22
+ import { ActingSwitch } from "./agent/acting-switch"
23
+ import { AgentAudit } from "./agent/audit"
24
+ import { Grants } from "./agent/grants"
25
+ import { Decisions, type Decision } from "./agent/decisions"
26
+ import type { Escalate } from "./agent/acting"
27
+ import { Escalations } from "./agent/escalation"
28
+ import { GrantRequests } from "./agent/grant-requests"
29
+ import { partyFromActor } from "./agent/envelope"
30
+ import { Delivery } from "./agent/delivery"
31
+ import { AgentError, type AgentErrorCode } from "./agent/errors"
32
+ import type { Launch } from "./agent/launch-store"
33
+ import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
34
+ import { hubDirectory } from "./agent/directory-routes"
35
+ import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
36
+ import { SessionTokens } from "./agent/session-tokens"
37
+ import { SessionTokenPush } from "./session-token-push"
38
+ import { TokenKeys } from "./token-keys"
39
+ import { EventLog } from "./events-log"
40
+ import { BusyError, Registry, type RegistryMessage } from "./registry"
41
+ import { createActions, type Unsupported, unsupportedMessage } from "./actions"
16
42
  import { audioFormat, SpeechError } from "./speech/provider"
17
43
  import { SpeechService } from "./speech/service"
18
- import { RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type Notifier, type SpokeSocketData } from "./spoke-ws"
19
- import { LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
44
+ import { NodeRegistry } from "./nodes"
45
+ import { ConnDirectory } from "./cluster/directory"
46
+ import { PodRpc } from "./cluster/rpc"
47
+ import { ClusterView } from "./cluster/view"
48
+ import { LeaderLoop, NoLeaderError } from "./cluster/leader"
49
+ import { ClusterRecommendations } from "./cluster/recommend"
50
+ import { LiveFanout, type LiveEvent } from "./cluster/live"
51
+ import { randomBytes, randomUUID } from "node:crypto"
52
+ import { LaunchEngine } from "./agent/launch-engine"
53
+ import { LaunchTokens } from "./agent/launch-tokens"
54
+ import { LaunchFeed, launchInfo } from "./agent/launch-routes"
55
+ import type { WatchRegistry } from "./agent/watch"
56
+ import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
57
+ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
20
58
 
21
59
  export interface Speech {
22
60
  transcribe(audio: Uint8Array, format: string): Promise<string>
23
61
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
62
+ /** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
63
+ speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
24
64
  }
25
65
 
26
66
  export interface CentralOptions {
@@ -37,16 +77,36 @@ export interface CentralOptions {
37
77
  readonly notifier?: Notifier
38
78
  /** Overrides web-push delivery (tests; web-push itself needs https push services). */
39
79
  readonly pushSender?: PushSender
40
- /** Resolves push endpoint hosts at subscribe time (SSRF check); the system resolver by default. */
80
+ /** Resolves push endpoint and webhook hosts (SSRF checks); the system resolver by default. */
41
81
  readonly resolveHost?: ResolveHost
42
- /** Clock for sign-in expiry and the store (tests). */
82
+ /** Clock for sign-in expiry, the store, the registry and the backends (tests). */
43
83
  readonly now?: () => number
84
+ /** Shared state for new code (default: in memory, see ./backend). */
85
+ readonly backends?: Backends
86
+ /** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
87
+ readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
44
88
  readonly timing?: {
45
89
  readonly helloTimeoutMs?: number
46
90
  readonly requestTimeoutMs?: number
47
91
  readonly turnGraceMs?: number
92
+ /** `Prefer: respond-async` turns: how long the hub waits for a quick result before answering 202. */
93
+ readonly turnAcceptMs?: number
48
94
  readonly heartbeatMs?: number
49
95
  readonly staleMs?: number
96
+ /** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
97
+ readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
98
+ /** Cluster mode: how often each pod re-checks its sockets' machines and owners (see RECHECK_MS). */
99
+ readonly recheckMs?: number
100
+ /** Cluster mode: the cluster view's heartbeat and slice TTL (see VIEW_HEARTBEAT_MS, VIEW_TTL_MS). */
101
+ readonly viewHeartbeatMs?: number
102
+ readonly viewTtlMs?: number
103
+ /** Leader leases of the timed jobs (see LEADER_TTL_MS). */
104
+ readonly leaderTtlMs?: number
105
+ /** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
106
+ readonly loopTickMs?: number
107
+ /** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
108
+ readonly drainTimeoutMs?: number
109
+ readonly drainRetryAfterMs?: number
50
110
  }
51
111
  }
52
112
 
@@ -56,11 +116,36 @@ export interface Central {
56
116
  readonly store: Store
57
117
  readonly auth: Auth
58
118
  readonly registry: Registry
119
+ /** Connected `hub node` daemons (never in the registry). */
120
+ readonly nodes: NodeRegistry
59
121
  readonly spokes: SpokeHub
60
122
  readonly push: Push
61
123
  readonly speechSettings: SpeechService
124
+ /** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
125
+ readonly backends: Backends
126
+ /** Agent messages: store and delivery to sessions (the message routes use it). */
127
+ readonly delivery: Delivery
128
+ /** Decisions (ask @me): answered by the user on /api/decisions. */
129
+ readonly decisions: Decisions
130
+ /** Kill switches (per user and hub-wide) and hub.json agents.never, for the acting verbs. */
131
+ readonly acting: ActingSwitch
132
+ /** Launches (design §6): accept, list, stop; placement onto nodes and each launch's run to its end. */
133
+ readonly launches: LaunchEngine
134
+ /** Every launch change, per user (SSE 'launch' events and the agent watch subscribe; webhooks hang off it). */
135
+ readonly launchFeed: LaunchFeed
136
+ /** Launch-bound (hlt1) tokens: what a sandboxed harness connects back to /spoke/ws with (phase 5). */
137
+ readonly launchTokens: LaunchTokens
138
+ /** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
139
+ readonly webhooks: Webhooks
62
140
  /** The JSON API's route table (method, path, access), for audits and tests. */
63
141
  readonly routes: readonly RouteInfo[]
142
+ /**
143
+ * Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
144
+ * nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
145
+ * are released, cluster view slices withdrawn, SSE streams end with a `retry:` hint, and it resolves when requests
146
+ * in flight are done (at most timeoutMs; the rest fail as offline). Call stop() after it. Repeated calls share one.
147
+ */
148
+ drain(options?: DrainOptions): Promise<void>
64
149
  stop(): Promise<void>
65
150
  }
66
151
 
@@ -69,12 +154,25 @@ type Message = { type: string; [key: string]: unknown }
69
154
  interface Listener {
70
155
  readonly userID: string
71
156
  readonly deviceID: string
72
- send(message: Message): void
157
+ /** Leaves out streamed text and reasoning (except of `session`'s tree). */
158
+ readonly lite: boolean
159
+ /** The session the page shows: its tree's text and reasoning stream even when lite. */
160
+ readonly session?: string
161
+ /** Sends a message; `id` is its event id (EventSource's lastEventId). */
162
+ send(message: Message, id?: string): void
73
163
  close(): void
164
+ /** Draining: tells the browser to reconnect after retryMs (`retry:`), then ends the stream. */
165
+ end(retryMs: number): void
74
166
  }
75
167
 
76
- /** Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path. */
77
- export type Access = "public" | "user" | "admin" | "session" | "machine"
168
+ /** Messages a lite stream leaves out (unless they are of the listener's session tree). */
169
+ const STREAMED = new Set(["delta", "reasoning"])
170
+
171
+ /**
172
+ * Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
173
+ * "agent": an /api/agent route (src/central/agent), checked per credential kind there.
174
+ */
175
+ export type Access = "public" | "user" | "admin" | "session" | "machine" | "agent"
78
176
 
79
177
  export interface RouteInfo {
80
178
  readonly method: string
@@ -107,7 +205,23 @@ const NO_STORE = { "cache-control": "no-store" }
107
205
  export const COOKIE = "voice_device"
108
206
  const COOKIE_MAX_AGE = 400 * 24 * 60 * 60
109
207
  const SSE_HEARTBEAT_MS = 20_000
208
+ /** Draining: the `retry:` an ending SSE stream sends (the browser reconnects, through the load balancer, after it). */
209
+ const SSE_RETRY_MS = 2_000
210
+ const SSE_RETRY = `retry: ${SSE_RETRY_MS}\n\n`
211
+ /** /readyz in cluster mode: how long the database and Valkey get to answer. */
212
+ const READY_CHECK_MS = 2_000
213
+ /** Cluster mode: how much longer than the turn timeout a turn's cluster-wide lease lasts (it is released at its end). */
214
+ const TURN_LEASE_SLACK_MS = 120_000
215
+ /** Cluster mode: how long a stop sent to the launches leader may take (it waits for the node's collect and cleanup). */
216
+ const LAUNCH_STOP_TIMEOUT_MS = 180_000
217
+ /**
218
+ * `POST …/turn` with `Prefer: respond-async`: a turn that has not settled after this long is answered 202 and
219
+ * finishes in the background (result as an SSE `turn` event). Long enough for the quick refusals (busy, unknown
220
+ * session, spoke error), short enough that no proxy cuts the request (Traefik's default readTimeout is 60 s).
221
+ */
222
+ export const TURN_ACCEPT_MS = 1500
110
223
  const NO_OWNER = "No OpenCode process for this session is connected"
224
+ const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
111
225
  const NOT_EXPOSED = "Voice is not enabled for this session"
112
226
  /** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
113
227
  const PERMISSION_GONE = "This request was already answered or expired"
@@ -118,6 +232,8 @@ const MAX_TITLE_CHARS = 200
118
232
  const MAX_HERDR_WORKSPACE_CHARS = 100
119
233
  /** View preferences: entries per list and characters per entry. */
120
234
  const VIEW_PREF_MAX_ENTRIES = 500
235
+ /** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
236
+ const VIEW_PREF_MAX_ARCHIVED = 5000
121
237
  const VIEW_PREF_MAX_CHARS = 4096
122
238
  const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
123
239
  /** Longest utterance accepted for conversation mode (characters, after trimming). */
@@ -149,6 +265,29 @@ const CONTENT_TYPES: Record<string, string> = {
149
265
  ".svg": "image/svg+xml",
150
266
  ".png": "image/png",
151
267
  ".ico": "image/x-icon",
268
+ // Voice mode's on-device speech detection (Silero VAD through onnxruntime-web)
269
+ ".wasm": "application/wasm",
270
+ ".onnx": "application/octet-stream",
271
+ }
272
+ /** Vite's content-hashed build output: a changed file gets a new name, so it can be cached for good. */
273
+ const HASHED_ASSET = /^assets\//
274
+ const IMMUTABLE = "public, max-age=31536000, immutable"
275
+ /** Hashed assets sent compressed when the browser accepts it (onnxruntime's wasm: 12 MB → 2.3 MB with Brotli). */
276
+ const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".wasm", ".json", ".svg"])
277
+ const MIN_COMPRESS_BYTES = 1024
278
+ const brotli = promisify(brotliCompress)
279
+ const gzipAsync = promisify(gzip)
280
+
281
+ /** "br" or "gzip" when the Accept-Encoding header allows it (q=0 refuses), else undefined. */
282
+ function pickEncoding(header: string | null): "br" | "gzip" | undefined {
283
+ const accepted = new Map<string, number>()
284
+ for (const part of (header ?? "").toLowerCase().split(",")) {
285
+ const [name, ...params] = part.trim().split(";")
286
+ const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)?.[1]).find(Boolean)
287
+ if (name) accepted.set(name.trim(), q === undefined ? 1 : Number(q))
288
+ }
289
+ for (const encoding of ["br", "gzip"] as const) if ((accepted.get(encoding) ?? 0) > 0) return encoding
290
+ return undefined
152
291
  }
153
292
 
154
293
  class HttpError extends Error {
@@ -162,38 +301,111 @@ class HttpError extends Error {
162
301
  }
163
302
  }
164
303
 
304
+ /** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
305
+ const unsupportedError = (lacking: Pick<Unsupported, "feature" | "harness">) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
306
+
165
307
  const error = (status: number, message: string, code?: string) =>
166
308
  Response.json({ error: message, ...(code ? { code } : {}) }, { status })
167
309
 
168
310
  export function startCentral(options: CentralOptions): Central {
169
311
  const { config } = options
312
+ const heartbeatMs = options.timing?.heartbeatMs ?? SSE_HEARTBEAT_MS
170
313
  const web: WebAssets = options.web ?? diskAssets(options.webDir ?? path.join(import.meta.dir, "..", "..", "web", "dist"))
171
314
  const now = options.now ?? Date.now
172
- const store = Store.open(config.stateDir, now, { mode: config.auth.mode })
315
+ /** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
316
+ const ownBackends = !options.backends
317
+ const backends = options.backends ?? hubBackends(config, { now })
318
+ // Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
319
+ // migration runs on start). Otherwise state.json in the state dir.
320
+ const store = config.cluster
321
+ ? Store.shared({ state: backends.state, bus: backends.bus, now, instanceID: `${config.cluster.podID}-${randomUUID().slice(0, 8)}` })
322
+ : Store.open(config.stateDir, now, { mode: config.auth.mode })
173
323
  // A machine's tenant is its owner (read live: ownership moves on migration).
174
- const registry = new Registry(Date.now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
175
- const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender })
324
+ const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
325
+ const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
326
+ const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
176
327
  const notifier = options.notifier ?? push
177
328
  /** SSE subscribers, one user each. */
178
329
  const listeners = new Set<Listener>()
330
+ /** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
331
+ const eventLog = new EventLog()
332
+ /** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
333
+ const wants = (listener: Listener, message: Message): boolean => {
334
+ if (!listener.lite || !STREAMED.has(message.type)) return true
335
+ if (!listener.session) return false
336
+ const root = registry.status(listener.session)?.[0]?.id ?? listener.session
337
+ return message.sessionID === listener.session || message.rootID === root || message.sessionID === root
338
+ }
339
+ /**
340
+ * Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
341
+ * EventLog first (except streamed text), so consoles on any pod get them and resume by Last-Event-ID on any pod.
342
+ */
343
+ const live = config.cluster ? new LiveFanout({ bus: backends.bus, events: backends.events, podID: config.cluster.podID }) : undefined
344
+ /** Sends a message to this process's streams of a user (except `exceptDevice`'s); a catalog is built per user. */
345
+ const deliver = (userID: string, message: Message, id?: string, exceptDevice?: string) => {
346
+ for (const listener of [...listeners]) {
347
+ if (listener.userID !== userID || listener.deviceID === exceptDevice) continue
348
+ if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(userID) }, id)
349
+ else if (wants(listener, message)) listener.send(message, id)
350
+ }
351
+ }
352
+ /** Logs a message for one user and sends it to their streams (except `exceptDevice`'s). */
353
+ const toUser = (userID: string, message: Message, exceptDevice?: string) => {
354
+ if (live) {
355
+ // A catalog is this pod's own view (its registry covers the cluster): never logged, never sent to other pods.
356
+ if (message.type === "catalog") deliver(userID, message)
357
+ // Failures are logged by the fan-out.
358
+ else live.send(userID, message, exceptDevice).catch(() => {})
359
+ return
360
+ }
361
+ // A catalog is logged as a marker: a replay sends one fresh catalog for any number of them.
362
+ const id = eventLog.append(userID, message.type === "catalog" ? { type: "catalog" } : message, exceptDevice)
363
+ deliver(userID, message, id, exceptDevice)
364
+ }
365
+ /** Closes the streams of users who were disabled or removed. */
366
+ const closeDisabled = () => {
367
+ for (const listener of [...listeners]) {
368
+ const user = store.user(listener.userID)
369
+ if (!user || user.disabled) listener.close()
370
+ }
371
+ }
372
+ /** Users that may receive an `audience` message (cluster mode: whether or not they stream on this pod). */
373
+ const audienceUsers = (audience: { tenant?: string; admins?: true }): string[] => {
374
+ if (audience.admins) return store.users().filter((u) => u.role === "admin" && !u.disabled).map((u) => u.id)
375
+ if (audience.tenant === undefined) return []
376
+ const user = store.user(audience.tenant)
377
+ return user && !user.disabled ? [user.id] : []
378
+ }
179
379
  /**
180
380
  * Sends to SSE subscribers: catalog to everyone (each gets their own machines), `admins` messages to
181
381
  * admins only, anything else only to `tenant` (the session's owner). Streams of users who were
182
382
  * disabled are closed.
183
383
  */
184
384
  const broadcast = (message: Message, audience: { tenant?: string; admins?: true } = {}) => {
185
- for (const listener of [...listeners]) {
186
- const user = store.user(listener.userID)
187
- if (!user || user.disabled) {
188
- listener.close()
189
- continue
190
- }
191
- if (message.type === "catalog") listener.send({ type: "catalog", machines: machines(user.id) })
385
+ closeDisabled()
386
+ if (live) {
387
+ // Stored machines changed: every pod sends its streams a fresh catalog (catalogHere).
388
+ if (message.type === "catalog") live.publishCatalog().catch((cause) => console.error(`[hub] catalog announcement failed: ${(cause as Error).message}`))
389
+ else for (const userID of audienceUsers(audience)) toUser(userID, message)
390
+ return
391
+ }
392
+ const users = new Set([...eventLog.users(), ...[...listeners].map((l) => l.userID)])
393
+ for (const userID of users) {
394
+ const user = store.user(userID)
395
+ if (!user || user.disabled) continue
396
+ if (message.type === "catalog") toUser(userID, message)
192
397
  else if (audience.admins) {
193
- if (user.role === "admin") listener.send(message)
194
- } else if (audience.tenant !== undefined && audience.tenant === user.id) listener.send(message)
398
+ if (user.role === "admin") toUser(userID, message)
399
+ } else if (audience.tenant !== undefined && audience.tenant === userID) toUser(userID, message)
195
400
  }
196
401
  }
402
+ /** Cluster mode: a fresh catalog to this pod's streams (the registry's cluster-wide view changed). */
403
+ const catalogHere = () => {
404
+ closeDisabled()
405
+ for (const userID of new Set([...listeners].map((l) => l.userID))) toUser(userID, { type: "catalog" })
406
+ }
407
+ live?.onCatalog(catalogHere)
408
+ live?.start().catch((cause) => console.error(`[hub] live fan-out did not start: ${(cause as Error).message}`))
197
409
  const speechSettings = new SpeechService({
198
410
  store,
199
411
  defaults: {
@@ -210,6 +422,10 @@ export function startCentral(options: CentralOptions): Central {
210
422
  let publicURL = config.publicURL
211
423
  let auth!: Auth
212
424
  let spokes!: SpokeHub
425
+ /** Set by drain() (design §10): /readyz fails and new SSE streams are sent away. */
426
+ let draining: Promise<void> | undefined
427
+ /** Aborted by drain(): the agent watch streams end with a `retry:` hint. */
428
+ const drainSignal = new AbortController()
213
429
  /** At most one machine per hub process is approved without confirmation (see localAutoApprove). */
214
430
  let autoApproved = false
215
431
 
@@ -233,22 +449,62 @@ export function startCentral(options: CentralOptions): Central {
233
449
  const origins = loopbackOrigins(publicURL)
234
450
  const secure = publicURL.startsWith("https:")
235
451
 
236
- auth = new Auth({ store, publicURL, now, mode: config.auth.mode })
452
+ auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
237
453
  // options.fetch is for speech providers only; sign-in always talks to the real issuer.
238
- const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now }) : undefined
454
+ const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
239
455
  /** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
240
456
  const speechScope = (userID: string) => (userID === LOCAL_USER ? undefined : userID)
241
457
  const speechFor = (userID: string): Speech => options.speech ?? speechSettings.forUser(speechScope(userID))
458
+ const tokenKeys = TokenKeys.open(config.stateDir)
459
+ // Launch-bound tokens (phase 5): checked against the launch engine's store (created below; read only at use).
460
+ const launchTokens: LaunchTokens = new LaunchTokens({ keys: tokenKeys, store, launches: { launch: (userID, launchID) => launches.store.get(userID, launchID) }, publicURL, now })
242
461
  spokes = new SpokeHub({
243
462
  store,
244
463
  auth,
245
464
  registry,
465
+ nodes,
246
466
  publicURL,
247
467
  notifier,
248
468
  broadcast: (message, tenant) => broadcast(message, { tenant }),
249
469
  turnTimeoutMs: config.turnTimeoutMs,
470
+ launchTokens,
471
+ // Cluster mode: connections are found through the shared directory and reached over pod RPC (design §9).
472
+ ...(config.cluster
473
+ ? { cluster: { directory: new ConnDirectory({ kv: backends.kv, podID: config.cluster.podID }), rpc: new PodRpc({ bus: backends.bus, podID: config.cluster.podID }) } }
474
+ : {}),
250
475
  ...options.timing,
251
476
  })
477
+ spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
478
+ // Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
479
+ // registries' reads cover the whole cluster (design §9).
480
+ const view = config.cluster
481
+ ? new ClusterView({
482
+ catalog: backends.catalog,
483
+ bus: backends.bus,
484
+ podID: config.cluster.podID,
485
+ registry,
486
+ nodes,
487
+ users: async () => {
488
+ await store.ready()
489
+ return store.users().map((u) => u.id)
490
+ },
491
+ ...(options.timing?.viewHeartbeatMs !== undefined ? { heartbeatMs: options.timing.viewHeartbeatMs } : {}),
492
+ ...(options.timing?.viewTtlMs !== undefined ? { ttlMs: options.timing.viewTtlMs } : {}),
493
+ })
494
+ : undefined
495
+ view?.start().catch((cause) => console.error(`[hub] cluster view did not start: ${(cause as Error).message}`))
496
+ // Session tokens (design §1.3): minted for the sessions spokes report, pushed on their sessions frames.
497
+ const sessionTokens = new SessionTokens({ keys: tokenKeys, store, sessions: registry, publicURL, now })
498
+ const tokenPush = new SessionTokenPush({
499
+ registry,
500
+ tokens: sessionTokens,
501
+ send: (connID, req) => spokes.request(connID, req),
502
+ hubURL: publicURL,
503
+ now,
504
+ log: (message) => console.error(message),
505
+ })
506
+ registry.onSessions((connID) => void tokenPush.sessions(connID))
507
+ tokenPush.start()
252
508
 
253
509
  /** The user's machines with their live processes and sessions. */
254
510
  const machines = (userID: string) => {
@@ -267,12 +523,20 @@ export function startCentral(options: CentralOptions): Central {
267
523
  }))
268
524
  }
269
525
 
270
- registry.subscribe((message, tenant) => broadcast({ ...message }, tenant !== undefined ? { tenant } : {}))
526
+ // Cluster mode: a catalog change stays on this pod (each pod's registry emits its own for remote slices); the rest
527
+ // goes to the tenant's streams on every pod.
528
+ registry.subscribe((message, tenant) => (live && message.type === "catalog" ? catalogHere() : broadcast({ ...message }, tenant !== undefined ? { tenant } : {})))
271
529
  registry.subscribeAlerts((alert) => {
272
530
  // Push goes to the session owner's devices only; an alert without an owner goes nowhere.
273
531
  if (!alert.owner) return
274
- const extra = alert.requestID ? { requestID: alert.requestID } : undefined
275
- notifier.notify(alert.kind, alert.sessionID, alert.title, alert.detail, alert.text, alert.owner, extra).catch((cause) => {
532
+ // Activity updates are collected and throttled per device by the push layer.
533
+ if (alert.kind === "activity") return push.activity(alert)
534
+ const extra = {
535
+ ...(alert.requestID ? { requestID: alert.requestID } : {}),
536
+ ...(alert.subject ? { subject: alert.subject } : {}),
537
+ ...(alert.key ? { key: alert.key } : {}),
538
+ }
539
+ notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
276
540
  console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
277
541
  })
278
542
  })
@@ -284,6 +548,7 @@ export function startCentral(options: CentralOptions): Central {
284
548
  {
285
549
  prefs: store.pushPrefs(deviceID),
286
550
  quietHours: store.quietHours(deviceID) ?? null,
551
+ activityMinutes: store.activityMinutes(deviceID),
287
552
  muted: store.mutedSessions(userID),
288
553
  subscriptions: mine.length,
289
554
  ...(endpoint ? { subscribed: mine.some((s) => s.endpoint === endpoint) } : {}),
@@ -296,12 +561,19 @@ export function startCentral(options: CentralOptions): Central {
296
561
  `${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`
297
562
 
298
563
  /** The device of the voice_device cookie and its user; undefined when unknown or the user is disabled. */
299
- const deviceFor = (request: Request): { device: DeviceRecord; user: UserRecord } | undefined => {
564
+ /** The device of a token (cookie value or device token), its owner re-read too (a shared store's security re-check). */
565
+ const deviceOfToken = async (token: string): Promise<DeviceRecord | undefined> => {
566
+ const device = await store.findDevice(token)
567
+ if (device) await store.recheck({ users: [device.ownerID] })
568
+ return device
569
+ }
570
+
571
+ const deviceFor = async (request: Request): Promise<{ device: DeviceRecord; user: UserRecord } | undefined> => {
300
572
  const header = request.headers.get("cookie") ?? ""
301
573
  for (const part of header.split(";")) {
302
574
  const [name, ...rest] = part.trim().split("=")
303
575
  if (name !== COOKIE) continue
304
- const device = store.findDevice(rest.join("="))
576
+ const device = await deviceOfToken(rest.join("="))
305
577
  if (!device) continue
306
578
  const user = store.user(device.ownerID)
307
579
  return user && !user.disabled ? { device, user } : undefined
@@ -329,34 +601,34 @@ export function startCentral(options: CentralOptions): Central {
329
601
  result = await oidc!.callback(url.searchParams, cookieValue(request.headers.get("cookie"), OIDC_COOKIE))
330
602
  } catch (cause) {
331
603
  if (!(cause instanceof OidcError)) throw cause
332
- if (cause.audited) store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
604
+ if (cause.audited) await store.audit({ actor: "system", action: "signin.denied", detail: cause.reason })
333
605
  else console.warn(`[hub] sign-in refused: ${cause.reason}`)
334
606
  return deny(cause.status, "Sign-in failed", cause.message)
335
607
  }
336
608
  const { identity, next } = result
337
609
  if (!emailAllowed(settings, identity.email)) {
338
- store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
610
+ await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "not allowed" })
339
611
  return deny(403, "Not allowed", `${identity.email} is not allowed to use this hub. Ask its admin to add you.`)
340
612
  }
341
613
  const role = settings.admins.includes(identity.email) ? "admin" : "user"
342
614
  const existing = store.userByEmail(identity.email)
343
615
  if (existing?.disabled) {
344
- store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
616
+ await store.audit({ actor: "system", action: "signin.denied", target: identity.email, detail: "disabled" })
345
617
  return deny(403, "Account disabled", "Your account on this hub is disabled. Ask its admin.")
346
618
  }
347
- const user = store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
348
- if (user.role === "admin" && store.migrateLocal(user.id)) broadcast({ type: "catalog" })
619
+ const user = await store.upsertUser({ email: identity.email, ...(identity.name ? { name: identity.name } : {}), role })
620
+ if (user.role === "admin" && await store.migrateLocal(user.id)) broadcast({ type: "catalog" })
349
621
  const headers = new Headers({ "cache-control": "no-store" })
350
622
  headers.append("set-cookie", cleared["set-cookie"])
351
- const current = deviceFor(request)
623
+ const current = await deviceFor(request)
352
624
  let deviceID: string
353
625
  if (current && current.user.id === user.id) deviceID = current.device.id
354
626
  else {
355
- const { device, token } = store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
627
+ const { device, token } = await store.addDevice(deviceName(request.headers.get("user-agent")), user.id)
356
628
  deviceID = device.id
357
629
  headers.append("set-cookie", cookie(token))
358
630
  }
359
- store.audit({ actor: user.id, action: "signin", target: deviceID })
631
+ await store.audit({ actor: user.id, action: "signin", target: deviceID })
360
632
  // Speech not usable yet (e.g. no key): land on the page's speech setup (the console opens Settings → Speech for #speech).
361
633
  const setup = !next.includes("#") && speechSettings.view(speechScope(user.id)).ready.reason ? "#speech" : ""
362
634
  headers.set("location", `${next}${setup}`)
@@ -376,17 +648,299 @@ export function startCentral(options: CentralOptions): Central {
376
648
  }
377
649
  }
378
650
 
651
+ /** A push to the user about one session (titled by it; "" opens the console), whatever device shows it. */
652
+ const pushUser = (kind: "decision" | "message", userID: string, sessionID: string, key: string, decisionID?: string) => {
653
+ const title = (sessionID && registry.status(sessionID)?.find((s) => s.id === sessionID)?.title) || "Hub"
654
+ notifier.notify(kind, sessionID, title, undefined, userID, { key, ...(decisionID ? { decisionID } : {}) }).catch((cause) => {
655
+ console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
656
+ })
657
+ }
658
+
659
+ const directory = hubDirectory({ registry, store, now })
660
+ /** This process as owner of leases (leader loops, flushes, turns): `<pod>:<pid>-<random>`. */
661
+ const leaseOwner = `${config.cluster?.podID ?? "hub"}:${process.pid}-${randomBytes(4).toString("hex")}`
662
+ /** Every stored user (the leader loops look at all of them). */
663
+ const allUsers = async () => {
664
+ await store.ready()
665
+ return store.users().map((u) => u.id)
666
+ }
667
+ const loopTickMs = options.timing?.loopTickMs
668
+ // Timed jobs run on one pod at a time, under a fenced leader lease (design §9; a single hub always leads).
669
+ const leaders: LeaderLoop[] = []
670
+ const leader = (name: string, handlers: Pick<ConstructorParameters<typeof LeaderLoop>[0], "onElected" | "onDeposed" | "onKick" | "onRequest">) => {
671
+ const loop = new LeaderLoop({
672
+ locks: backends.locks,
673
+ bus: backends.bus,
674
+ name,
675
+ owner: leaseOwner,
676
+ ...(options.timing?.leaderTtlMs !== undefined ? { ttlMs: options.timing.leaderTtlMs } : {}),
677
+ ...handlers,
678
+ })
679
+ leaders.push(loop)
680
+ return loop
681
+ }
682
+ // Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
683
+ // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
684
+ const delivery = new Delivery({
685
+ state: backends.state,
686
+ counters: backends.counters,
687
+ registry,
688
+ directory,
689
+ send: (connID, req) => spokes.request(connID, req),
690
+ now,
691
+ describe: (userID, sessionID) => {
692
+ try {
693
+ const entry = directory.resolve(userID, sessionID).sessions[0]
694
+ const machine = entry?.machineID ? store.machines().find((m) => m.id === entry.machineID)?.name : undefined
695
+ return { ...(entry?.name ? { name: entry.name } : {}), ...(machine ? { machine } : {}) }
696
+ } catch {
697
+ return {}
698
+ }
699
+ },
700
+ // Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
701
+ alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
702
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
703
+ // Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
704
+ ...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
705
+ })
706
+ /** Message events (watch, waiters), shared by the agent routes and the console's Decision answers. */
707
+ const feed = new MessageFeed()
708
+ // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
709
+ if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
710
+ // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
711
+ const decisions = new Decisions({
712
+ state: backends.state,
713
+ delivery,
714
+ now,
715
+ users: () => store.users().map((u) => u.id),
716
+ notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
717
+ // The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
718
+ changed: (userID, decision) => broadcast({ type: "decision", decision }, { tenant: userID }),
719
+ // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user
720
+ // (both are set up below).
721
+ act: (userID, decision, choice, by) => (decision.action?.kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)),
722
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
723
+ })
724
+ leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
725
+ /** The user prompted the session: its message hop chain starts over (never fails the prompt). */
726
+ const humanTurn = async (sessionID: string): Promise<void> => {
727
+ const tenant = registry.sessionTenant(sessionID)
728
+ if (tenant === undefined) return
729
+ await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
730
+ }
731
+ registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
732
+ registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
733
+ /** Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. */
734
+ const actions = createActions({
735
+ registry,
736
+ spokes,
737
+ humanTurn,
738
+ kv: backends.kv,
739
+ // Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
740
+ ...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
741
+ })
742
+ leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
743
+ // Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
744
+ // service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
745
+ // console streams get it as a 'launch' event.
746
+ const launchFeed = new LaunchFeed()
747
+ // Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
748
+ // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
749
+ if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
750
+ launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
751
+ const launches = new LaunchEngine({
752
+ state: backends.state,
753
+ counters: backends.counters,
754
+ locks: backends.locks,
755
+ owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
756
+ ...(config.launchCaps ? { caps: config.launchCaps } : {}),
757
+ nodes,
758
+ nodeOps: spokes,
759
+ registry,
760
+ spokes,
761
+ directory,
762
+ delivery,
763
+ decisions,
764
+ actions,
765
+ // openshell launches: the node starts the sandbox's harness with an hlt1 token and the hub's public URL.
766
+ launchTokens,
767
+ hubURL: publicURL,
768
+ users: () => store.users().map((u) => u.id),
769
+ now,
770
+ onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
771
+ // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
772
+ ...(config.cluster
773
+ ? {
774
+ supervisor: {
775
+ kick: (userID: string) => void launchLeader.kick({ userID }).catch((cause) => console.error(`[hub] launch kick failed: ${(cause as Error).message}`)),
776
+ stop: async (userID: string, id: string, reason: string) => {
777
+ let reply: { launch?: Launch; error?: { code: AgentErrorCode; message: string } }
778
+ try {
779
+ reply = await launchLeader.request({ op: "stop", userID, id, reason }, { timeoutMs: LAUNCH_STOP_TIMEOUT_MS })
780
+ } catch (cause) {
781
+ if (cause instanceof NoLeaderError) return undefined
782
+ throw cause
783
+ }
784
+ if (reply.error) throw new AgentError(reply.error.code, reply.error.message)
785
+ return reply.launch
786
+ },
787
+ },
788
+ }
789
+ : {}),
790
+ })
791
+ // A launch-bound connection lives only while its launch is provisioning or running: it is closed (and refused from
792
+ // then on) when the launch ends. Not on queued: that would refuse the sandbox's spoke before it ever connects.
793
+ launchFeed.subscribe((_userID, launch) => {
794
+ if (launch.state === "ended") spokes.closeLaunch(launch.id)
795
+ }, { origin: true })
796
+ const launchLeader = leader("launches", {
797
+ onElected: () => launches.start().catch((cause) => console.error(`[hub] launches did not start: ${(cause as Error).message}`)),
798
+ onDeposed: () => launches.shutdown(),
799
+ onKick: (message) => {
800
+ const userID = (message as { userID?: unknown } | undefined)?.userID
801
+ if (typeof userID === "string") launches.kick(userID)
802
+ },
803
+ onRequest: async (message) => {
804
+ const { op, userID, id, reason } = (message ?? {}) as { op?: unknown; userID?: unknown; id?: unknown; reason?: unknown }
805
+ if (op !== "stop" || typeof userID !== "string" || typeof id !== "string" || typeof reason !== "string") throw new Error("Invalid launch supervisor request")
806
+ try {
807
+ return { launch: await launches.stop(userID, id, reason) }
808
+ } catch (cause) {
809
+ if (cause instanceof AgentError) return { error: { code: cause.code, message: cause.message } }
810
+ throw cause
811
+ }
812
+ },
813
+ })
814
+ // Webhooks (P4-T10): launch changes go to the owner's matching registrations through the durable outbox; URLs
815
+ // are checked again at every send; private targets only with hub.json agents.webhooks.allowPrivate.
816
+ const webhooks = new Webhooks({
817
+ state: backends.state,
818
+ locks: backends.locks,
819
+ now,
820
+ source: publicURL,
821
+ users: () => store.users().map((u) => u.id),
822
+ resolve: options.resolveHost ?? systemResolveHost,
823
+ ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
824
+ appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
825
+ userAgent: `hub/${options.version ?? "0.0.0"}`,
826
+ })
827
+ launchFeed.subscribe((userID, launch) => {
828
+ void webhooks
829
+ .onLaunch(userID, launch, launchInfo(launch))
830
+ // Queued here, sent by the webhooks leader (kicked when that is another pod).
831
+ .then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
832
+ .catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
833
+ }, { origin: true })
834
+ const webhookLeader = leader("webhooks", {
835
+ onElected: () => webhooks.start(),
836
+ onDeposed: () => webhooks.stop(),
837
+ onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
838
+ })
839
+ for (const loop of leaders) void loop.start()
840
+
841
+ // One credential resolver for every /api request: device cookie, device token, session and app tokens.
842
+ // Every stored agent message also goes to the user's console streams (live transcript rows).
843
+ feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
844
+ const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
845
+ const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
846
+ const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
847
+ /** An agent as envelopes name it (a session by its directory name). */
848
+ const describeActor = (userID: string, actor: AgentActor) => {
849
+ if (actor.kind !== "session") return partyFromActor(actor)
850
+ try {
851
+ const name = directory.resolve(userID, actor.sessionID).sessions[0]?.name
852
+ return partyFromActor(actor, name ? { name } : {})
853
+ } catch {
854
+ return partyFromActor(actor)
855
+ }
856
+ }
857
+ // needs_user escalations (P3-T8): one Decision per (holder, request); answered as the user; closed when the
858
+ // request goes elsewhere.
859
+ // Cluster mode: a recommendation on a request another pod holds goes to that pod.
860
+ const recommendations = config.cluster ? new ClusterRecommendations({ bus: backends.bus, podID: config.cluster.podID, registry }) : undefined
861
+ recommendations?.start().catch((cause) => console.error(`[hub] cluster recommendations did not start: ${(cause as Error).message}`))
862
+ const escalations: Escalations = new Escalations({
863
+ state: backends.state,
864
+ counters: backends.counters,
865
+ decisions,
866
+ registry: {
867
+ sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
868
+ recommend: (requestID, recommendation) => (recommendations ?? registry).recommend(requestID, recommendation),
869
+ },
870
+ actions,
871
+ audit,
872
+ now,
873
+ describe: describeActor,
874
+ published: (userID, message) => feed.publish(userID, message),
875
+ })
876
+ // Grant requests (P3-T9): an agent asks; the user's tap creates the grant as the user.
877
+ const grantRequests: GrantRequests = new GrantRequests({
878
+ state: backends.state,
879
+ counters: backends.counters,
880
+ decisions,
881
+ grants,
882
+ directory,
883
+ audit,
884
+ now,
885
+ describe: describeActor,
886
+ published: (userID, message) => feed.publish(userID, message),
887
+ })
888
+ registry.onPermissionClosed((requestID, _sessionID, tenant) => {
889
+ void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
890
+ })
891
+ const escalate: Escalate = (escalation) => escalations.escalate(escalation)
892
+ /** Cluster mode: what GET /api/agent/watch reads; status of sessions on any pod comes from the user's live events. */
893
+ const watchRegistry = live
894
+ ? (userID: string): WatchRegistry => ({
895
+ catalog: () => registry.catalog(),
896
+ sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
897
+ subscribe: (listener) => {
898
+ const offCatalog = registry.subscribe((message) => {
899
+ if (message.type === "catalog") listener(message)
900
+ })
901
+ const subscription = live.subscribe(userID, (event) => {
902
+ if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
903
+ })
904
+ return () => {
905
+ offCatalog()
906
+ subscription.close()
907
+ }
908
+ },
909
+ })
910
+ : undefined
911
+ const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
912
+ const resolveActor = createResolver({
913
+ cookieName: COOKIE,
914
+ findDevice: (token) => deviceOfToken(token),
915
+ user: (id) => store.user(id),
916
+ verifiers: [...agentVerifiers(agentDeps), ...(options.agent?.verifiers ?? [])],
917
+ })
918
+ const agent = agentApi({
919
+ resolve: resolveActor,
920
+ cookieName: COOKIE,
921
+ sameOrigin,
922
+ touch: (user, device) => {
923
+ // Never rejects (lastSeen only).
924
+ if (device) void store.touchDevice(device.id)
925
+ void store.touchUser(user.id)
926
+ },
927
+ routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
928
+ })
929
+
379
930
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
380
931
  const url = new URL(request.url)
381
932
  const { pathname } = url
382
933
  const method = request.method
383
934
  try {
935
+ if (method === "GET" && pathname === "/readyz") return readyz()
384
936
  if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
385
937
  if (method === "GET" && pathname === "/healthz") {
386
- return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections() })
938
+ return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
387
939
  }
940
+ // A shared (cluster) store loads its records on first use; the file store is ready at once.
941
+ await store.ready()
388
942
  if (method === "POST" && pathname === "/spoke/enroll") {
389
- const start = auth.startEnrollment((await body(request)).machineName)
943
+ const start = await auth.startEnrollment((await body(request)).machineName)
390
944
  const auto = config.auth.mode === "pairing" && localAutoApprove({
391
945
  host: config.host,
392
946
  publicURL,
@@ -396,7 +950,7 @@ export function startCentral(options: CentralOptions): Central {
396
950
  devices: store.devices().length,
397
951
  used: autoApproved,
398
952
  })
399
- if (auto && auth.approveEnrollment(start.userCode)) {
953
+ if (auto && (await auth.approveEnrollment(start.userCode))) {
400
954
  autoApproved = true
401
955
  console.log(`[hub] local hub: approved the first machine (code ${start.userCode}) without confirmation; later machines need approval`)
402
956
  return Response.json({ ...start, autoApproved: true } satisfies EnrollStart)
@@ -405,25 +959,29 @@ export function startCentral(options: CentralOptions): Central {
405
959
  return Response.json(start)
406
960
  }
407
961
  if (method === "POST" && pathname === "/spoke/enroll/poll") {
408
- const poll = auth.pollEnrollment((await body(request)).deviceCode)
962
+ const poll = await auth.pollEnrollment((await body(request)).deviceCode)
409
963
  if (poll.status === "approved") broadcast({ type: "catalog" })
410
964
  return Response.json(poll)
411
965
  }
966
+ if (method === "GET" && pathname === "/.well-known/hub") {
967
+ return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
968
+ }
412
969
  if (method === "GET" && pathname === "/login") return login(request, url)
413
970
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
414
971
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
972
+ if (isAgentPath(pathname)) return await agent.handle(request, url)
415
973
  if (pathname.startsWith("/api/")) return await api(request, server, url)
416
- if (method === "GET") return staticFile(pathname)
974
+ if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
417
975
  return error(404, "Not found")
418
976
  } catch (cause) {
419
977
  return failure(cause)
420
978
  }
421
979
  }
422
980
 
423
- function login(request: Request, url: URL): Response {
981
+ async function login(request: Request, url: URL): Promise<Response> {
424
982
  const next = url.searchParams.get("next") ?? "/"
425
983
  if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
426
- const ownerID = auth.redeemLogin(url.searchParams.get("code"))
984
+ const ownerID = await auth.redeemLogin(url.searchParams.get("code"))
427
985
  if (!ownerID) {
428
986
  return new Response("This sign-in link has expired or was already used. Run /hub again.", {
429
987
  status: 401,
@@ -434,9 +992,9 @@ export function startCentral(options: CentralOptions): Central {
434
992
  const setup = speechSettings.view(speechScope(ownerID)).ready.reason ? "#speech" : ""
435
993
  const headers = new Headers({ location: `${next}${setup}`, "cache-control": "no-store" })
436
994
  // A link signs in as the machine's owner: a browser signed in as someone else gets a new device.
437
- if (deviceFor(request)?.user.id !== ownerID) {
438
- const { device, token } = store.addDevice("Browser (sign-in link)", ownerID)
439
- store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
995
+ if ((await deviceFor(request))?.user.id !== ownerID) {
996
+ const { device, token } = await store.addDevice("Browser (sign-in link)", ownerID)
997
+ await store.audit({ actor: ownerID, action: "device.pair", target: device.id, detail: "login link" })
440
998
  headers.set("set-cookie", cookie(token))
441
999
  }
442
1000
  return new Response(null, { status: 302, headers })
@@ -447,6 +1005,11 @@ export function startCentral(options: CentralOptions): Central {
447
1005
  const ownMachine = (userID: string, machineID: string) =>
448
1006
  store.machines().find((m) => m.id === machineID && !m.revoked && m.ownerID === userID)
449
1007
 
1008
+ /** Session routes that also answer for the user's offline sessions the directory knows (hub-side data only). */
1009
+ const OFFLINE_SESSION_ROUTES = new Set(["/api/sessions/:session/messages"])
1010
+ /** No spoke reports the session (for anyone) and the user's directory has a record of it (not deleted). */
1011
+ const knownOffline = (userID: string, sessionID: string) =>
1012
+ registry.sessionTenant(sessionID) === undefined && (store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt === undefined)
450
1013
  const route = (method: string, path: string, access: Access, handle: Handler, exposed = false): Route => {
451
1014
  const keys: string[] = []
452
1015
  const pattern = new RegExp(`^${path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
@@ -460,11 +1023,19 @@ export function startCentral(options: CentralOptions): Central {
460
1023
  ),
461
1024
  route("POST", "/api/pair", "public", async ({ request }) => {
462
1025
  const input = await body(request)
463
- const paired = auth.pair(input.code, input.deviceName)
1026
+ const paired = await auth.pair(input.code, input.deviceName)
464
1027
  if (!paired) return error(401, "Invalid or expired pairing code")
465
- store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
1028
+ await store.audit({ actor: paired.device.ownerID, action: "device.pair", target: paired.device.id })
466
1029
  return Response.json({ device: deviceView(paired.device) }, { headers: { "set-cookie": cookie(paired.token) } })
467
1030
  }),
1031
+ // A native app trades a link code for a device token (kept by the app, sent as Authorization: Bearer): no cookie.
1032
+ route("POST", "/api/devices/redeem", "public", async ({ request }) => {
1033
+ const input = await body(request)
1034
+ const linked = await auth.redeemLink(input.code, input.deviceName, input.publicKey)
1035
+ if (!linked) return error(401, "Invalid or expired link code")
1036
+ await store.audit({ actor: linked.device.ownerID, action: "device.pair", target: linked.device.id, detail: "app link" })
1037
+ return Response.json({ device: deviceView(linked.device), token: linked.token }, { headers: NO_STORE })
1038
+ }),
468
1039
 
469
1040
  // any signed-in user, own data only
470
1041
  route("GET", "/api/me", "user", ({ device, user }) =>
@@ -477,18 +1048,21 @@ export function startCentral(options: CentralOptions): Central {
477
1048
  }),
478
1049
  ),
479
1050
  route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
480
- route("GET", "/api/events", "user", ({ request, server, user, device }) => {
1051
+ route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
481
1052
  server.timeout(request, 0)
482
- return events(request.signal, user.id, device.id)
1053
+ const session = url.searchParams.get("session")
1054
+ return events(request.signal, user.id, device.id, {
1055
+ lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
1056
+ lite: url.searchParams.get("lite") === "1",
1057
+ ...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
1058
+ })
483
1059
  }),
484
1060
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
485
1061
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
486
1062
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
487
- const prefs = store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1063
+ const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
488
1064
  // The user's other devices only: never another user, and not the device that made the change.
489
- for (const listener of [...listeners]) {
490
- if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "prefs", prefs })
491
- }
1065
+ toUser(user.id, { type: "prefs", prefs }, device.id)
492
1066
  return Response.json({ prefs }, { headers: NO_STORE })
493
1067
  }),
494
1068
  route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
@@ -502,23 +1076,33 @@ export function startCentral(options: CentralOptions): Central {
502
1076
  .map((d) => ({ ...deviceView(d), current: d.id === device.id })),
503
1077
  }),
504
1078
  ),
505
- route("DELETE", "/api/devices/:id", "user", ({ params, user }) => {
1079
+ // A link code for a native app, shown as a QR code / deep link in Settings → Devices → Add app.
1080
+ route("POST", "/api/devices/link", "user", async ({ user }) => {
1081
+ const { code, expiresAt } = auth.issueLinkCode(user.id)
1082
+ await store.audit({ actor: user.id, action: "device.link" })
1083
+ const url = `${APP_SCHEME}://link?hub=${encodeURIComponent(publicURL)}&code=${code}`
1084
+ return Response.json({ code, expiresAt, hub: publicURL, url }, { headers: NO_STORE })
1085
+ }),
1086
+ route("DELETE", "/api/devices/:id", "user", async ({ params, user }) => {
506
1087
  const target = store.devices().find((d) => d.id === params.id && d.ownerID === user.id)
507
- if (!target || !store.removeDevice(target.id)) return error(404, "No such device")
508
- store.audit({ actor: user.id, action: "device.revoke", target: target.id })
1088
+ if (!target || !await store.removeDevice(target.id)) return error(404, "No such device")
1089
+ await store.audit({ actor: user.id, action: "device.revoke", target: target.id })
509
1090
  return new Response(null, { status: 204 })
510
1091
  }),
511
- route("POST", "/api/logout", "user", ({ device }) => {
512
- store.removeDevice(device.id)
1092
+ route("POST", "/api/logout", "user", async ({ device }) => {
1093
+ await store.removeDevice(device.id)
513
1094
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
514
1095
  }),
515
1096
  route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
516
1097
  route("POST", "/api/speak", "user", async ({ request, user }) => {
517
1098
  const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
518
1099
  if (!text) return error(400, "Nothing to speak")
519
- return new Response(await speechFor(user.id).speak(text), { headers: { "content-type": "audio/mpeg" } })
1100
+ const speech = speechFor(user.id)
1101
+ // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1102
+ const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
1103
+ return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
520
1104
  }),
521
- route("GET", "/api/push/key", "user", () => Response.json({ publicKey: push.publicKey() })),
1105
+ route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
522
1106
  route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
523
1107
  const input = await body(request)
524
1108
  const target = pushTarget(input.subscription)
@@ -526,14 +1110,14 @@ export function startCentral(options: CentralOptions): Central {
526
1110
  const problem = await pushEndpointProblem(target.endpoint, options.resolveHost ?? systemResolveHost)
527
1111
  if (problem) return error(400, `Invalid push subscription: ${problem}`)
528
1112
  // pushsubscriptionchange: the service worker sends the endpoint it replaces.
529
- if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) store.removePushSubscription(device.id, input.oldEndpoint)
530
- store.addPushSubscription(device.id, target)
1113
+ if (typeof input.oldEndpoint === "string" && input.oldEndpoint !== target.endpoint) await store.removePushSubscription(device.id, input.oldEndpoint)
1114
+ await store.addPushSubscription(device.id, target)
531
1115
  const prefs = prefsInput(input.prefs)
532
- return Response.json({ prefs: prefs ? store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
1116
+ return Response.json({ prefs: prefs ? await store.setPushPrefs(device.id, prefs) : store.pushPrefs(device.id) })
533
1117
  }),
534
1118
  route("POST", "/api/push/unsubscribe", "user", async ({ request, device }) => {
535
1119
  const endpoint = (await body(request)).endpoint
536
- if (typeof endpoint !== "string" || !store.removePushSubscription(device.id, endpoint)) {
1120
+ if (typeof endpoint !== "string" || !await store.removePushSubscription(device.id, endpoint)) {
537
1121
  return error(404, "No such subscription on this device")
538
1122
  }
539
1123
  return new Response(null, { status: 204 })
@@ -544,10 +1128,17 @@ export function startCentral(options: CentralOptions): Central {
544
1128
  if ("quietHours" in input) {
545
1129
  const quiet = input.quietHours === null ? null : quietHoursInput(input.quietHours)
546
1130
  if (quiet === undefined) return error(400, "quietHours must be {start: \"HH:MM\", end: \"HH:MM\", timeZone} or null")
547
- store.setQuietHours(device.id, quiet)
1131
+ await store.setQuietHours(device.id, quiet)
1132
+ }
1133
+ if ("activityMinutes" in input) {
1134
+ const minutes = input.activityMinutes
1135
+ if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < ACTIVITY_INTERVAL_MIN || minutes > ACTIVITY_INTERVAL_MAX) {
1136
+ return error(400, `activityMinutes must be a whole number of minutes from ${ACTIVITY_INTERVAL_MIN} to ${ACTIVITY_INTERVAL_MAX}`)
1137
+ }
1138
+ await store.setActivityMinutes(device.id, minutes)
548
1139
  }
549
1140
  const prefs = prefsInput(input)
550
- if (prefs) store.setPushPrefs(device.id, prefs)
1141
+ if (prefs) await store.setPushPrefs(device.id, prefs)
551
1142
  return pushPrefsView(device.id, user.id)
552
1143
  }),
553
1144
  route("PUT", "/api/push/mute", "user", async ({ request, user }) => {
@@ -556,12 +1147,15 @@ export function startCentral(options: CentralOptions): Central {
556
1147
  if (typeof sessionID !== "string" || !sessionID || sessionID.length > 200 || typeof input.muted !== "boolean") {
557
1148
  return error(400, "Expected {sessionID, muted}")
558
1149
  }
559
- return Response.json({ muted: store.setSessionMuted(user.id, sessionID, input.muted) })
1150
+ return Response.json({ muted: await store.setSessionMuted(user.id, sessionID, input.muted) })
560
1151
  }),
561
- route("POST", "/api/push/test", "user", async ({ device }) => {
1152
+ route("POST", "/api/push/test", "user", async ({ request, device }) => {
1153
+ // The body is optional (older clients send none): {kind?} picks what the test looks like.
1154
+ const kind = (await body(request).catch(() => ({}) as Record<string, unknown>)).kind
1155
+ if (kind !== undefined && !isNotificationKind(kind)) return error(400, `kind must be one of ${PUSH_PREF_KEYS.join(", ")}`)
562
1156
  const subscriptions = store.pushSubscriptions().filter((s) => s.deviceID === device.id).length
563
1157
  if (subscriptions === 0) return Response.json({ sent: 0, failed: [], subscriptions })
564
- const result = await push.test(device.id)
1158
+ const result = await push.test(device.id, kind)
565
1159
  if ("limited" in result) {
566
1160
  return Response.json({ error: "Wait a few seconds before sending another test" }, { status: 429, headers: { "retry-after": String(result.retryAfter) } })
567
1161
  }
@@ -571,7 +1165,7 @@ export function startCentral(options: CentralOptions): Central {
571
1165
  route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
572
1166
  // Raw JSON: anything but an object is refused by the validator.
573
1167
  route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
574
- Response.json(speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
1168
+ Response.json(await speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
575
1169
  ),
576
1170
  route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
577
1171
  const kind = url.searchParams.get("kind")
@@ -605,16 +1199,18 @@ export function startCentral(options: CentralOptions): Central {
605
1199
  "/api/sessions/:session/interrupt",
606
1200
  "session",
607
1201
  async ({ params }) => {
608
- await spokes.request(ownerOf(params.session!), { op: "interrupt", sessionID: params.session! })
1202
+ await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
609
1203
  registry.cancelTurns(params.session!)
610
1204
  return new Response(null, { status: 204 })
611
1205
  },
612
1206
  ),
613
1207
  route("POST", "/api/sessions/:session/stop", "session", async ({ params }) => {
614
- const result = await spokes.request(ownerOf(params.session!), { op: "stop", sessionID: params.session! })
615
- registry.cancelTurns(params.session!)
616
- return Response.json(result, { headers: NO_STORE })
1208
+ const stopped = await actions.stop(params.session!)
1209
+ if (stopped.outcome === "offline") throw new HttpError(409, NO_OWNER)
1210
+ if (stopped.outcome === "unsupported") throw unsupportedError(stopped)
1211
+ return Response.json(stopped.result, { headers: NO_STORE })
617
1212
  }),
1213
+ route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
618
1214
  route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
619
1215
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
620
1216
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
@@ -632,7 +1228,7 @@ export function startCentral(options: CentralOptions): Central {
632
1228
  const change = modelPrefsInput(await request.json())
633
1229
  await checkModelSelections(ownerOf(params.session!), params.session!, change)
634
1230
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
635
- const prefs = store.setModelPrefs(user.id, params.session!, change)
1231
+ const prefs = await store.setModelPrefs(user.id, params.session!, change)
636
1232
  return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
637
1233
  }),
638
1234
  route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
@@ -641,7 +1237,35 @@ export function startCentral(options: CentralOptions): Central {
641
1237
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
642
1238
  const cursor = url.searchParams.get("cursor") ?? undefined
643
1239
  if (cursor && cursor.length > 4096) return error(400, "cursor is too long")
644
- return Response.json(await spokes.request(ownerOf(params.session!), { op: "timeline", sessionID: params.session!, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) }), { headers: NO_STORE })
1240
+ // Newest-first mode: order=desc for the newest page, before=<older cursor> for the page before it.
1241
+ const order = url.searchParams.get("order") ?? undefined
1242
+ if (order !== undefined && order !== "asc" && order !== "desc") return error(400, "order must be asc or desc")
1243
+ const before = url.searchParams.get("before") || undefined
1244
+ if (before && before.length > 4096) return error(400, "before is too long")
1245
+ if (cursor && (before || order === "desc")) return error(400, "cursor cannot be combined with before or order=desc")
1246
+ try {
1247
+ const page = await spokes.request(ownerOf(params.session!), {
1248
+ op: "timeline",
1249
+ sessionID: params.session!,
1250
+ ...(limit ? { limit } : {}),
1251
+ ...(cursor ? { cursor } : {}),
1252
+ ...(order ? { order } : {}),
1253
+ ...(before ? { before } : {}),
1254
+ })
1255
+ return Response.json(page, { headers: NO_STORE })
1256
+ } catch (cause) {
1257
+ // The row the cursor names is gone (history reverted or compacted): the console starts over.
1258
+ if (cause instanceof SpokeError && cause.message.includes("Invalid timeline cursor")) return error(400, "Invalid timeline cursor")
1259
+ throw cause
1260
+ }
1261
+ }),
1262
+ // Agent messages the session sent or received (newest first), merged into the timeline by `at` (+ watch events).
1263
+ route("GET", "/api/sessions/:session/messages", "session", async ({ params, url, user }) => {
1264
+ const raw = url.searchParams.get("limit")
1265
+ const limit = raw === null ? undefined : Number(raw)
1266
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
1267
+ const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
1268
+ return Response.json({ messages }, { headers: NO_STORE })
645
1269
  }),
646
1270
  route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
647
1271
  Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
@@ -662,11 +1286,9 @@ export function startCentral(options: CentralOptions): Central {
662
1286
  const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
663
1287
  return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
664
1288
  }),
665
- route("POST", "/api/sessions/:session/read", "session", ({ params, user, device }) => {
666
- const reads = store.markRead(user.id, params.session!, Date.now())
667
- for (const listener of [...listeners]) {
668
- if (listener.userID === user.id && listener.deviceID !== device.id) listener.send({ type: "reads", reads })
669
- }
1289
+ route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
1290
+ const reads = await store.markRead(user.id, params.session!, Date.now())
1291
+ toUser(user.id, { type: "reads", reads }, device.id)
670
1292
  return Response.json({ reads }, { headers: NO_STORE })
671
1293
  }),
672
1294
 
@@ -675,7 +1297,7 @@ export function startCentral(options: CentralOptions): Central {
675
1297
  route("DELETE", "/api/machines/:machine", "machine", ({ params, user }) => revokeMachine(params.machine!, user)),
676
1298
 
677
1299
  // admins: users, machines, devices, audit, enrollments, the server speech default
678
- route("GET", "/api/enrollments", "admin", () => Response.json({ enrollments: auth.pendingEnrollments() })),
1300
+ route("GET", "/api/enrollments", "admin", async () => Response.json({ enrollments: await auth.pendingEnrollments() })),
679
1301
  route("GET", "/api/admin/users", "admin", () => Response.json(store.users().map(userRow))),
680
1302
  route("PUT", "/api/admin/users/:id", "admin", async ({ request, params, user }) => updateUser(params.id!, await body(request), user)),
681
1303
  route("GET", "/api/admin/machines", "admin", () => {
@@ -697,16 +1319,49 @@ export function startCentral(options: CentralOptions): Central {
697
1319
  )
698
1320
  }),
699
1321
  route("DELETE", "/api/admin/machines/:id", "admin", ({ params, user }) => revokeMachine(params.id!, user)),
700
- route("DELETE", "/api/admin/devices/:id", "admin", ({ params, user }) => {
701
- if (!store.removeDevice(params.id!)) return error(404, "No such device")
702
- store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
1322
+ route("DELETE", "/api/admin/devices/:id", "admin", async ({ params, user }) => {
1323
+ if (!await store.removeDevice(params.id!)) return error(404, "No such device")
1324
+ await store.audit({ actor: user.id, action: "device.revoke", target: params.id! })
703
1325
  return new Response(null, { status: 204 })
704
1326
  }),
705
1327
  route("GET", "/api/admin/audit", "admin", () => Response.json(store.auditLog())),
1328
+ // The hub-wide agent kill switch: pauses every agent action for every user (reading and messaging still work).
1329
+ route("GET", "/api/admin/agents/acting", "admin", () => Response.json({ enabled: !store.actingPaused() }, { headers: NO_STORE })),
1330
+ route("PUT", "/api/admin/agents/acting", "admin", async ({ request, user }) => {
1331
+ const enabled = (await body(request)).enabled
1332
+ if (typeof enabled !== "boolean") return error(400, "enabled must be true or false")
1333
+ await store.setActingPaused(!enabled)
1334
+ await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
1335
+ return Response.json({ enabled }, { headers: NO_STORE })
1336
+ }),
1337
+ // Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
1338
+ route("GET", "/api/decisions", "user", async ({ url, user }) => {
1339
+ try {
1340
+ return Response.json({ decisions: await decisions.list(user.id, { ...optionalState(url) }) }, { headers: NO_STORE })
1341
+ } catch (cause) {
1342
+ return decisionFailure(cause)
1343
+ }
1344
+ }),
1345
+ route("GET", "/api/decisions/:id", "user", async ({ params, user }) => {
1346
+ const decision = await decisions.get(user.id, params.id!)
1347
+ return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
1348
+ }),
1349
+ route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
1350
+ const input = await body(request)
1351
+ const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
1352
+ try {
1353
+ const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
1354
+ if (answered.reply) feed.publish(user.id, answered.reply)
1355
+ await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer?.option ?? "text" })
1356
+ return Response.json(answered, { headers: NO_STORE })
1357
+ } catch (cause) {
1358
+ return decisionFailure(cause)
1359
+ }
1360
+ }),
706
1361
  route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
707
1362
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
708
- const view = speechSettings.update(await request.json())
709
- store.audit({ actor: user.id, action: "speech.default" })
1363
+ const view = await speechSettings.update(await request.json())
1364
+ await store.audit({ actor: user.id, action: "speech.default" })
710
1365
  return Response.json(view, { headers: NO_STORE })
711
1366
  }),
712
1367
  ]
@@ -725,7 +1380,11 @@ export function startCentral(options: CentralOptions): Central {
725
1380
 
726
1381
  async function api(request: Request, server: Server<SpokeSocketData>, url: URL): Promise<Response> {
727
1382
  const method = request.method
728
- if (method !== "GET" && method !== "HEAD" && !sameOrigin(request)) {
1383
+ const mode = await resolveActor.mode(request)
1384
+ if (mode === "mixed") return error(400, "Send the device cookie or a bearer token, not both", "invalid")
1385
+ // Bearer requests (device tokens of native apps) are not CSRF-prone; cookie writes must be same-origin (a
1386
+ // foreign bearer next to the cookie, e.g. from an auth proxy, is ignored: still a cookie request).
1387
+ if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !sameOrigin(request) && !credentialFree(request, mode, url)) {
729
1388
  return error(403, "Cross-origin request refused")
730
1389
  }
731
1390
  const found = findRoute(method, url.pathname)
@@ -734,11 +1393,16 @@ export function startCentral(options: CentralOptions): Central {
734
1393
  return await found.route.handle({ request, server, url, params: found.params } as Context)
735
1394
  }
736
1395
 
737
- const resolved = deviceFor(request)
738
- if (!resolved) return error(401, "This device is not paired")
1396
+ const resolved = await resolveActor(request)
1397
+ if (!resolved.ok) {
1398
+ if (resolved.reason === "expired") return error(401, "Token expired", "token_expired")
1399
+ return resolved.bearer ? error(401, "Invalid or revoked token", "unauthorized") : error(401, "This device is not paired")
1400
+ }
1401
+ // Console routes are for the user's devices (cookie or device token) only, never session or app tokens.
1402
+ if (resolved.actor.kind !== "user" || !resolved.device) return error(403, "This token cannot be used here", "forbidden")
739
1403
  const { device, user } = resolved
740
- store.touchDevice(device.id)
741
- store.touchUser(user.id)
1404
+ void store.touchDevice(device.id)
1405
+ void store.touchUser(user.id)
742
1406
  if (!found) return error(404, "Not found")
743
1407
 
744
1408
  const { route: matched, params } = found
@@ -748,7 +1412,7 @@ export function startCentral(options: CentralOptions): Central {
748
1412
  break
749
1413
  case "session":
750
1414
  // Foreign and unknown sessions answer alike, so IDs don't leak.
751
- if (!registry.owns(user.id, params.session!)) return error(404, "Not found")
1415
+ if (!registry.owns(user.id, params.session!) && !(OFFLINE_SESSION_ROUTES.has(matched.path) && knownOffline(user.id, params.session!))) return error(404, "Not found")
752
1416
  // Sessions listed for discovery only: nothing but enabling voice (and presence) reaches them.
753
1417
  if (matched.exposed && registry.exposed(params.session!) === false) return error(403, NOT_EXPOSED, "not_exposed")
754
1418
  break
@@ -759,17 +1423,18 @@ export function startCentral(options: CentralOptions): Central {
759
1423
  return await matched.handle({ request, server, url, params, device, user })
760
1424
  }
761
1425
 
762
- function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Response {
763
- const machineName = auth.decide(code, status, user.id)
1426
+ async function decideEnrollment(code: string, status: "approved" | "denied", user: UserRecord): Promise<Response> {
1427
+ const machineName = await auth.decide(code, status, user.id)
764
1428
  if (machineName === undefined) return error(404, "No pending enrollment with that code")
765
- store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
1429
+ await store.audit({ actor: user.id, action: status === "approved" ? "machine.approve" : "machine.deny", detail: machineName })
766
1430
  broadcast({ type: "enrollment" }, { admins: true })
767
1431
  return new Response(null, { status: 204 })
768
1432
  }
769
1433
 
770
- function revokeMachine(id: string, user: UserRecord): Response {
771
- if (!store.revokeMachine(id)) return error(404, "No such machine")
772
- store.audit({ actor: user.id, action: "machine.revoke", target: id })
1434
+ async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
1435
+ if (!await store.revokeMachine(id)) return error(404, "No such machine")
1436
+ await store.bumpMachineEpoch(id)
1437
+ await store.audit({ actor: user.id, action: "machine.revoke", target: id })
773
1438
  spokes.closeMachine(id, "revoked")
774
1439
  broadcast({ type: "catalog" })
775
1440
  return new Response(null, { status: 204 })
@@ -790,7 +1455,7 @@ export function startCentral(options: CentralOptions): Central {
790
1455
  }
791
1456
 
792
1457
  /** PUT /api/admin/users/:id {role?, disabled?}: the last active admin can be neither demoted nor disabled. */
793
- function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Response {
1458
+ async function updateUser(id: string, input: Record<string, unknown>, actor: UserRecord): Promise<Response> {
794
1459
  const target = store.user(id)
795
1460
  if (!target) return error(404, "No such user")
796
1461
  const { role, disabled } = input
@@ -802,12 +1467,13 @@ export function startCentral(options: CentralOptions): Central {
802
1467
  if (activeAdmin(target) && !activeAdmin(after) && store.users().filter(activeAdmin).length <= 1) {
803
1468
  return error(409, "Cannot demote or disable the last active admin. Make someone else an admin first.")
804
1469
  }
805
- if (role && role !== target.role) store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
1470
+ if (role && role !== target.role) await store.audit({ actor: actor.id, action: "user.role", target: id, detail: role })
806
1471
  if (disabled !== undefined && disabled !== (target.disabled === true)) {
807
- store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
1472
+ await store.audit({ actor: actor.id, action: disabled ? "user.disable" : "user.enable", target: id })
808
1473
  }
809
- store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
1474
+ await store.setUser(id, { ...(role ? { role: role as "admin" | "user" } : {}), ...(disabled !== undefined ? { disabled } : {}) })
810
1475
  if (disabled === true) {
1476
+ await store.bumpUserEpoch(id)
811
1477
  // Their machines go offline at once; their browsers get 401 from now on and lose their event streams.
812
1478
  for (const machine of store.machines()) if (machine.ownerID === id) spokes.closeMachine(machine.id, "revoked")
813
1479
  broadcast({ type: "catalog" })
@@ -829,26 +1495,30 @@ export function startCentral(options: CentralOptions): Central {
829
1495
  : undefined
830
1496
  if (!text && !images) return error(400, "Empty prompt")
831
1497
  if (text.length > MAX_PROMPT_CHARS) return error(400, `Prompt longer than ${MAX_PROMPT_CHARS} characters`)
832
- const attached = images ? { images } : {}
833
- const connID = ownerOf(sessionID)
834
- // While anything in the tree runs, typed input redirects the current work instead of queueing.
835
- if (registry.running(sessionID)) {
836
- const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text, ...attached })
837
- return Response.json({ mode: "steer", inboxID }, { status: 202 })
838
- }
839
- // A stop or interrupt releases the lock and answers this request at once, even if the spoke never does.
840
- let cancel = () => {}
841
- const stopped = new Promise<TurnResult>((resolve) => {
842
- cancel = () => resolve({ outcome: "interrupted", message: "Stopped", reply: "" })
843
- })
844
- const release = registry.beginTurn(sessionID, () => cancel())
1498
+ // A turn may run for minutes: this request never times out (a stop or interrupt still answers it at once).
845
1499
  server.timeout(request, 0)
846
- try {
847
- const voiceStyle = registry.exposed(sessionID) === true
848
- return Response.json(await Promise.race([spokes.request(connID, { op: "turn", sessionID, text, voiceStyle, ...attached }), stopped]))
849
- } finally {
850
- release()
1500
+ const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
1501
+ if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
1502
+ if (started.outcome === "unsupported") throw unsupportedError(started)
1503
+ if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
1504
+ const result = started.result
1505
+ if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
1506
+ // Asynchronous: answer quick results (and refusals) as usual; a turn still running is accepted (202) and its
1507
+ // result goes to the owner's consoles as an SSE `turn` event (the reply itself streams as deltas meanwhile).
1508
+ const early = await Promise.race([result.then((value) => ({ value }), (cause: unknown) => ({ cause })), sleep(options.timing?.turnAcceptMs ?? TURN_ACCEPT_MS)])
1509
+ if (early) {
1510
+ if ("cause" in early) throw early.cause
1511
+ return Response.json(early.value)
851
1512
  }
1513
+ const tenant = registry.sessionTenant(sessionID)
1514
+ void result.then(
1515
+ (value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
1516
+ (cause: unknown) => {
1517
+ const message = cause instanceof Error ? cause.message : String(cause)
1518
+ broadcast({ type: "turn", sessionID, ...(cause instanceof RequestTimeoutError ? { outcome: "timeout" } : { outcome: "failed", message }), reply: "" }, { tenant })
1519
+ },
1520
+ )
1521
+ return Response.json({ mode: "turn" }, { status: 202, headers: { "preference-applied": "respond-async" } })
852
1522
  }
853
1523
 
854
1524
  /** GET …/timeline/messages/:message/files/:index: one image of a user message, as bytes. */
@@ -887,10 +1557,11 @@ export function startCentral(options: CentralOptions): Central {
887
1557
  }
888
1558
  }
889
1559
 
890
- /** Connection that owns the session, or a 409. */
891
- function ownerOf(sessionID: string): string {
1560
+ /** Connection that owns the session, or a 409; also a 409 (code unsupported) when it lacks the core op `feature`. */
1561
+ function ownerOf(sessionID: string, feature?: CoreOp): string {
892
1562
  const connID = registry.owner(sessionID)
893
1563
  if (!connID) throw new HttpError(409, NO_OWNER)
1564
+ if (feature && !registry.supports(connID, feature)) throw unsupportedError({ feature, harness: registry.harnessOf(connID) ?? "unknown" })
894
1565
  return connID
895
1566
  }
896
1567
 
@@ -900,11 +1571,61 @@ export function startCentral(options: CentralOptions): Central {
900
1571
  const text = typeof raw === "string" ? raw.trim() : ""
901
1572
  if (!text) return error(400, "Empty utterance")
902
1573
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
903
- const { inboxID } = await spokes.request(ownerOf(sessionID), { op: "steer", sessionID, text })
1574
+ const connID = ownerOf(sessionID, "steer")
1575
+ await humanTurn(sessionID)
1576
+ const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
904
1577
  registry.trackUtterance(sessionID, inboxID)
905
1578
  return Response.json({ inboxID }, { status: 202 })
906
1579
  }
907
1580
 
1581
+ /** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
1582
+ async function rename(request: Request, sessionID: string): Promise<Response> {
1583
+ const json = await body(request)
1584
+ if (json.auto !== undefined) {
1585
+ if (json.auto !== true) return error(400, "Expected {auto: true} or {title}")
1586
+ return autoName(sessionID)
1587
+ }
1588
+ const raw = json.title
1589
+ const title = typeof raw === "string" ? raw.trim() : ""
1590
+ if (!title) return error(400, "Expected {title}: a non-empty title")
1591
+ if (title.length > MAX_TITLE_CHARS) return error(400, `Title must be at most ${MAX_TITLE_CHARS} characters`)
1592
+ const targets = registry.sessionTargets(sessionID)
1593
+ if (!targets.length) throw new HttpError(409, NO_OWNER)
1594
+ let failure: unknown
1595
+ for (const connID of targets) {
1596
+ try {
1597
+ const result = await spokes.request(connID, { op: "rename", sessionID, title })
1598
+ return Response.json({ title: typeof result?.title === "string" ? result.title : title }, { headers: NO_STORE })
1599
+ } catch (cause) {
1600
+ failure ??= cause
1601
+ }
1602
+ }
1603
+ throw failure
1604
+ }
1605
+
1606
+ /**
1607
+ * Has OpenCode regenerate the title with its title agent, through the processes reporting the session that
1608
+ * support it (older spokes drop the op): 200 {title} or {title, unchanged}, 202 {pending} while generating.
1609
+ */
1610
+ async function autoName(sessionID: string): Promise<Response> {
1611
+ const reporters = registry.sessionTargets(sessionID)
1612
+ if (!reporters.length) throw new HttpError(409, NO_OWNER)
1613
+ const targets = reporters.filter((connID) => registry.supports(connID, "autoName"))
1614
+ if (!targets.length) throw new HttpError(409, AUTO_NAME_OUTDATED, "plugin_outdated")
1615
+ let failure: unknown
1616
+ for (const connID of targets) {
1617
+ try {
1618
+ const result = await spokes.request(connID, { op: "autoName", sessionID })
1619
+ if (result?.pending) return Response.json({ pending: true }, { status: 202, headers: NO_STORE })
1620
+ if (typeof result?.title !== "string") throw new SpokeError("OpenCode did not report a title")
1621
+ return Response.json({ title: result.title, ...(result.unchanged ? { unchanged: true } : {}) }, { headers: NO_STORE })
1622
+ } catch (cause) {
1623
+ failure ??= cause
1624
+ }
1625
+ }
1626
+ throw failure
1627
+ }
1628
+
908
1629
  async function conversation(request: Request, sessionID: string): Promise<Response> {
909
1630
  const on = (await body(request)).on
910
1631
  if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
@@ -968,22 +1689,9 @@ export function startCentral(options: CentralOptions): Central {
968
1689
  const input = await body(request)
969
1690
  const decision = input.decision as PermissionDecision
970
1691
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
971
- const targets = registry.permissionTargets(rootID, input.requestID)
972
- if (!targets.length) return error(409, PERMISSION_GONE, "gone")
973
- // Try every process that saw the request: only the one (and the location) running the session holds it.
974
- let failure: unknown
975
- for (const target of targets) {
976
- try {
977
- const result = await spokes.request(target.connID, { op: "permission", sessionID: target.sessionID, requestID: input.requestID, decision })
978
- if (!result?.gone) return new Response(null, { status: 204 })
979
- } catch (cause) {
980
- // Older spokes pass OpenCode's "Permission request not found" through as an error.
981
- if (!/not found/i.test(cause instanceof Error ? cause.message : String(cause))) failure ??= cause
982
- }
983
- }
984
- if (failure) throw failure
985
- registry.dropPermission(input.requestID)
986
- return error(409, PERMISSION_GONE, "gone")
1692
+ const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
1693
+ if (answered.outcome === "unsupported") throw unsupportedError(answered)
1694
+ return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
987
1695
  }
988
1696
 
989
1697
  /** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
@@ -996,23 +1704,11 @@ export function startCentral(options: CentralOptions): Central {
996
1704
  const problem = formAnswerProblem(input.answer)
997
1705
  if (problem) return error(400, problem)
998
1706
  }
999
- const targets = registry.formTargets(rootID, input.formID)
1000
- if (!targets.length) return error(409, PERMISSION_GONE, "gone")
1001
- let failure: unknown
1002
- for (const target of targets) {
1003
- const req = cancel
1004
- ? { op: "form" as const, sessionID: target.sessionID, formID: input.formID, cancel: true as const }
1005
- : { op: "form" as const, sessionID: target.sessionID, formID: input.formID, answer: input.answer as FormAnswer }
1006
- try {
1007
- const result = await spokes.request(target.connID, req)
1008
- if (!result?.gone) return new Response(null, { status: 204 })
1009
- } catch (cause) {
1010
- failure ??= cause
1011
- }
1012
- }
1013
- if (failure) throw failure
1014
- registry.dropForm(input.formID)
1015
- return error(409, PERMISSION_GONE, "gone")
1707
+ const answered = await actions.answerForm(
1708
+ cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
1709
+ )
1710
+ if (answered.outcome === "unsupported") throw unsupportedError(answered)
1711
+ return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1016
1712
  }
1017
1713
 
1018
1714
  async function transcribe(request: Request, speech: Speech): Promise<Response> {
@@ -1027,7 +1723,14 @@ export function startCentral(options: CentralOptions): Central {
1027
1723
  return Response.json({ text: await speech.transcribe(audio, format) })
1028
1724
  }
1029
1725
 
1030
- function events(signal: AbortSignal, userID: string, deviceID: string): Response {
1726
+ /**
1727
+ * The live stream. Every message has an id; with `lastID` (Last-Event-ID or ?last=) the hub replays what came
1728
+ * after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
1729
+ * over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
1730
+ */
1731
+ function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
1732
+ // Draining: come back (to another pod) shortly.
1733
+ if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
1031
1734
  const encoder = new TextEncoder()
1032
1735
  let cleanup = () => {}
1033
1736
  const stream = new ReadableStream<Uint8Array>({
@@ -1042,17 +1745,38 @@ export function startCentral(options: CentralOptions): Central {
1042
1745
  const listener: Listener = {
1043
1746
  userID,
1044
1747
  deviceID,
1045
- send: (message) => write(`data: ${JSON.stringify(message)}\n\n`),
1748
+ lite: options.lite,
1749
+ ...(options.session ? { session: options.session } : {}),
1750
+ send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
1046
1751
  close: () => {
1047
1752
  cleanup()
1048
1753
  try {
1049
1754
  controller.close()
1050
1755
  } catch {}
1051
1756
  },
1757
+ end: (retryMs) => {
1758
+ write(`retry: ${retryMs}\n\n`)
1759
+ listener.close()
1760
+ },
1761
+ }
1762
+ if (live) return clusterStream(listener, write)
1763
+ eventLog.open(userID)
1764
+ const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
1765
+ if (missed) {
1766
+ let catalog = false
1767
+ for (const { id, message, exceptDevice } of missed) {
1768
+ if (exceptDevice === deviceID) continue
1769
+ if (message.type === "catalog") catalog = true
1770
+ else if (wants(listener, message)) listener.send(message, id)
1771
+ }
1772
+ if (catalog) listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
1773
+ } else {
1774
+ listener.send({ type: "catalog", machines: machines(userID) }, eventLog.latest(userID))
1775
+ if (options.lastID) listener.send({ type: "resync" }, eventLog.latest(userID))
1052
1776
  }
1053
- listener.send({ type: "catalog", machines: machines(userID) })
1054
1777
  listeners.add(listener)
1055
- const heartbeat = setInterval(() => write(": ping\n\n"), SSE_HEARTBEAT_MS)
1778
+ // A named event (not a comment) so the page sees it and can tell a live stream from a dead one.
1779
+ const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
1056
1780
  cleanup = () => {
1057
1781
  clearInterval(heartbeat)
1058
1782
  listeners.delete(listener)
@@ -1063,12 +1787,104 @@ export function startCentral(options: CentralOptions): Central {
1063
1787
  cleanup()
1064
1788
  },
1065
1789
  })
1790
+ /**
1791
+ * Cluster mode: the user's live events from every pod; the replay comes from the shared EventLog. Events that
1792
+ * arrive while it is read wait (in order) and then go out, except those the replay already sent.
1793
+ */
1794
+ async function clusterStream(listener: Listener, write: (chunk: string) => void): Promise<void> {
1795
+ let pending: LiveEvent[] | undefined = []
1796
+ const replayed = new Set<string>()
1797
+ const forward = (event: LiveEvent) => {
1798
+ if (event.exceptDevice === deviceID || (event.id !== undefined && replayed.has(event.id))) return
1799
+ if (wants(listener, event.message)) listener.send(event.message, event.id)
1800
+ }
1801
+ const subscription = live!.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
1802
+ listeners.add(listener)
1803
+ const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), heartbeatMs)
1804
+ cleanup = () => {
1805
+ clearInterval(heartbeat)
1806
+ listeners.delete(listener)
1807
+ subscription.close()
1808
+ }
1809
+ signal.addEventListener("abort", () => cleanup(), { once: true })
1810
+ try {
1811
+ await subscription.ready
1812
+ const missed = options.lastID ? await backends.events.since(userID, options.lastID) : undefined
1813
+ if (missed && "events" in missed) {
1814
+ for (const { id, message, exceptDevice } of missed.events) {
1815
+ replayed.add(id)
1816
+ if (exceptDevice !== deviceID && wants(listener, message)) listener.send(message, id)
1817
+ }
1818
+ // Catalogs are not logged in a cluster: a resumed stream always gets the current one.
1819
+ listener.send({ type: "catalog", machines: machines(userID) })
1820
+ } else {
1821
+ const latest = await backends.events.latest(userID)
1822
+ listener.send({ type: "catalog", machines: machines(userID) }, latest)
1823
+ if (options.lastID) listener.send({ type: "resync" }, latest)
1824
+ }
1825
+ } catch (cause) {
1826
+ console.error(`[hub] live stream did not start: ${(cause as Error).message}`)
1827
+ listener.close()
1828
+ return
1829
+ }
1830
+ const waiting = pending
1831
+ pending = undefined
1832
+ for (const event of waiting) forward(event)
1833
+ }
1066
1834
  return new Response(stream, {
1067
1835
  headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
1068
1836
  })
1069
1837
  }
1070
1838
 
1071
- function staticFile(pathname: string): Response {
1839
+ /** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
1840
+ async function readyz(): Promise<Response> {
1841
+ if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
1842
+ if (config.cluster) {
1843
+ let timer: ReturnType<typeof setTimeout> | undefined
1844
+ const late = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error("timed out")), READY_CHECK_MS)))
1845
+ try {
1846
+ await Promise.race([Promise.all([backends.state.ping?.(), backends.kv.ping?.()]), late])
1847
+ } catch {
1848
+ // A public endpoint: no backend detail (hosts, users) in the answer.
1849
+ return Response.json({ ok: false, error: "database or Valkey not reachable" }, { status: 503, headers: NO_STORE })
1850
+ } finally {
1851
+ clearTimeout(timer)
1852
+ }
1853
+ }
1854
+ return Response.json({ ok: true }, { headers: NO_STORE })
1855
+ }
1856
+
1857
+ async function runDrain(drainOptions: DrainOptions): Promise<void> {
1858
+ const timeoutMs = drainOptions.timeoutMs ?? options.timing?.drainTimeoutMs ?? DRAIN_TIMEOUT_MS
1859
+ const retryAfterMs = drainOptions.retryAfterMs ?? options.timing?.drainRetryAfterMs ?? DRAIN_RETRY_AFTER_MS
1860
+ // Refuse new WebSockets and requests; bye each connection once its requests in flight are answered.
1861
+ const connections = spokes.drain({ timeoutMs, retryAfterMs })
1862
+ // Step down at once (releasing the leases), so another pod takes the timed jobs over.
1863
+ await Promise.allSettled(leaders.map((loop) => loop.stop()))
1864
+ // Withdraw this pod's slices: the other pods stop showing its connections (the new pod republishes them).
1865
+ await view?.stop().catch((cause) => console.error(`[hub] cluster view not withdrawn: ${(cause as Error).message}`))
1866
+ for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
1867
+ drainSignal.abort()
1868
+ await connections
1869
+ }
1870
+
1871
+ /** Compressed copies of hashed assets, made once per file and encoding (they never change). */
1872
+ const compressed = new Map<string, Promise<Uint8Array>>()
1873
+ function compress(name: string, bytes: Uint8Array, encoding: "br" | "gzip"): Promise<Uint8Array> {
1874
+ const key = `${encoding} ${name}`
1875
+ let done = compressed.get(key)
1876
+ if (!done) {
1877
+ done =
1878
+ encoding === "br"
1879
+ ? brotli(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 6, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } })
1880
+ : gzipAsync(bytes, { level: 9 })
1881
+ done.catch(() => compressed.delete(key))
1882
+ compressed.set(key, done)
1883
+ }
1884
+ return done
1885
+ }
1886
+
1887
+ async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
1072
1888
  const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
1073
1889
  const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
1074
1890
  const bytes = name && type ? web.read(name) : undefined
@@ -1082,6 +1898,16 @@ export function startCentral(options: CentralOptions): Central {
1082
1898
  headers.set("service-worker-allowed", "/")
1083
1899
  } else if (type.startsWith("text/html")) {
1084
1900
  headers.set("cache-control", "no-cache")
1901
+ } else if (HASHED_ASSET.test(name)) {
1902
+ headers.set("cache-control", IMMUTABLE)
1903
+ if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
1904
+ headers.set("vary", "Accept-Encoding")
1905
+ const encoding = pickEncoding(acceptEncoding)
1906
+ if (encoding) {
1907
+ headers.set("content-encoding", encoding)
1908
+ return new Response(new Uint8Array(await compress(name, bytes, encoding)), { headers })
1909
+ }
1910
+ }
1085
1911
  }
1086
1912
  return new Response(bytes, { headers })
1087
1913
  }
@@ -1092,20 +1918,85 @@ export function startCentral(options: CentralOptions): Central {
1092
1918
  store,
1093
1919
  auth,
1094
1920
  registry,
1921
+ nodes,
1095
1922
  spokes,
1096
1923
  push,
1097
1924
  speechSettings,
1098
- routes: routes.map(({ method, path, access }) => ({ method, path, access })),
1925
+ backends,
1926
+ delivery,
1927
+ decisions,
1928
+ acting,
1929
+ launches,
1930
+ launchFeed,
1931
+ launchTokens,
1932
+ webhooks,
1933
+ routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
1934
+ drain(drainOptions: DrainOptions = {}) {
1935
+ draining ??= runDrain(drainOptions)
1936
+ return draining
1937
+ },
1099
1938
  async stop() {
1100
- spokes.stop()
1939
+ // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
1940
+ await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
1941
+ launches.shutdown()
1942
+ webhooks.stop()
1943
+ tokenPush.stop()
1944
+ delivery.stop()
1945
+ decisions.stop()
1946
+ await spokes.stop()
1947
+ await view?.stop().catch((cause) => console.error(`[hub] cluster view not stopped: ${(cause as Error).message}`))
1101
1948
  for (const listener of [...listeners]) listeners.delete(listener)
1949
+ await Promise.all([live?.stop(), feed.detach(), launchFeed.detach()]).catch((cause) => console.error(`[hub] live fan-out not stopped: ${(cause as Error).message}`))
1102
1950
  // Bun 1.3.5 quirk: once the server has closed a WebSocket itself, stop(true)'s promise never
1103
1951
  // resolves even though the listener is closed. Don't wait on it forever.
1104
1952
  await Promise.race([server.stop(true), Bun.sleep(250)])
1953
+ await backends.state.flush?.().catch((cause) => console.error(`[hub] agent state not saved: ${(cause as Error).message}`))
1954
+ await store.close()
1955
+ if (ownBackends) await backends.close?.().catch((cause) => console.error(`[hub] backends not closed: ${(cause as Error).message}`))
1105
1956
  },
1106
1957
  }
1107
1958
  }
1108
1959
 
1960
+ /** The state filter of GET /api/decisions (throws invalid). */
1961
+ const optionalState = (url: URL): { state?: Decision["state"] } => {
1962
+ const state = decisionStateQuery(url.searchParams.get("state"))
1963
+ return state ? { state } : {}
1964
+ }
1965
+
1966
+ /** {option?, text?} of a Decision answer (types only; Decisions validates). */
1967
+ function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
1968
+ for (const key of ["option", "text"] as const) {
1969
+ if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
1970
+ }
1971
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
1972
+ }
1973
+
1974
+ /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
1975
+ function decisionFailure(cause: unknown): Response {
1976
+ if (!(cause instanceof AgentError)) throw cause
1977
+ const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
1978
+ return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
1979
+ }
1980
+
1981
+ /** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
1982
+ export const APP_SCHEME = "hubapp"
1983
+
1984
+ /**
1985
+ * Public writes a native app makes with no ambient credential at all (no cookie, no bearer, no Origin or
1986
+ * Referer): not CSRF-prone, the single-use code in the body is the credential. Every other write keeps the
1987
+ * same-origin check, and so does any request carrying a cookie or a browser Origin.
1988
+ */
1989
+ const CREDENTIAL_FREE = new Set(["POST /api/devices/redeem"])
1990
+
1991
+ function credentialFree(request: Request, mode: CredentialMode, url: URL): boolean {
1992
+ return (
1993
+ mode === "none" &&
1994
+ !request.headers.has("origin") &&
1995
+ !request.headers.has("referer") &&
1996
+ CREDENTIAL_FREE.has(`${request.method} ${url.pathname}`)
1997
+ )
1998
+ }
1999
+
1109
2000
  function userView(user: UserRecord) {
1110
2001
  return { id: user.id, ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), role: user.role }
1111
2002
  }
@@ -1134,11 +2025,11 @@ function pushTarget(value: unknown): { endpoint: string; keys: { p256dh: string;
1134
2025
  return { endpoint, keys: { p256dh, auth } }
1135
2026
  }
1136
2027
 
1137
- /** Boolean pref fields (and 0.5.0's `blocked`) from untrusted input; undefined when nothing applies. */
2028
+ /** Boolean pref fields (and the legacy `blocked` and `finished`) from untrusted input; undefined when nothing applies. */
1138
2029
  function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | undefined {
1139
2030
  if (typeof value !== "object" || value === null) return undefined
1140
2031
  const prefs: Partial<PushPrefs> & LegacyPushPrefs = {}
1141
- for (const key of [...PUSH_PREF_KEYS, "blocked"] as const) {
2032
+ for (const key of [...PUSH_PREF_KEYS, "blocked", "finished"] as const) {
1142
2033
  const v = (value as Record<string, unknown>)[key]
1143
2034
  if (typeof v === "boolean") prefs[key] = v
1144
2035
  }
@@ -1148,7 +2039,10 @@ function prefsInput(value: unknown): (Partial<PushPrefs> & LegacyPushPrefs) | un
1148
2039
  /** PUT /api/prefs/view body: any of the four lists, each replacing the stored one. */
1149
2040
  function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
1150
2041
  const invalid = () =>
1151
- new HttpError(400, `Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} strings of at most ${VIEW_PREF_MAX_CHARS} characters`)
2042
+ new HttpError(
2043
+ 400,
2044
+ `Expected {${VIEW_PREF_KEYS.join("?, ")}?}: lists of at most ${VIEW_PREF_MAX_ENTRIES} (hiddenSessions: ${VIEW_PREF_MAX_ARCHIVED}) strings of at most ${VIEW_PREF_MAX_CHARS} characters`,
2045
+ )
1152
2046
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
1153
2047
  const record = value as Record<string, unknown>
1154
2048
  if (Object.keys(record).some((key) => !(VIEW_PREF_KEYS as readonly string[]).includes(key))) throw invalid()
@@ -1156,7 +2050,7 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
1156
2050
  for (const key of VIEW_PREF_KEYS) {
1157
2051
  const list = record[key]
1158
2052
  if (list === undefined) continue
1159
- if (!Array.isArray(list) || list.length > VIEW_PREF_MAX_ENTRIES) throw invalid()
2053
+ if (!Array.isArray(list) || list.length > (key === "hiddenSessions" ? VIEW_PREF_MAX_ARCHIVED : VIEW_PREF_MAX_ENTRIES)) throw invalid()
1160
2054
  if (!list.every((entry) => typeof entry === "string" && entry.length > 0 && entry.length <= VIEW_PREF_MAX_CHARS)) throw invalid()
1161
2055
  out[key] = [...new Set(list as string[])]
1162
2056
  }
@@ -1192,6 +2086,8 @@ function param(raw: string): string {
1192
2086
  }
1193
2087
  }
1194
2088
 
2089
+ const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
2090
+
1195
2091
  async function body(request: Request): Promise<Record<string, unknown>> {
1196
2092
  const value = (await request.json()) as unknown
1197
2093
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}