@huynle/hub 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -2,12 +2,13 @@
2
2
  // Phones authenticate with the `voice_device` cookie (or, in native apps, the same secret as a bearer device
3
3
  // token; see ./agent/actor.ts); spokes with machine bearer tokens. Source IPs
4
4
  // (including loopback) are never trusted.
5
+ import { BODY_LIMITS, readBounded, slowMessage } from "./agent/body"
5
6
  import type { Server } from "bun"
6
7
  import { diskAssets, type WebAssets } from "./assets"
7
8
  import path from "node:path"
8
9
  import { promisify } from "node:util"
9
10
  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"
11
+ import { formAnswerProblem, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
11
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
12
13
  import { speakable } from "../speakable"
13
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -17,7 +18,7 @@ import { isNotificationKind, Push, type PushSender } from "./push"
17
18
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
18
19
  import { Webhooks } from "./agent/webhooks"
19
20
  import { issueAppToken } from "./agent/app-tokens"
20
- import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
21
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
21
22
  import { EventWriters } from "./agent/event-writers"
22
23
  import { MemoryEventLog } from "./backend/memory-events"
23
24
  import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
@@ -26,8 +27,8 @@ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMo
26
27
  import { ActingSwitch } from "./agent/acting-switch"
27
28
  import { AgentAudit } from "./agent/audit"
28
29
  import { Grants } from "./agent/grants"
29
- import { Decisions, type Decision } from "./agent/decisions"
30
- import type { Escalate } from "./agent/acting"
30
+ import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
31
+ import { Acting, type Escalate } from "./agent/acting"
31
32
  import { Escalations } from "./agent/escalation"
32
33
  import { GrantRequests } from "./agent/grant-requests"
33
34
  import { partyFromActor } from "./agent/envelope"
@@ -36,6 +37,7 @@ import { AgentError, type AgentErrorCode } from "./agent/errors"
36
37
  import type { Launch } from "./agent/launch-store"
37
38
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
38
39
  import { hubDirectory } from "./agent/directory-routes"
40
+ import type { Directory } from "./agent/directory"
39
41
  import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
40
42
  import { MCP_PATH, mcpHttp } from "./mcp-http"
41
43
  import { SessionTokens } from "./agent/session-tokens"
@@ -44,6 +46,7 @@ import { TokenKeys } from "./token-keys"
44
46
  import { EventLog } from "./events-log"
45
47
  import { BusyError, Registry } from "./registry"
46
48
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
49
+ import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
47
50
  import { audioFormat, SpeechError } from "./speech/provider"
48
51
  import { SpeechService } from "./speech/service"
49
52
  import { NodeRegistry } from "./nodes"
@@ -57,6 +60,46 @@ import { randomBytes, randomUUID } from "node:crypto"
57
60
  import { LaunchEngine } from "./agent/launch-engine"
58
61
  import { LaunchTokens } from "./agent/launch-tokens"
59
62
  import { LaunchFeed, launchInfo } from "./agent/launch-routes"
63
+ import { ProfileApplier } from "./profiles/apply"
64
+ import { ProfileStore } from "./profiles/store"
65
+ import { WorkspaceStore } from "./workspaces/store"
66
+ import { directoryPresence, MembershipStore } from "./workspaces/members"
67
+ import { SupervisionStore } from "./workspaces/supervision"
68
+ import { WorkspaceLifecycle } from "./workspaces/lifecycle"
69
+ import { registryBoardSessions } from "./workspaces/board"
70
+ import { CheckpointStore, journalCursors } from "./workspaces/checkpoints"
71
+ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinator"
72
+ import { artifactJournalEvent } from "./artifacts/events"
73
+ import { ArtifactMaintenance } from "./artifacts/maintenance"
74
+ import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
75
+ import { ArtifactStore } from "./artifacts/store"
76
+ import { artifactHost, isArtifactHostPath } from "./artifacts/host"
77
+ import { ArtifactHostCaps } from "./artifacts/host-caps"
78
+ import { artifactWorkspacesFromState } from "./artifacts/workspaces"
79
+ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
80
+ import { TeamSeatStore } from "./teams/seats"
81
+ import { TeamStaffingService } from "./teams/staffing"
82
+ import { intakeActor, TeamIntakeService } from "./teams/intake"
83
+ import { TeamStore } from "./teams/store"
84
+ import { ActivityTracker } from "./concierge/activity"
85
+ import { ResultTracker } from "./concierge/board-results"
86
+ import { BoardRevisions } from "./concierge/board"
87
+ import { conciergeBoard } from "./concierge/board-routes"
88
+ import { ContextPusher } from "./concierge/context"
89
+ import { C1_ENTITLEMENTS } from "./concierge/entitlements"
90
+ import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge/inbound"
91
+ import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
92
+ import { watchedSessions } from "./concierge/notifier"
93
+ import { NotesStore } from "./concierge/notes"
94
+ import { conciergeSessionGate } from "./concierge/session-routes"
95
+ import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
96
+ import { ConciergeService, type MachineFacts } from "./concierge/service"
97
+ import { SettingsStore } from "./concierge/settings"
98
+ import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
99
+ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
100
+ import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
101
+ import { grantState } from "./agent/grants"
102
+ import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
60
103
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
61
104
  import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
62
105
 
@@ -143,10 +186,18 @@ export interface Central {
143
186
  readonly webhooks: Webhooks
144
187
  /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
145
188
  readonly agentEvents: AgentEvents
189
+ /** The agent directory (names, roles, address resolution). */
190
+ readonly directory: Directory
191
+ /** (CON-04) The Concierge service (settings, supervisor, context). */
192
+ readonly concierge: ConciergeService
146
193
  /** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
147
194
  readonly agentEventWriters: EventWriters
195
+ /** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
196
+ readonly intentRetention: IntentRetention
148
197
  /** The JSON API's route table (method, path, access), for audits and tests. */
149
198
  readonly routes: readonly RouteInfo[]
199
+ /** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
200
+ readonly teams: { readonly store: TeamStore; readonly seats: TeamSeatStore; readonly staffing: TeamStaffingService; readonly intake: TeamIntakeService }
150
201
  /**
151
202
  * Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
152
203
  * nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
@@ -164,8 +215,8 @@ interface Listener {
164
215
  readonly deviceID: string
165
216
  /** Leaves out streamed text and reasoning (except of `session`'s tree). */
166
217
  readonly lite: boolean
167
- /** The session the page shows: its tree's text and reasoning stream even when lite. */
168
- readonly session?: string
218
+ /** The sessions the page shows (one, or one per visible pane): their trees' text and reasoning stream even when lite. */
219
+ readonly sessions?: readonly string[]
169
220
  /** Sends a message; `id` is its event id (EventSource's lastEventId). */
170
221
  send(message: Message, id?: string): void
171
222
  close(): void
@@ -176,6 +227,23 @@ interface Listener {
176
227
  /** Messages a lite stream leaves out (unless they are of the listener's session tree). */
177
228
  const STREAMED = new Set(["delta", "reasoning"])
178
229
 
230
+ /**
231
+ * The sessions a console stream shows (HWA-07 panes): `session` (the focused one; all an older console sends)
232
+ * then `sessions` (comma-separated, one per visible pane), each once, blanks ignored. At most
233
+ * WORKSPACE_LIMITS.panes; an ID over 300 characters or too many sessions is a problem (400), never truncated.
234
+ */
235
+ function streamSessionsQuery(url: URL): { sessions: string[]; multi: boolean } | { problem: string } {
236
+ const listed = url.searchParams.getAll("sessions").flatMap((v) => v.split(","))
237
+ const sessions: string[] = []
238
+ for (const id of [url.searchParams.get("session") ?? "", ...listed]) {
239
+ if (!id || sessions.includes(id)) continue
240
+ if (id.length > 300) return { problem: "a session ID is too long" }
241
+ sessions.push(id)
242
+ }
243
+ if (sessions.length > WORKSPACE_LIMITS.panes) return { problem: `a stream shows at most ${WORKSPACE_LIMITS.panes} sessions` }
244
+ return { sessions, multi: url.searchParams.has("sessions") }
245
+ }
246
+
179
247
  /**
180
248
  * Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
181
249
  * "agent": an /api/agent route (src/central/agent), checked per credential kind there.
@@ -229,6 +297,10 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
229
297
  */
230
298
  export const TURN_ACCEPT_MS = 1500
231
299
  const NO_OWNER = "No OpenCode process for this session is connected"
300
+ /** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
301
+ const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
302
+ /** A message ID in a fork or revert request (as the spoke protocol bounds it). */
303
+ const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
232
304
  const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
233
305
  const NOT_EXPOSED = "Voice is not enabled for this session"
234
306
  /** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
@@ -257,7 +329,9 @@ const WEB_NOT_BUILT = [
257
329
  /** Preview text for POST /api/settings/speech/test. */
258
330
  const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to you."
259
331
  const SPEECH_TEST_MAX_CHARS = 300
260
- const APP_ROUTES = /^\/(?:|pair|approve|new|settings|s\/[^/]+)$/
332
+ // Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
333
+ // redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
334
+ const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
261
335
  const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
262
336
  /** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
263
337
  const LOGIN_NEXT = /^\/(?:s\/[A-Za-z0-9_-]+(?:\?voice=1)?)?$/
@@ -302,16 +376,18 @@ class HttpError extends Error {
302
376
  message: string,
303
377
  /** Machine-readable reason, e.g. not_exposed. */
304
378
  readonly code?: string,
379
+ /** Answer with Connection: close (a request body was left unread on the socket: agent/body.ts). */
380
+ readonly close?: boolean,
305
381
  ) {
306
382
  super(message)
307
383
  }
308
384
  }
309
385
 
310
386
  /** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
311
- const unsupportedError = (lacking: Pick<Unsupported, "feature" | "harness">) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
387
+ const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
312
388
 
313
- const error = (status: number, message: string, code?: string) =>
314
- Response.json({ error: message, ...(code ? { code } : {}) }, { status })
389
+ const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
390
+ Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
315
391
 
316
392
  export function startCentral(options: CentralOptions): Central {
317
393
  const { config } = options
@@ -330,7 +406,12 @@ export function startCentral(options: CentralOptions): Central {
330
406
  const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
331
407
  const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
332
408
  const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
333
- const notifier = options.notifier ?? push
409
+ // CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
410
+ // push remapping all follow it); the service itself is built below, once its parts exist.
411
+ const conciergeStatus = new StatusStore(backends.state, now)
412
+ const conciergeIdentity = recordIdentity(conciergeStatus)
413
+ // Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
414
+ const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
334
415
  /** SSE subscribers, one user each. */
335
416
  const listeners = new Set<Listener>()
336
417
  /** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
@@ -338,9 +419,10 @@ export function startCentral(options: CentralOptions): Central {
338
419
  /** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
339
420
  const wants = (listener: Listener, message: Message): boolean => {
340
421
  if (!listener.lite || !STREAMED.has(message.type)) return true
341
- if (!listener.session) return false
342
- const root = registry.status(listener.session)?.[0]?.id ?? listener.session
343
- return message.sessionID === listener.session || message.rootID === root || message.sessionID === root
422
+ return (listener.sessions ?? []).some((session) => {
423
+ const root = registry.status(session)?.[0]?.id ?? session
424
+ return message.sessionID === session || message.rootID === root || message.sessionID === root
425
+ })
344
426
  }
345
427
  /**
346
428
  * Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
@@ -439,7 +521,11 @@ export function startCentral(options: CentralOptions): Central {
439
521
  hostname: config.host,
440
522
  port: config.port,
441
523
  idleTimeout: 255,
442
- maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, 1 << 20),
524
+ // Staged artifact blobs stream up to ARTIFACT_UPLOAD_LIMITS.blobBytes (the routes bound every body themselves).
525
+ // Plus headroom for the drain past a route's bound (BODY_LIMITS.drainBytes, overshot by up to one chunk): Bun ≥ 1.4
526
+ // enforces this limit on chunked bodies too, answering a bare 413 and closing the socket before the route can
527
+ // give its own 400/413. It stays a backstop; the routes' bounds decide.
528
+ maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, ARTIFACT_UPLOAD_LIMITS.blobBytes + 65_536, 1 << 20) + 2 * BODY_LIMITS.drainBytes,
443
529
  fetch: (request, server) => handle(request, server),
444
530
  websocket: {
445
531
  open: (ws) => spokes.websocket.open?.(ws),
@@ -536,7 +622,11 @@ export function startCentral(options: CentralOptions): Central {
536
622
  // Push goes to the session owner's devices only; an alert without an owner goes nowhere.
537
623
  if (!alert.owner) return
538
624
  // Activity updates are collected and throttled per device by the push layer.
539
- if (alert.kind === "activity") return push.activity(alert)
625
+ if (alert.kind === "activity") {
626
+ // The Concierge's own activity is never pushed (its session is hidden).
627
+ void isConciergeSession(conciergeIdentity, alert.owner, alert.sessionID).then((hidden) => (hidden ? undefined : push.activity(alert)))
628
+ return
629
+ }
540
630
  const extra = {
541
631
  ...(alert.requestID ? { requestID: alert.requestID } : {}),
542
632
  ...(alert.subject ? { subject: alert.subject } : {}),
@@ -688,7 +778,25 @@ export function startCentral(options: CentralOptions): Central {
688
778
  }
689
779
  // Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
690
780
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
781
+ // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
782
+ let teamSeats: TeamSeatStore | undefined
783
+ const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
784
+ const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
785
+ teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
786
+ const teamPolicy = new TeamPolicy({
787
+ teams: teamStore,
788
+ seats: teamSeats,
789
+ directory,
790
+ denied: async (userID, denial) => {
791
+ const target = denial.recipientID !== undefined ? { sessionID: denial.recipientID } : {}
792
+ const reason = `team policy: ${denial.reason} (${denial.kind})`
793
+ await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
794
+ },
795
+ })
691
796
  const delivery = new Delivery({
797
+ teams: teamPolicy,
798
+ // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
799
+ inbound: conciergeInbound({ identity: conciergeIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
692
800
  state: backends.state,
693
801
  counters: backends.counters,
694
802
  registry,
@@ -714,6 +822,8 @@ export function startCentral(options: CentralOptions): Central {
714
822
  const feed = new MessageFeed()
715
823
  // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
716
824
  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}`))
825
+ /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
826
+ let conciergeTierHooks: ConciergeTiers | undefined
717
827
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
718
828
  const decisions = new Decisions({
719
829
  state: backends.state,
@@ -722,10 +832,26 @@ export function startCentral(options: CentralOptions): Central {
722
832
  users: () => store.users().map((u) => u.id),
723
833
  notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
724
834
  // The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
725
- changed: (userID, decision) => broadcast({ type: "decision", decision }, { tenant: userID }),
726
- // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user
727
- // (both are set up below).
728
- act: (userID, decision, choice, by) => (decision.action?.kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)),
835
+ changed: (userID, decision) => {
836
+ broadcast({ type: "decision", decision }, { tenant: userID })
837
+ conciergeTierHooks?.decisionChanged?.(userID, decision)
838
+ },
839
+ // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
840
+ // (CON-06) Concierge proposals run their verb as the user (all set up below).
841
+ act: (userID, decision, choice, by) => {
842
+ const kind = decision.action?.kind
843
+ if (kind === "act") {
844
+ if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
845
+ return conciergeTierHooks.act(userID, decision, choice, by)
846
+ }
847
+ return kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)
848
+ },
849
+ // (CON-06) Voice/text answers: only what the Concierge tiers admit (armed, presence, the user's device).
850
+ via: async (userID, decision, via, by) => {
851
+ if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
852
+ await conciergeTierHooks.checkVia(userID, decision, via, by)
853
+ },
854
+ concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID),
729
855
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
730
856
  })
731
857
  leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
@@ -737,15 +863,26 @@ export function startCentral(options: CentralOptions): Central {
737
863
  }
738
864
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
739
865
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
740
- /** Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. */
741
- const actions = createActions({
866
+ // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
867
+ const intents = new SessionIntents({
868
+ state: backends.state,
869
+ now,
870
+ events: { append: (userID, event) => agentEvents.append(userID, event) },
871
+ // The owner's consoles refetch the intent (the Details rail's card); the text never rides the stream.
872
+ onChange: (userID, sessionID, source, revision) => broadcast({ type: "intent", sessionID, source, revision }, { tenant: userID }),
873
+ })
874
+ /**
875
+ * Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
876
+ * prompt the hub carries derives the session's intent once (CON-02).
877
+ */
878
+ const actions = withPromptIntents(createActions({
742
879
  registry,
743
880
  spokes,
744
881
  humanTurn,
745
882
  kv: backends.kv,
746
883
  // Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
747
884
  ...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
748
- })
885
+ }), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
749
886
  leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
750
887
  // Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
751
888
  // service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
@@ -755,6 +892,8 @@ export function startCentral(options: CentralOptions): Central {
755
892
  // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
756
893
  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}`))
757
894
  launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
895
+ // Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
896
+ let profileApplier: ProfileApplier | undefined
758
897
  const launches = new LaunchEngine({
759
898
  state: backends.state,
760
899
  counters: backends.counters,
@@ -775,6 +914,8 @@ export function startCentral(options: CentralOptions): Central {
775
914
  users: () => store.users().map((u) => u.id),
776
915
  now,
777
916
  onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
917
+ intents,
918
+ profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
778
919
  // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
779
920
  ...(config.cluster
780
921
  ? {
@@ -839,11 +980,19 @@ export function startCentral(options: CentralOptions): Central {
839
980
  })
840
981
  // The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
841
982
  // presence by the agent-events leader, everything else where it happens (event-writers.ts).
983
+ let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
984
+ /** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
985
+ let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
986
+ // CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
987
+ // rejects, and the catch keeps it that way: an unhandled rejection would end the process).
988
+ const conciergeActivity = new ActivityTracker({ state: backends.state, now })
989
+ // CON-03: the board's last result per root session, from appends (best effort, as above).
990
+ const conciergeResults = new ResultTracker({ state: backends.state, now })
842
991
  const agentEvents = new AgentEvents({
843
992
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
844
993
  kv: backends.kv,
845
994
  now,
846
- onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
995
+ onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
847
996
  })
848
997
  const agentEventWriters = new EventWriters({
849
998
  events: agentEvents,
@@ -856,13 +1005,58 @@ export function startCentral(options: CentralOptions): Central {
856
1005
  })
857
1006
  agentEventWriters.start()
858
1007
  leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
1008
+ // Persistent artifacts (HWA-21): metadata in the StateStore, bytes in Backends.blobs (files standalone, Postgres
1009
+ // in a cluster (HWA-22); a bundle without blobs answers unsupported). Events go to the journal (family artifact); recovery
1010
+ // and archive purging run on a leader.
1011
+ const artifacts = backends.blobs
1012
+ ? new ArtifactStore({
1013
+ state: backends.state,
1014
+ blobs: backends.blobs,
1015
+ now,
1016
+ workspaces: artifactWorkspacesFromState(backends.state),
1017
+ onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
1018
+ })
1019
+ : undefined
1020
+ const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
1021
+ // Isolated artifact host (HWA-26): /artifact-host/v1/<capability>/… on this process (opaque-origin sandbox).
1022
+ const artifactHostCaps = new ArtifactHostCaps({ state: backends.state, keys: tokenKeys, now })
1023
+ const artifactHostServer = artifactHost({ caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}), consoleOrigins: () => [...origins], deviceActive: (userID, id) => store.devices().some((d) => d.id === id && d.ownerID === userID), now })
1024
+ // (CON-02) Session intent retention (agent/intents.ts sweep), on one leader every 6 h: online sessions (the
1025
+ // registry, whatever the directory holds) are kept; records of deleted sessions and of sessions neither seen nor
1026
+ // written for 30 days are dropped. The directory (at most 2000 records per user) only adds lastSeen/deletedAt.
1027
+ const intentRetention = new IntentRetention({
1028
+ intents,
1029
+ users: allUsers,
1030
+ now,
1031
+ // (CON-10) Run Concierge prompt proposals: the build before CON-09a derived them as the session's intent.
1032
+ proposed: async (userID) =>
1033
+ (await decisions.list(userID, { state: "answered" })).flatMap((d) =>
1034
+ d.as === "concierge" && d.action?.kind === "act" && d.action.verb === "prompt" && d.answer?.option === "go" && typeof d.action.input.text === "string"
1035
+ ? [{ sessionID: d.action.target, text: d.action.input.text, at: d.answer.at }]
1036
+ : [],
1037
+ ),
1038
+ facts: (userID) => {
1039
+ const known = new Map((store.agentData(userID).sessions ?? []).map((s) => [s.sessionID, s]))
1040
+ return (sessionID) => {
1041
+ const s = known.get(sessionID)
1042
+ const lastSeen = s?.lastSeen ?? s?.firstSeen
1043
+ return {
1044
+ online: registry.sessionTenant(sessionID) === userID && registry.hasSession(sessionID),
1045
+ ...(lastSeen !== undefined ? { lastSeen } : {}),
1046
+ ...(s?.deletedAt !== undefined ? { deletedAt: s.deletedAt } : {}),
1047
+ }
1048
+ }
1049
+ },
1050
+ })
1051
+ leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
1052
+ if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
859
1053
  for (const loop of leaders) void loop.start()
860
1054
 
861
1055
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
862
1056
  // Every stored agent message also goes to the user's console streams (live transcript rows).
863
1057
  feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
864
- const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }) })
865
- const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now })
1058
+ const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID) })
1059
+ const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
866
1060
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
867
1061
  /** An agent as envelopes name it (a session by its directory name). */
868
1062
  const describeActor = (userID: string, actor: AgentActor) => {
@@ -909,7 +1103,269 @@ export function startCentral(options: CentralOptions): Central {
909
1103
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
910
1104
  })
911
1105
  const escalate: Escalate = (escalation) => escalations.escalate(escalation)
912
- const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
1106
+ const profiles: ProfileStore = new ProfileStore({ state: backends.state, now, ack: (userID, assignment, version) => profileApplier!.ackFor(userID, assignment, version) })
1107
+ profileApplier = new ProfileApplier({ profiles, registry, spokes, events: agentEvents, now })
1108
+ profileApplier.start()
1109
+ const workspaceStore = new WorkspaceStore({ state: backends.state, now, ownsMachine: (userID, machineID) => ownMachine(userID, machineID) !== undefined })
1110
+ const workspaceMembers = new MembershipStore({ state: backends.state, workspaces: workspaceStore, now, presence: directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? []) })
1111
+ const workspaceSupervision = new SupervisionStore({ state: backends.state, workspaces: workspaceStore, members: workspaceMembers, now })
1112
+ const workspaceLifecycle = new WorkspaceLifecycle({ workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, events: agentEvents })
1113
+ registry.onSessionDeleted((sessionID, tenant) => void workspaceLifecycle.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] workspace session end failed: ${(cause as Error).message}`)))
1114
+ const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1115
+ // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1116
+ const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
1117
+ const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now })
1118
+ registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1119
+ launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1120
+ // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
1121
+ teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text)).message.id }) })
1122
+ // Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
1123
+ // supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
1124
+ const workspaceCursors = journalCursors(tokenKeys, agentEvents)
1125
+ let workspaceCoordinator: SupervisorCoordinator | undefined
1126
+ const workspaceCheckpoints = new CheckpointStore({ ...workspaces, state: backends.state, cursors: workspaceCursors, now, onAck: async (u, w, m, b) => workspaceCoordinator?.acknowledged(u, w, m, b) })
1127
+ const coordinatorLeader = leader("workspace-coordinator", { onElected: () => workspaceCoordinator?.start(), onDeposed: () => workspaceCoordinator?.stop() })
1128
+ workspaceCoordinator = new SupervisorCoordinator({
1129
+ ...workspaces,
1130
+ state: backends.state,
1131
+ events: agentEvents,
1132
+ cursors: workspaceCursors,
1133
+ checkpoints: workspaceCheckpoints,
1134
+ sessions: registryBoardSessions(directory, registry),
1135
+ send: async (userID, sessionID, text, options) => {
1136
+ const sent = await delivery.send(userID, coordinatorActor(userID), sessionID, "msg", text, options)
1137
+ return { messageID: sent.message.id, ...(sent.delivery ? { delivery: sent.delivery } : {}), ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1138
+ },
1139
+ users: allUsers,
1140
+ isLeader: () => coordinatorLeader.isLeader(),
1141
+ owner: leaseOwner,
1142
+ now,
1143
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
1144
+ })
1145
+ void coordinatorLeader.start() // created after the other leaders were started
1146
+ const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
1147
+
1148
+ // ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
1149
+ const conciergeSettings = new SettingsStore(backends.state, now)
1150
+ const conciergeNotes = new NotesStore(backends.state, now)
1151
+ const conciergeLabels = async (userID: string) => {
1152
+ const out = new Map<string, Readonly<Record<string, string>>>()
1153
+ for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
1154
+ return out
1155
+ }
1156
+ // The board's `armed` flag reads the tiers slot (built below).
1157
+ const conciergeArmed = async (userID: string) => conciergeTierHooks?.armedID?.(userID)
1158
+ // (CON-13) The board's inGrant: the tiers' acting engine (made below) checks whether the current Concierge's grant
1159
+ // would approve a pending request once, without acting.
1160
+ let conciergeActing: Acting | undefined
1161
+ const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
1162
+ conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
1163
+ const conciergeBoardDeps = { coverage: conciergeCoverage, identity: conciergeIdentity, state: backends.state, activity: conciergeActivity, directory, store, registry, decisions, labels: conciergeLabels, intents, results: conciergeResults, armed: conciergeArmed, timeZone: async (userID: string) => (await conciergeSettings.get(userID)).timeZone, now }
1164
+ const conciergeRevisions = new BoardRevisions(backends.state)
1165
+ const conciergeContext = new ContextPusher({
1166
+ registry,
1167
+ send: (connID, req) => spokes.request(connID, req),
1168
+ board: async (userID) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)).rendered,
1169
+ notes: conciergeNotes,
1170
+ settings: conciergeSettings,
1171
+ grant: async (userID, grantID) => {
1172
+ try {
1173
+ const grant = await grants.get(userID, grantID)
1174
+ return { ...grant, state: grantState(grant, now()) }
1175
+ } catch {
1176
+ return undefined
1177
+ }
1178
+ },
1179
+ // (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
1180
+ voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
1181
+ now,
1182
+ })
1183
+ /** The user's machines as the Concierge's eligibility sees them (store, nodes, registry). */
1184
+ const conciergeMachines = (userID: string): MachineFacts[] => {
1185
+ const online = new Set(registry.catalog().map((m) => m.id))
1186
+ return store
1187
+ .machines()
1188
+ .filter((m) => !m.revoked && m.ownerID === userID)
1189
+ .map((m) => {
1190
+ const node = nodes.forMachine(m.id)
1191
+ const own = node && node.ownerID === userID ? node : undefined
1192
+ const conn = registry.serviceFor(m.id)
1193
+ return {
1194
+ id: m.id,
1195
+ name: m.name,
1196
+ online: online.has(m.id) || own !== undefined,
1197
+ ...(own ? { node: { runtimes: own.runtimes, workspaceRoots: own.workspaceRoots, draining: own.draining } } : {}),
1198
+ ...(conn !== undefined ? { service: { launch: registry.supports(conn, "launch"), sessionContext: registry.supports(conn, "sessionContext") } } : {}),
1199
+ }
1200
+ })
1201
+ }
1202
+ let conciergeService: ConciergeService | undefined
1203
+ let conciergeModulesNow: ReturnType<typeof conciergeModules> | undefined
1204
+ const conciergeSupervisor = new Supervisor({
1205
+ state: backends.state,
1206
+ settings: conciergeSettings,
1207
+ launches,
1208
+ directory,
1209
+ grants,
1210
+ eligibility: (userID, machineID) => conciergeService!.eligibility(userID, machineID),
1211
+ entitlements: C1_ENTITLEMENTS,
1212
+ context: { push: (userID, sessionID) => conciergeContext.push(userID, sessionID, { force: true }).then(() => undefined), clear: (userID, sessionID) => conciergeContext.clear(userID, sessionID), retire: async (userID, sessionID) => {
1213
+ // (CON-10) Hub updates still pending for it are dropped: never delivered to (or shown waiting for) an ended session.
1214
+ const dropped = await delivery.dropPending(userID, sessionID).catch((cause) => (console.error(`[hub] concierge pending drop failed: ${(cause as Error).message}`), 0))
1215
+ // Audited as a count only (never the updates' text).
1216
+ if (dropped > 0) await store.audit({ actor: userID, action: "concierge.updates_dropped", target: sessionID, detail: String(dropped) }).catch(() => undefined)
1217
+ await conciergeContext.retire(userID, sessionID)
1218
+ } },
1219
+ onStatus: (userID, status) => {
1220
+ broadcast({ type: "concierge", status }, { tenant: userID })
1221
+ void agentEvents.append(userID, conciergeEvent({ what: "status", state: status.state, generation: status.generation })).catch((cause) => console.error(`[hub] concierge event failed: ${(cause as Error).message}`))
1222
+ if (status.state === "disabled") void conciergeModulesNow?.notifier.disabled(userID).catch(() => undefined)
1223
+ },
1224
+ audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
1225
+ // A session that becomes the Concierge leaves every workspace (member, master) and team seat it held: hub-owned.
1226
+ onAdopted: async (userID, sessionID) => {
1227
+ const [members, seats] = await Promise.all([workspaceLifecycle.sessionEnded(userID, sessionID), teamStaffing.sessionEnded(userID, sessionID)])
1228
+ if (members.length || seats.length) await store.audit({ actor: userID, action: "concierge.detached", target: sessionID, detail: `${members.length} workspace memberships, ${seats.length} team seats` })
1229
+ },
1230
+ onSuspended: (userID, sessionID) => void notifier.notify("concierge", sessionID, "Concierge", undefined, userID, { key: `suspended:${sessionID}` }).catch(() => 0),
1231
+ users: allUsers,
1232
+ now,
1233
+ })
1234
+ conciergeService = new ConciergeService({
1235
+ settings: conciergeSettings,
1236
+ notes: conciergeNotes,
1237
+ supervisor: conciergeSupervisor,
1238
+ grants,
1239
+ machines: conciergeMachines,
1240
+ entitlements: C1_ENTITLEMENTS,
1241
+ audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
1242
+ // The file store buffers writes (FILE_STATE_DEBOUNCE_MS); Postgres writes through.
1243
+ ...(backends.state.flush ? { flush: () => backends.state.flush!() } : {}),
1244
+ })
1245
+ const conciergePorts: ConciergePorts = {
1246
+ identity: conciergeIdentity,
1247
+ status: (userID) => conciergeSupervisor.status(userID),
1248
+ settings: (userID) => conciergeSettings.get(userID),
1249
+ beforeWake: async (userID) => {
1250
+ const { sessionID } = await conciergeIdentity.current(userID)
1251
+ return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
1252
+ },
1253
+ send: async (userID, text, sendOptions) => {
1254
+ const { sessionID } = await conciergeIdentity.current(userID)
1255
+ if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
1256
+ const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
1257
+ return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1258
+ },
1259
+ entitlements: C1_ENTITLEMENTS,
1260
+ users: allUsers,
1261
+ // CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
1262
+ journal: {
1263
+ events: agentEvents,
1264
+ state: backends.state,
1265
+ watched: (userID, hidden) => watchedSessions(conciergeBoardDeps, userID, hidden),
1266
+ boardRevision: async (userID, hidden) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, hidden)).board.revision,
1267
+ decisionOf: async (userID, messageID) => {
1268
+ const decision = await decisions.byMessage(userID, messageID)
1269
+ return decision ? { id: decision.id, about: decision.about } : undefined
1270
+ },
1271
+ isLeader: () => conciergeLeader.isLeader(),
1272
+ owner: leaseOwner,
1273
+ now,
1274
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
1275
+ },
1276
+ // CON-06: proposals run through the same acting engine as the act verbs (agent/routes.ts actVerbRoutes).
1277
+ tiers: {
1278
+ state: backends.state,
1279
+ counters: backends.counters,
1280
+ decisions,
1281
+ acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeIdentity), conciergeActor: conciergeProtects(conciergeIdentity) })),
1282
+ audit,
1283
+ protects: conciergeProtects(conciergeIdentity),
1284
+ published: (userID, message) => feed.publish(userID, message),
1285
+ describe: describeActor,
1286
+ present: (deviceID, sessionID) => registry.present(deviceID, sessionID),
1287
+ deviceOwner: (deviceID) => store.devices().find((d) => d.id === deviceID)?.ownerID,
1288
+ // The board shows the armed Decision: refresh the Concierge's context too.
1289
+ armed: (userID, armed) => (broadcast({ type: "concierge", armed }, { tenant: userID }), conciergeTouch(userID)),
1290
+ now,
1291
+ },
1292
+ }
1293
+ const conciergeParts = conciergeModules(conciergePorts)
1294
+ conciergeModulesNow = conciergeParts
1295
+ conciergeTierHooks = conciergeParts.tiers
1296
+ /** The user's current Concierge session, for a debounced context refresh. */
1297
+ const conciergeTouch = (userID: string, soon = false) => conciergeContext.touch(userID, async () => (await conciergeIdentity.current(userID)).sessionID, soon ? { soon } : {})
1298
+ conciergeOnAppend = (userID, event) => {
1299
+ try {
1300
+ // (CON-13) A blocker opened or closed reaches the Concierge's board within a second.
1301
+ conciergeTouch(userID, event.type === "blocker.opened" || event.type === "blocker.closed")
1302
+ conciergeParts.notifier.observe(userID, event)
1303
+ } catch (cause) {
1304
+ console.error(`[hub] concierge append hook failed: ${(cause as Error).message}`)
1305
+ }
1306
+ }
1307
+ // Kicks: a Concierge launch changed, a node connected, a spoke reported its sessions (also re-sends the context
1308
+ // to a restarted spoke: a new connection reports the session).
1309
+ launchFeed.subscribe((userID, launch) => (isConciergeLaunch(launch) ? conciergeSupervisor.kick(userID) : undefined), { origin: true })
1310
+ nodes.subscribe((event) => (event.type === "connected" && event.node.ownerID !== undefined ? conciergeSupervisor.kick(event.node.ownerID) : undefined), { remote: true })
1311
+ /**
1312
+ * What the spoke reports about the user's current Concierge session (each sessions frame): on another agent than
1313
+ * `concierge` → suspended at once (grant revoked, authority gone; only the user restarts it); without the
1314
+ * context revision the hub sent (a restarted or reconnected spoke) → the context is re-sent now, not debounced.
1315
+ */
1316
+ const conciergeForced = new Map<string, number>()
1317
+ const conciergeReported = async (tenant: string) => {
1318
+ const { sessionID, hiddenSessionIDs } = await conciergeIdentity.current(tenant)
1319
+ // (CON-09a) A previous Concierge session a spoke reports without the retired context's revision (a restarted
1320
+ // spoke, or one that lost it) is retired again (locked, powerless): forced, at most once per CONCIERGE_FORCED_PUSH_MS.
1321
+ for (const id of hiddenSessionIDs) {
1322
+ if (id === sessionID || registry.sessionTenant(id) !== tenant) continue
1323
+ const view = registry.session(id)
1324
+ if (!view || view.contextRevision === conciergeContext.revisionOf(tenant, id)) continue
1325
+ // A reported revision that is not ours: the spoke lost it, so the same connection gets it again (forced).
1326
+ // None reported (a new connection, an older spoke): sent once per connection (not forced).
1327
+ const force = view.contextRevision !== undefined
1328
+ const key = `retire\n${tenant}\n${id}`
1329
+ const at = now()
1330
+ if (force && at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) continue
1331
+ // Re-read: a relaunch may have adopted this very session meanwhile (never retire the current one).
1332
+ if ((await conciergeIdentity.current(tenant)).sessionID === id) continue
1333
+ if (force) conciergeForced.set(key, at)
1334
+ await conciergeContext.retire(tenant, id, { force })
1335
+ }
1336
+ if (sessionID === undefined || registry.sessionTenant(sessionID) !== tenant) return
1337
+ const view = registry.session(sessionID)
1338
+ if (!view) return
1339
+ // OpenCode's internal agents (title, compaction, summary) are not tamper; every other agent is.
1340
+ if (view.agent !== undefined && view.agent !== CONCIERGE_AGENT && !INTERNAL_AGENTS.has(view.agent)) {
1341
+ if (!KNOWN_PRIMARY_AGENTS.has(view.agent)) console.warn(`[hub] concierge session ${sessionID} reported on unknown agent ${view.agent.slice(0, 60)}: suspending`)
1342
+ await conciergeSupervisor.suspend(tenant, sessionID, CONCIERGE_TAMPERED)
1343
+ await backends.state.flush?.()
1344
+ return
1345
+ }
1346
+ const revision = conciergeContext.revisionOf(tenant, sessionID)
1347
+ if (revision !== undefined && view.contextRevision === revision) return
1348
+ // At most one forced push per session every CONCIERGE_FORCED_PUSH_MS (the debounced refresh still runs).
1349
+ const key = `${tenant}\n${sessionID}`
1350
+ const at = now()
1351
+ if (at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) return
1352
+ conciergeForced.set(key, at)
1353
+ if (conciergeForced.size > 1000) conciergeForced.delete(conciergeForced.keys().next().value as string)
1354
+ await conciergeContext.push(tenant, sessionID, { force: true })
1355
+ }
1356
+ registry.onSessions((connID) => {
1357
+ const tenant = registry.connTenant(connID)
1358
+ if (tenant === undefined) return
1359
+ conciergeSupervisor.kick(tenant)
1360
+ conciergeTouch(tenant)
1361
+ void conciergeReported(tenant).catch((cause) => console.error(`[hub] concierge report check failed: ${(cause as Error).message}`))
1362
+ })
1363
+ const conciergeLeader = leader("concierge", {
1364
+ onElected: () => (conciergeSupervisor.start(), conciergeParts.notifier.start()),
1365
+ onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1366
+ })
1367
+ void conciergeLeader.start()
1368
+ const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeIdentity, protects: conciergeProtects(conciergeIdentity), service: conciergeService, routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
913
1369
  const resolveActor = createResolver({
914
1370
  cookieName: COOKIE,
915
1371
  findDevice: (token) => deviceOfToken(token),
@@ -926,6 +1382,8 @@ export function startCentral(options: CentralOptions): Central {
926
1382
  void store.touchUser(user.id)
927
1383
  },
928
1384
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
1385
+ // CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
1386
+ gate: conciergeSessionGate(conciergeIdentity, () => conciergeParts.tiers.routes()),
929
1387
  })
930
1388
 
931
1389
  // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
@@ -987,6 +1445,7 @@ export function startCentral(options: CentralOptions): Central {
987
1445
  if (method === "GET" && pathname === "/login") return login(request, url)
988
1446
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
989
1447
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
1448
+ if (isArtifactHostPath(pathname)) return await artifactHostServer.handle(request, url)
990
1449
  if (pathname === MCP_PATH) return await mcp(request)
991
1450
  if (isAgentPath(pathname)) return await agent.handle(request, url)
992
1451
  if (pathname.startsWith("/api/")) return await api(request, server, url)
@@ -1093,19 +1552,24 @@ export function startCentral(options: CentralOptions): Central {
1093
1552
  ),
1094
1553
  route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
1095
1554
  route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
1555
+ const shown = streamSessionsQuery(url)
1556
+ if ("problem" in shown) return error(400, shown.problem)
1096
1557
  server.timeout(request, 0)
1097
- const session = url.searchParams.get("session")
1558
+ // Only the user's own sessions; others are left out silently (no hint whether they exist).
1559
+ const sessions = shown.sessions.filter((session) => registry.owns(user.id, session))
1098
1560
  return events(request.signal, user.id, device.id, {
1099
1561
  lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
1100
1562
  lite: url.searchParams.get("lite") === "1",
1101
- ...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
1563
+ ...(sessions.length ? { sessions } : {}),
1564
+ // A pane console learns what is streamed (an older console, with ?session= only, gets no new message).
1565
+ scope: shown.multi,
1102
1566
  })
1103
1567
  }),
1104
1568
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
1105
1569
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
1106
1570
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
1107
1571
  const before = new Set(store.viewPrefs(user.id).hiddenSessions)
1108
- const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1572
+ const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await jsonBody(request)))
1109
1573
  // The user's other devices only: never another user, and not the device that made the change.
1110
1574
  toUser(user.id, { type: "prefs", prefs }, device.id)
1111
1575
  // Archive changes made in the console go to the agent event journal like the agent API's.
@@ -1220,9 +1684,12 @@ export function startCentral(options: CentralOptions): Central {
1220
1684
  // The caller's own settings (pairing mode: the single owner's are the server default).
1221
1685
  route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
1222
1686
  // Raw JSON: anything but an object is refused by the validator.
1223
- route("PUT", "/api/settings/speech", "user", async ({ request, user }) =>
1224
- Response.json(await speechSettings.update(await request.json(), speechScope(user.id)), { headers: NO_STORE }),
1225
- ),
1687
+ route("PUT", "/api/settings/speech", "user", async ({ request, user }) => {
1688
+ const view = await speechSettings.update(await jsonBody(request), speechScope(user.id))
1689
+ // (CON-10) The Concierge's instructions mention voice only while speech is ready: refresh its context.
1690
+ conciergeTouch(user.id)
1691
+ return Response.json(view, { headers: NO_STORE })
1692
+ }),
1226
1693
  route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
1227
1694
  const kind = url.searchParams.get("kind")
1228
1695
  if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
@@ -1267,6 +1734,16 @@ export function startCentral(options: CentralOptions): Central {
1267
1734
  return Response.json(stopped.result, { headers: NO_STORE })
1268
1735
  }),
1269
1736
  route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
1737
+ // Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
1738
+ route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
1739
+ route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
1740
+ const messageID = messageIDInput(url.searchParams.get("messageID"))
1741
+ const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
1742
+ return Response.json(preview, { headers: NO_STORE })
1743
+ }),
1744
+ route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
1745
+ route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
1746
+ route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
1270
1747
  route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
1271
1748
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
1272
1749
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
@@ -1281,7 +1758,7 @@ export function startCentral(options: CentralOptions): Central {
1281
1758
  return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
1282
1759
  }),
1283
1760
  route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
1284
- const change = modelPrefsInput(await request.json())
1761
+ const change = modelPrefsInput(await jsonBody(request))
1285
1762
  await checkModelSelections(ownerOf(params.session!), params.session!, change)
1286
1763
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
1287
1764
  const prefs = await store.setModelPrefs(user.id, params.session!, change)
@@ -1332,8 +1809,8 @@ export function startCentral(options: CentralOptions): Central {
1332
1809
  "session",
1333
1810
  async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
1334
1811
  ),
1335
- route("POST", "/api/sessions/:session/permission", "session", ({ request, params }) => permission(request, params.session!)),
1336
- route("POST", "/api/sessions/:session/form", "session", ({ request, params }) => form(request, params.session!)),
1812
+ route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
1813
+ route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
1337
1814
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
1338
1815
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
1339
1816
  route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
@@ -1402,6 +1879,8 @@ export function startCentral(options: CentralOptions): Central {
1402
1879
  const decision = await decisions.get(user.id, params.id!)
1403
1880
  return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
1404
1881
  }),
1882
+ // CON-06: `via: "voice" | "text"` (the Concierge panel) only for an armed confirm Decision on a device present on
1883
+ // the current Concierge session (tiers.ts); else 403 "Answer this decision with a tap".
1405
1884
  route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
1406
1885
  const input = await body(request)
1407
1886
  const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
@@ -1416,7 +1895,7 @@ export function startCentral(options: CentralOptions): Central {
1416
1895
  }),
1417
1896
  route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
1418
1897
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
1419
- const view = await speechSettings.update(await request.json())
1898
+ const view = await speechSettings.update(await jsonBody(request))
1420
1899
  await store.audit({ actor: user.id, action: "speech.default" })
1421
1900
  return Response.json(view, { headers: NO_STORE })
1422
1901
  }),
@@ -1646,6 +2125,64 @@ export function startCentral(options: CentralOptions): Central {
1646
2125
  return connID
1647
2126
  }
1648
2127
 
2128
+ /**
2129
+ * The process that runs a fork or revert op: the session's owner, else another process reporting it, that
2130
+ * announced the feature; 409 unsupported {feature, harness} when none did (another harness, an older plugin, or
2131
+ * an OpenCode process that cannot reach its own server).
2132
+ */
2133
+ function featureOwner(sessionID: string, feature: "fork" | "revert"): string {
2134
+ const targets = registry.sessionTargets(sessionID)
2135
+ if (!targets.length) throw new HttpError(409, NO_OWNER)
2136
+ const connID = targets.find((c) => registry.supports(c, feature))
2137
+ if (!connID) throw unsupportedError({ feature, harness: registry.harnessOf(targets[0]!) ?? "unknown" })
2138
+ return connID
2139
+ }
2140
+
2141
+ /** A user message ID from a request (1–300 ID characters), else 400. */
2142
+ function messageIDInput(raw: unknown): string {
2143
+ if (typeof raw !== "string" || !MESSAGE_ID.test(raw)) throw new HttpError(400, "Expected {messageID}: one of the session's user messages")
2144
+ return raw
2145
+ }
2146
+
2147
+ /** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
2148
+ function busyRefusal(cause: unknown): unknown {
2149
+ return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
2150
+ }
2151
+
2152
+ /** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
2153
+ async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2154
+ const messageID = messageIDInput((await body(request)).messageID)
2155
+ const connID = featureOwner(sessionID, "fork")
2156
+ const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
2157
+ await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
2158
+ return Response.json(result, { status: 201, headers: NO_STORE })
2159
+ }
2160
+
2161
+ /** POST …/revert {messageID, files?}: stages a revert (files restored unless files: false); 409 while it runs. */
2162
+ async function revertSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2163
+ const input = await body(request)
2164
+ const messageID = messageIDInput(input.messageID)
2165
+ if (input.files !== undefined && typeof input.files !== "boolean") return error(400, "files must be true or false")
2166
+ const files = input.files !== false
2167
+ const connID = featureOwner(sessionID, "revert")
2168
+ if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
2169
+ const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
2170
+ throw busyRefusal(cause)
2171
+ })
2172
+ await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
2173
+ return Response.json(result, { headers: NO_STORE })
2174
+ }
2175
+
2176
+ /** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
2177
+ async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
2178
+ const connID = featureOwner(sessionID, "revert")
2179
+ await spokes.request(connID, { op, sessionID }).catch((cause) => {
2180
+ throw busyRefusal(cause)
2181
+ })
2182
+ await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
2183
+ return new Response(null, { status: 204 })
2184
+ }
2185
+
1649
2186
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
1650
2187
  async function utterance(request: Request, sessionID: string): Promise<Response> {
1651
2188
  const raw = (await body(request)).text
@@ -1766,17 +2303,27 @@ export function startCentral(options: CentralOptions): Central {
1766
2303
  return Response.json({ sessionID: result.sessionID, ...(openedIn ? { openedIn } : {}), ...(warning ? { warning } : {}) }, { status: 201 })
1767
2304
  }
1768
2305
 
1769
- async function permission(request: Request, rootID: string): Promise<Response> {
2306
+ /**
2307
+ * (CON-13) The user's own answer on a session's card, in the agent audit as actor user (as the acting engine records
2308
+ * a user's act through /api/agent): Settings → Agents → Activity then shows "you denied …" next to the agents'
2309
+ * answers. The asking session (a subagent's request is answered through its root) is the target. Best effort.
2310
+ */
2311
+ const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
2312
+ void audit.record(userID, { actor: { kind: "user" }, verb, target, decision: "allow", outcome: "ok" }).catch((cause: unknown) => console.error(`[hub] audit of a console answer failed: ${(cause as Error).message}`))
2313
+
2314
+ async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
1770
2315
  const input = await body(request)
1771
2316
  const decision = input.decision as PermissionDecision
1772
2317
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
2318
+ const asking = registry.permissionRequest(input.requestID)?.sessionID ?? rootID
1773
2319
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
1774
2320
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2321
+ if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
1775
2322
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1776
2323
  }
1777
2324
 
1778
2325
  /** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
1779
- async function form(request: Request, rootID: string): Promise<Response> {
2326
+ async function form(request: Request, rootID: string, userID: string): Promise<Response> {
1780
2327
  const input = await body(request)
1781
2328
  if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
1782
2329
  const cancel = input.cancel === true
@@ -1785,10 +2332,12 @@ export function startCentral(options: CentralOptions): Central {
1785
2332
  const problem = formAnswerProblem(input.answer)
1786
2333
  if (problem) return error(400, problem)
1787
2334
  }
2335
+ const asking = registry.status(rootID)?.find((s) => s.blockers.some((b) => b.kind === "form" && b.id === input.formID))?.id ?? rootID
1788
2336
  const answered = await actions.answerForm(
1789
2337
  cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
1790
2338
  )
1791
2339
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2340
+ if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
1792
2341
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1793
2342
  }
1794
2343
 
@@ -1798,8 +2347,7 @@ export function startCentral(options: CentralOptions): Central {
1798
2347
  if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
1799
2348
  return error(413, "Recording too large")
1800
2349
  }
1801
- const audio = new Uint8Array(await request.arrayBuffer())
1802
- if (audio.length > config.maxAudioBytes) return error(413, "Recording too large")
2350
+ const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
1803
2351
  if (audio.length === 0) return error(400, "Empty recording")
1804
2352
  return Response.json({ text: await speech.transcribe(audio, format) })
1805
2353
  }
@@ -1809,7 +2357,7 @@ export function startCentral(options: CentralOptions): Central {
1809
2357
  * after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
1810
2358
  * over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
1811
2359
  */
1812
- function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
2360
+ function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; sessions?: readonly string[]; scope?: boolean }): Response {
1813
2361
  // Draining: come back (to another pod) shortly.
1814
2362
  if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
1815
2363
  const encoder = new TextEncoder()
@@ -1827,7 +2375,7 @@ export function startCentral(options: CentralOptions): Central {
1827
2375
  userID,
1828
2376
  deviceID,
1829
2377
  lite: options.lite,
1830
- ...(options.session ? { session: options.session } : {}),
2378
+ ...(options.sessions ? { sessions: options.sessions } : {}),
1831
2379
  send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
1832
2380
  close: () => {
1833
2381
  cleanup()
@@ -1840,6 +2388,12 @@ export function startCentral(options: CentralOptions): Central {
1840
2388
  listener.close()
1841
2389
  },
1842
2390
  }
2391
+ // At once, whatever follows: Bun sends the response head with the first body chunk, and a resumed stream
2392
+ // with nothing to replay would otherwise stay silent until the first heartbeat, so the page's EventSource
2393
+ // would not open (its "Reconnecting…" showing) for up to heartbeatMs (RP-10). A comment: the page ignores it.
2394
+ write(": open\n\n")
2395
+ // Before anything else, so the page can tell a hub that streams several sessions from one that does not.
2396
+ if (options.scope) listener.send({ type: "scope", sessions: options.sessions ?? [] })
1843
2397
  if (live) return clusterStream(listener, write)
1844
2398
  eventLog.open(userID)
1845
2399
  const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
@@ -2012,7 +2566,11 @@ export function startCentral(options: CentralOptions): Central {
2012
2566
  launchTokens,
2013
2567
  webhooks,
2014
2568
  agentEvents,
2569
+ directory,
2570
+ concierge: conciergeService,
2015
2571
  agentEventWriters,
2572
+ intentRetention,
2573
+ teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2016
2574
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
2017
2575
  drain(drainOptions: DrainOptions = {}) {
2018
2576
  draining ??= runDrain(drainOptions)
@@ -2022,7 +2580,12 @@ export function startCentral(options: CentralOptions): Central {
2022
2580
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
2023
2581
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
2024
2582
  launches.shutdown()
2583
+ conciergeSupervisor.stop()
2584
+ conciergeContext.stop()
2585
+ conciergeParts.tiers.stop?.()
2025
2586
  webhooks.stop()
2587
+ artifactMaintenance?.stop()
2588
+ profileApplier?.stop()
2026
2589
  agentEventWriters.stop()
2027
2590
  tokenPush.stop()
2028
2591
  delivery.stop()
@@ -2047,18 +2610,19 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
2047
2610
  return state ? { state } : {}
2048
2611
  }
2049
2612
 
2050
- /** {option?, text?} of a Decision answer (types only; Decisions validates). */
2051
- function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
2613
+ /** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
2614
+ function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
2052
2615
  for (const key of ["option", "text"] as const) {
2053
2616
  if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
2054
2617
  }
2055
- return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
2618
+ if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
2619
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
2056
2620
  }
2057
2621
 
2058
2622
  /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
2059
2623
  function decisionFailure(cause: unknown): Response {
2060
2624
  if (!(cause instanceof AgentError)) throw cause
2061
- const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423 }
2625
+ const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423, offline: 503, unsupported: 422 }
2062
2626
  return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
2063
2627
  }
2064
2628
 
@@ -2172,13 +2736,32 @@ function param(raw: string): string {
2172
2736
 
2173
2737
  const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
2174
2738
 
2739
+ /**
2740
+ * Console and public JSON bodies: at most this many bytes (agent/body.ts bounds the bytes and time read). Generous:
2741
+ * a prompt may carry up to 4 images (MAX_PROMPT_IMAGES; the validator refuses more than MAX_PROMPT_IMAGES_BASE64).
2742
+ */
2743
+ const CONSOLE_JSON_BODY_MAX = 4 << 20
2744
+
2745
+ /** The bytes of a console/public request body, or HttpError 413 (too large) / 408 (too slow). */
2746
+ async function boundedBytes(request: Request, max: number, tooLarge: string): Promise<Uint8Array> {
2747
+ const read = await readBounded(request, max)
2748
+ if (read.ok) return read.bytes
2749
+ if (read.reason === "timeout") throw new HttpError(408, slowMessage(), undefined, true)
2750
+ throw new HttpError(413, tooLarge, undefined, read.close)
2751
+ }
2752
+
2753
+ /** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
2754
+ async function jsonBody(request: Request): Promise<unknown> {
2755
+ return JSON.parse(Buffer.from(await boundedBytes(request, CONSOLE_JSON_BODY_MAX, `Request body too large (at most ${CONSOLE_JSON_BODY_MAX} bytes)`)).toString("utf8"))
2756
+ }
2757
+
2175
2758
  async function body(request: Request): Promise<Record<string, unknown>> {
2176
- const value = (await request.json()) as unknown
2759
+ const value = await jsonBody(request)
2177
2760
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
2178
2761
  }
2179
2762
 
2180
2763
  function failure(cause: unknown): Response {
2181
- if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code)
2764
+ if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code, cause.close ? { connection: "close" } : undefined)
2182
2765
  if (cause instanceof BusyError) return error(409, cause.message)
2183
2766
  if (cause instanceof RateLimitError) return error(429, cause.message)
2184
2767
  if (cause instanceof ValidationError) return error(400, cause.message)