@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. 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,47 @@ 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, timedIdentity } from "./concierge/session-routes"
95
+ import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
96
+ import { ConciergeService, type MachineFacts } from "./concierge/service"
97
+ import { machineModels } from "./concierge/routes"
98
+ import { SettingsStore } from "./concierge/settings"
99
+ import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
100
+ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
101
+ import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
102
+ import { grantState } from "./agent/grants"
103
+ import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
60
104
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
61
105
  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
106
 
@@ -143,10 +187,18 @@ export interface Central {
143
187
  readonly webhooks: Webhooks
144
188
  /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
145
189
  readonly agentEvents: AgentEvents
190
+ /** The agent directory (names, roles, address resolution). */
191
+ readonly directory: Directory
192
+ /** (CON-04) The Concierge service (settings, supervisor, context). */
193
+ readonly concierge: ConciergeService
146
194
  /** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
147
195
  readonly agentEventWriters: EventWriters
196
+ /** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
197
+ readonly intentRetention: IntentRetention
148
198
  /** The JSON API's route table (method, path, access), for audits and tests. */
149
199
  readonly routes: readonly RouteInfo[]
200
+ /** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
201
+ readonly teams: { readonly store: TeamStore; readonly seats: TeamSeatStore; readonly staffing: TeamStaffingService; readonly intake: TeamIntakeService }
150
202
  /**
151
203
  * Draining before a shutdown (design §10; SIGTERM): /readyz fails, new /spoke/ws upgrades are refused, spokes and
152
204
  * nodes get bye{reason:"draining", retryAfterMs} (once their requests in flight are answered), the leader leases
@@ -164,8 +216,8 @@ interface Listener {
164
216
  readonly deviceID: string
165
217
  /** Leaves out streamed text and reasoning (except of `session`'s tree). */
166
218
  readonly lite: boolean
167
- /** The session the page shows: its tree's text and reasoning stream even when lite. */
168
- readonly session?: string
219
+ /** The sessions the page shows (one, or one per visible pane): their trees' text and reasoning stream even when lite. */
220
+ readonly sessions?: readonly string[]
169
221
  /** Sends a message; `id` is its event id (EventSource's lastEventId). */
170
222
  send(message: Message, id?: string): void
171
223
  close(): void
@@ -176,6 +228,23 @@ interface Listener {
176
228
  /** Messages a lite stream leaves out (unless they are of the listener's session tree). */
177
229
  const STREAMED = new Set(["delta", "reasoning"])
178
230
 
231
+ /**
232
+ * The sessions a console stream shows (HWA-07 panes): `session` (the focused one; all an older console sends)
233
+ * then `sessions` (comma-separated, one per visible pane), each once, blanks ignored. At most
234
+ * WORKSPACE_LIMITS.panes; an ID over 300 characters or too many sessions is a problem (400), never truncated.
235
+ */
236
+ function streamSessionsQuery(url: URL): { sessions: string[]; multi: boolean } | { problem: string } {
237
+ const listed = url.searchParams.getAll("sessions").flatMap((v) => v.split(","))
238
+ const sessions: string[] = []
239
+ for (const id of [url.searchParams.get("session") ?? "", ...listed]) {
240
+ if (!id || sessions.includes(id)) continue
241
+ if (id.length > 300) return { problem: "a session ID is too long" }
242
+ sessions.push(id)
243
+ }
244
+ if (sessions.length > WORKSPACE_LIMITS.panes) return { problem: `a stream shows at most ${WORKSPACE_LIMITS.panes} sessions` }
245
+ return { sessions, multi: url.searchParams.has("sessions") }
246
+ }
247
+
179
248
  /**
180
249
  * Who may call a route: anyone, any signed-in user, admins, or the owner of the :session / :machine in the path;
181
250
  * "agent": an /api/agent route (src/central/agent), checked per credential kind there.
@@ -229,6 +298,10 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
229
298
  */
230
299
  export const TURN_ACCEPT_MS = 1500
231
300
  const NO_OWNER = "No OpenCode process for this session is connected"
301
+ /** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
302
+ const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
303
+ /** A message ID in a fork or revert request (as the spoke protocol bounds it). */
304
+ const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
232
305
  const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
233
306
  const NOT_EXPOSED = "Voice is not enabled for this session"
234
307
  /** A permission or question that is not pending any more: someone answered it elsewhere, or it expired. */
@@ -257,7 +330,9 @@ const WEB_NOT_BUILT = [
257
330
  /** Preview text for POST /api/settings/speech/test. */
258
331
  const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to you."
259
332
  const SPEECH_TEST_MAX_CHARS = 300
260
- const APP_ROUTES = /^\/(?:|pair|approve|new|settings|s\/[^/]+)$/
333
+ // Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
334
+ // redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
335
+ const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
261
336
  const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
262
337
  /** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
263
338
  const LOGIN_NEXT = /^\/(?:s\/[A-Za-z0-9_-]+(?:\?voice=1)?)?$/
@@ -302,16 +377,18 @@ class HttpError extends Error {
302
377
  message: string,
303
378
  /** Machine-readable reason, e.g. not_exposed. */
304
379
  readonly code?: string,
380
+ /** Answer with Connection: close (a request body was left unread on the socket: agent/body.ts). */
381
+ readonly close?: boolean,
305
382
  ) {
306
383
  super(message)
307
384
  }
308
385
  }
309
386
 
310
387
  /** 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")
388
+ const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
312
389
 
313
- const error = (status: number, message: string, code?: string) =>
314
- Response.json({ error: message, ...(code ? { code } : {}) }, { status })
390
+ const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
391
+ Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
315
392
 
316
393
  export function startCentral(options: CentralOptions): Central {
317
394
  const { config } = options
@@ -330,7 +407,17 @@ export function startCentral(options: CentralOptions): Central {
330
407
  const registry = new Registry(now, undefined, (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID)
331
408
  const nodes = new NodeRegistry((machineID) => store.machines().find((m) => m.id === machineID)?.ownerID, now)
332
409
  const push = new Push({ store, subject: config.vapidSubject, sender: options.pushSender, kv: backends.kv })
333
- const notifier = options.notifier ?? push
410
+ // CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
411
+ // push remapping all follow it); the service itself is built below, once its parts exist.
412
+ const conciergeStatus = new StatusStore(backends.state, now)
413
+ // (FX-07) Beyond the past sessions the record keeps: those a machine reports locked (its spoke holds them as Concierge sessions).
414
+ const conciergeIdentity = recordIdentity(conciergeStatus, { reported: (userID) => registry.lockedSessions(userID) })
415
+ // (FX-07) The identity checks on request paths (route gate, manage guard, acting guard, inbound/outbound rules, team
416
+ // staffing, Decision and audit labels) wait at most IDENTITY_TIMEOUT_MS for the store: past it they refuse with busy
417
+ // (503), never hang; one shared breaker answers busy at once for a while after a read timed out.
418
+ const conciergeTimedIdentity = timedIdentity(conciergeIdentity)
419
+ // Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
420
+ const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
334
421
  /** SSE subscribers, one user each. */
335
422
  const listeners = new Set<Listener>()
336
423
  /** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
@@ -338,9 +425,10 @@ export function startCentral(options: CentralOptions): Central {
338
425
  /** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
339
426
  const wants = (listener: Listener, message: Message): boolean => {
340
427
  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
428
+ return (listener.sessions ?? []).some((session) => {
429
+ const root = registry.status(session)?.[0]?.id ?? session
430
+ return message.sessionID === session || message.rootID === root || message.sessionID === root
431
+ })
344
432
  }
345
433
  /**
346
434
  * Cluster mode (design §9): a user's events go to every pod over the Bus (user.<id>.live), logged in the shared
@@ -439,7 +527,11 @@ export function startCentral(options: CentralOptions): Central {
439
527
  hostname: config.host,
440
528
  port: config.port,
441
529
  idleTimeout: 255,
442
- maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, 1 << 20),
530
+ // Staged artifact blobs stream up to ARTIFACT_UPLOAD_LIMITS.blobBytes (the routes bound every body themselves).
531
+ // Plus headroom for the drain past a route's bound (BODY_LIMITS.drainBytes, overshot by up to one chunk): Bun ≥ 1.4
532
+ // enforces this limit on chunked bodies too, answering a bare 413 and closing the socket before the route can
533
+ // give its own 400/413. It stays a backstop; the routes' bounds decide.
534
+ maxRequestBodySize: Math.max(config.maxAudioBytes + 1024, ARTIFACT_UPLOAD_LIMITS.blobBytes + 65_536, 1 << 20) + 2 * BODY_LIMITS.drainBytes,
443
535
  fetch: (request, server) => handle(request, server),
444
536
  websocket: {
445
537
  open: (ws) => spokes.websocket.open?.(ws),
@@ -536,7 +628,11 @@ export function startCentral(options: CentralOptions): Central {
536
628
  // Push goes to the session owner's devices only; an alert without an owner goes nowhere.
537
629
  if (!alert.owner) return
538
630
  // Activity updates are collected and throttled per device by the push layer.
539
- if (alert.kind === "activity") return push.activity(alert)
631
+ if (alert.kind === "activity") {
632
+ // The Concierge's own activity is never pushed (its session is hidden).
633
+ void isConciergeSession(conciergeIdentity, alert.owner, alert.sessionID).then((hidden) => (hidden ? undefined : push.activity(alert)))
634
+ return
635
+ }
540
636
  const extra = {
541
637
  ...(alert.requestID ? { requestID: alert.requestID } : {}),
542
638
  ...(alert.subject ? { subject: alert.subject } : {}),
@@ -688,7 +784,25 @@ export function startCentral(options: CentralOptions): Central {
688
784
  }
689
785
  // Agent messages (design §3): delivered through spokes that announce "deliver"; pending ones flush on every
690
786
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
787
+ // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
788
+ let teamSeats: TeamSeatStore | undefined
789
+ const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
790
+ const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
791
+ teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
792
+ const teamPolicy = new TeamPolicy({
793
+ teams: teamStore,
794
+ seats: teamSeats,
795
+ directory,
796
+ denied: async (userID, denial) => {
797
+ const target = denial.recipientID !== undefined ? { sessionID: denial.recipientID } : {}
798
+ const reason = `team policy: ${denial.reason} (${denial.kind})`
799
+ 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))])
800
+ },
801
+ })
691
802
  const delivery = new Delivery({
803
+ teams: teamPolicy,
804
+ // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
805
+ inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
692
806
  state: backends.state,
693
807
  counters: backends.counters,
694
808
  registry,
@@ -706,6 +820,8 @@ export function startCentral(options: CentralOptions): Central {
706
820
  },
707
821
  // Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
708
822
  alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
823
+ // A message pending for 24 h expired undelivered: journaled (message.changed, delivery "expired") for its sender.
824
+ expired: (userID, message) => feed.publish(userID, message),
709
825
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
710
826
  // Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
711
827
  ...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
@@ -714,6 +830,8 @@ export function startCentral(options: CentralOptions): Central {
714
830
  const feed = new MessageFeed()
715
831
  // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
716
832
  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}`))
833
+ /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
834
+ let conciergeTierHooks: ConciergeTiers | undefined
717
835
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
718
836
  const decisions = new Decisions({
719
837
  state: backends.state,
@@ -722,10 +840,27 @@ export function startCentral(options: CentralOptions): Central {
722
840
  users: () => store.users().map((u) => u.id),
723
841
  notify: (userID, decision, reason) => pushUser("decision", userID, decision.sessionID ?? "", `${decision.id}:${reason}`, decision.id),
724
842
  // 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)),
843
+ changed: (userID, decision) => {
844
+ broadcast({ type: "decision", decision }, { tenant: userID })
845
+ conciergeTierHooks?.decisionChanged?.(userID, decision)
846
+ },
847
+ // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
848
+ // (CON-06) Concierge proposals run their verb as the user (all set up below).
849
+ act: (userID, decision, choice, by) => {
850
+ const kind = decision.action?.kind
851
+ if (kind === "act") {
852
+ if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
853
+ return conciergeTierHooks.act(userID, decision, choice, by)
854
+ }
855
+ return kind === "grant" ? grantRequests.act(userID, decision, choice, by) : escalations.act(userID, decision, choice, by)
856
+ },
857
+ // (CON-06) Voice/text answers: only what the Concierge tiers admit (armed, presence, the user's device).
858
+ via: async (userID, decision, via, by) => {
859
+ if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
860
+ await conciergeTierHooks.checkVia(userID, decision, via, by)
861
+ },
862
+ // (FX-07) Time-limited and fail closed: a timed-out read refuses the ask with busy (no label silently dropped).
863
+ concierge: conciergeProtects(conciergeTimedIdentity),
729
864
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
730
865
  })
731
866
  leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
@@ -737,15 +872,26 @@ export function startCentral(options: CentralOptions): Central {
737
872
  }
738
873
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
739
874
  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({
875
+ // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
876
+ const intents = new SessionIntents({
877
+ state: backends.state,
878
+ now,
879
+ events: { append: (userID, event) => agentEvents.append(userID, event) },
880
+ // The owner's consoles refetch the intent (the Details rail's card); the text never rides the stream.
881
+ onChange: (userID, sessionID, source, revision) => broadcast({ type: "intent", sessionID, source, revision }, { tenant: userID }),
882
+ })
883
+ /**
884
+ * Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
885
+ * prompt the hub carries derives the session's intent once (CON-02).
886
+ */
887
+ const actions = withPromptIntents(createActions({
742
888
  registry,
743
889
  spokes,
744
890
  humanTurn,
745
891
  kv: backends.kv,
746
892
  // Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
747
893
  ...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
748
- })
894
+ }), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
749
895
  leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
750
896
  // Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
751
897
  // service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
@@ -754,7 +900,10 @@ export function startCentral(options: CentralOptions): Central {
754
900
  // Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
755
901
  // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
756
902
  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
- launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
903
+ // The user's live view: flagged when the launch's app token was revoked (launches is set below, before any change).
904
+ launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: userID }), { origin: true })
905
+ // Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
906
+ let profileApplier: ProfileApplier | undefined
758
907
  const launches = new LaunchEngine({
759
908
  state: backends.state,
760
909
  counters: backends.counters,
@@ -775,6 +924,16 @@ export function startCentral(options: CentralOptions): Central {
775
924
  users: () => store.users().map((u) => u.id),
776
925
  now,
777
926
  onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
927
+ // FX-02: a waiting text change of the same kind: the console's live stream (and the Concierge's status) only,
928
+ // never the journal or webhooks.
929
+ onLaunchWaiting: (launch) => {
930
+ broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: launch.owner })
931
+ if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
932
+ },
933
+ intents,
934
+ // FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
935
+ launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
936
+ 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
937
  // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
779
938
  ...(config.cluster
780
939
  ? {
@@ -839,11 +998,19 @@ export function startCentral(options: CentralOptions): Central {
839
998
  })
840
999
  // The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
841
1000
  // presence by the agent-events leader, everything else where it happens (event-writers.ts).
1001
+ let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
1002
+ /** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
1003
+ let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
1004
+ // CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
1005
+ // rejects, and the catch keeps it that way: an unhandled rejection would end the process).
1006
+ const conciergeActivity = new ActivityTracker({ state: backends.state, now })
1007
+ // CON-03: the board's last result per root session, from appends (best effort, as above).
1008
+ const conciergeResults = new ResultTracker({ state: backends.state, now })
842
1009
  const agentEvents = new AgentEvents({
843
1010
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
844
1011
  kv: backends.kv,
845
1012
  now,
846
- onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
1013
+ 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
1014
  })
848
1015
  const agentEventWriters = new EventWriters({
849
1016
  events: agentEvents,
@@ -856,13 +1023,58 @@ export function startCentral(options: CentralOptions): Central {
856
1023
  })
857
1024
  agentEventWriters.start()
858
1025
  leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
1026
+ // Persistent artifacts (HWA-21): metadata in the StateStore, bytes in Backends.blobs (files standalone, Postgres
1027
+ // in a cluster (HWA-22); a bundle without blobs answers unsupported). Events go to the journal (family artifact); recovery
1028
+ // and archive purging run on a leader.
1029
+ const artifacts = backends.blobs
1030
+ ? new ArtifactStore({
1031
+ state: backends.state,
1032
+ blobs: backends.blobs,
1033
+ now,
1034
+ workspaces: artifactWorkspacesFromState(backends.state),
1035
+ onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
1036
+ })
1037
+ : undefined
1038
+ const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
1039
+ // Isolated artifact host (HWA-26): /artifact-host/v1/<capability>/… on this process (opaque-origin sandbox).
1040
+ const artifactHostCaps = new ArtifactHostCaps({ state: backends.state, keys: tokenKeys, now })
1041
+ const artifactHostServer = artifactHost({ caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}), consoleOrigins: () => [...origins], deviceActive: (userID, id) => store.devices().some((d) => d.id === id && d.ownerID === userID), now })
1042
+ // (CON-02) Session intent retention (agent/intents.ts sweep), on one leader every 6 h: online sessions (the
1043
+ // registry, whatever the directory holds) are kept; records of deleted sessions and of sessions neither seen nor
1044
+ // written for 30 days are dropped. The directory (at most 2000 records per user) only adds lastSeen/deletedAt.
1045
+ const intentRetention = new IntentRetention({
1046
+ intents,
1047
+ users: allUsers,
1048
+ now,
1049
+ // (CON-10) Run Concierge prompt proposals: the build before CON-09a derived them as the session's intent.
1050
+ proposed: async (userID) =>
1051
+ (await decisions.list(userID, { state: "answered" })).flatMap((d) =>
1052
+ d.as === "concierge" && d.action?.kind === "act" && d.action.verb === "prompt" && d.answer?.option === "go" && typeof d.action.input.text === "string"
1053
+ ? [{ sessionID: d.action.target, text: d.action.input.text, at: d.answer.at }]
1054
+ : [],
1055
+ ),
1056
+ facts: (userID) => {
1057
+ const known = new Map((store.agentData(userID).sessions ?? []).map((s) => [s.sessionID, s]))
1058
+ return (sessionID) => {
1059
+ const s = known.get(sessionID)
1060
+ const lastSeen = s?.lastSeen ?? s?.firstSeen
1061
+ return {
1062
+ online: registry.sessionTenant(sessionID) === userID && registry.hasSession(sessionID),
1063
+ ...(lastSeen !== undefined ? { lastSeen } : {}),
1064
+ ...(s?.deletedAt !== undefined ? { deletedAt: s.deletedAt } : {}),
1065
+ }
1066
+ }
1067
+ },
1068
+ })
1069
+ leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
1070
+ if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
859
1071
  for (const loop of leaders) void loop.start()
860
1072
 
861
1073
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
862
1074
  // Every stored agent message also goes to the user's console streams (live transcript rows).
863
1075
  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 })
1076
+ const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID) })
1077
+ const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
866
1078
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
867
1079
  /** An agent as envelopes name it (a session by its directory name). */
868
1080
  const describeActor = (userID: string, actor: AgentActor) => {
@@ -909,7 +1121,269 @@ export function startCentral(options: CentralOptions): Central {
909
1121
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
910
1122
  })
911
1123
  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 }
1124
+ const profiles: ProfileStore = new ProfileStore({ state: backends.state, now, ack: (userID, assignment, version) => profileApplier!.ackFor(userID, assignment, version) })
1125
+ profileApplier = new ProfileApplier({ profiles, registry, spokes, events: agentEvents, now })
1126
+ profileApplier.start()
1127
+ const workspaceStore = new WorkspaceStore({ state: backends.state, now, ownsMachine: (userID, machineID) => ownMachine(userID, machineID) !== undefined })
1128
+ const workspaceMembers = new MembershipStore({ state: backends.state, workspaces: workspaceStore, now, presence: directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? []) })
1129
+ const workspaceSupervision = new SupervisionStore({ state: backends.state, workspaces: workspaceStore, members: workspaceMembers, now })
1130
+ const workspaceLifecycle = new WorkspaceLifecycle({ workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, events: agentEvents })
1131
+ registry.onSessionDeleted((sessionID, tenant) => void workspaceLifecycle.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] workspace session end failed: ${(cause as Error).message}`)))
1132
+ const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1133
+ // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1134
+ const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
1135
+ const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1136
+ registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1137
+ launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1138
+ // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
1139
+ teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text, options = {}) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, options.hop === undefined ? {} : { chainHop: options.hop })).message.id }), hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }) })
1140
+ // Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
1141
+ // supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
1142
+ const workspaceCursors = journalCursors(tokenKeys, agentEvents)
1143
+ let workspaceCoordinator: SupervisorCoordinator | undefined
1144
+ const workspaceCheckpoints = new CheckpointStore({ ...workspaces, state: backends.state, cursors: workspaceCursors, now, onAck: async (u, w, m, b) => workspaceCoordinator?.acknowledged(u, w, m, b) })
1145
+ const coordinatorLeader = leader("workspace-coordinator", { onElected: () => workspaceCoordinator?.start(), onDeposed: () => workspaceCoordinator?.stop() })
1146
+ workspaceCoordinator = new SupervisorCoordinator({
1147
+ ...workspaces,
1148
+ state: backends.state,
1149
+ events: agentEvents,
1150
+ cursors: workspaceCursors,
1151
+ checkpoints: workspaceCheckpoints,
1152
+ sessions: registryBoardSessions(directory, registry),
1153
+ send: async (userID, sessionID, text, options) => {
1154
+ const sent = await delivery.send(userID, coordinatorActor(userID), sessionID, "msg", text, options)
1155
+ return { messageID: sent.message.id, ...(sent.delivery ? { delivery: sent.delivery } : {}), ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1156
+ },
1157
+ users: allUsers,
1158
+ isLeader: () => coordinatorLeader.isLeader(),
1159
+ owner: leaseOwner,
1160
+ now,
1161
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
1162
+ })
1163
+ void coordinatorLeader.start() // created after the other leaders were started
1164
+ const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
1165
+
1166
+ // ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
1167
+ const conciergeSettings = new SettingsStore(backends.state, now)
1168
+ const conciergeNotes = new NotesStore(backends.state, now)
1169
+ const conciergeLabels = async (userID: string) => {
1170
+ const out = new Map<string, Readonly<Record<string, string>>>()
1171
+ for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
1172
+ return out
1173
+ }
1174
+ // The board's `armed` flag reads the tiers slot (built below).
1175
+ const conciergeArmed = async (userID: string) => conciergeTierHooks?.armedID?.(userID)
1176
+ // (CON-13) The board's inGrant: the tiers' acting engine (made below) checks whether the current Concierge's grant
1177
+ // would approve a pending request once, without acting.
1178
+ let conciergeActing: Acting | undefined
1179
+ const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
1180
+ conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
1181
+ 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 }
1182
+ const conciergeRevisions = new BoardRevisions(backends.state)
1183
+ const conciergeContext = new ContextPusher({
1184
+ registry,
1185
+ send: (connID, req) => spokes.request(connID, req),
1186
+ board: async (userID) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)).rendered,
1187
+ notes: conciergeNotes,
1188
+ settings: conciergeSettings,
1189
+ grant: async (userID, grantID) => {
1190
+ try {
1191
+ const grant = await grants.get(userID, grantID)
1192
+ return { ...grant, state: grantState(grant, now()) }
1193
+ } catch {
1194
+ return undefined
1195
+ }
1196
+ },
1197
+ // (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
1198
+ voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
1199
+ now,
1200
+ })
1201
+ /** The user's machines as the Concierge's eligibility sees them (store, nodes, registry). */
1202
+ const conciergeMachines = (userID: string): MachineFacts[] => {
1203
+ const online = new Set(registry.catalog().map((m) => m.id))
1204
+ return store
1205
+ .machines()
1206
+ .filter((m) => !m.revoked && m.ownerID === userID)
1207
+ .map((m) => {
1208
+ const node = nodes.forMachine(m.id)
1209
+ const own = node && node.ownerID === userID ? node : undefined
1210
+ const conn = registry.serviceFor(m.id)
1211
+ return {
1212
+ id: m.id,
1213
+ name: m.name,
1214
+ online: online.has(m.id) || own !== undefined,
1215
+ ...(own ? { node: { runtimes: own.runtimes, workspaceRoots: own.workspaceRoots, draining: own.draining } } : {}),
1216
+ ...(conn !== undefined ? { service: { launch: registry.supports(conn, "launch"), sessionContext: registry.supports(conn, "sessionContext") } } : {}),
1217
+ }
1218
+ })
1219
+ }
1220
+ let conciergeService: ConciergeService | undefined
1221
+ let conciergeModulesNow: ReturnType<typeof conciergeModules> | undefined
1222
+ const conciergeSupervisor = new Supervisor({
1223
+ state: backends.state,
1224
+ settings: conciergeSettings,
1225
+ launches,
1226
+ directory,
1227
+ grants,
1228
+ eligibility: (userID, machineID) => conciergeService!.eligibility(userID, machineID),
1229
+ entitlements: C1_ENTITLEMENTS,
1230
+ context: { push: (userID, sessionID) => conciergeContext.push(userID, sessionID, { force: true }).then(() => undefined), clear: (userID, sessionID) => conciergeContext.clear(userID, sessionID), retire: async (userID, sessionID) => {
1231
+ // (CON-10) Hub updates still pending for it are dropped: never delivered to (or shown waiting for) an ended session.
1232
+ const dropped = await delivery.dropPending(userID, sessionID).catch((cause) => (console.error(`[hub] concierge pending drop failed: ${(cause as Error).message}`), 0))
1233
+ // Audited as a count only (never the updates' text).
1234
+ if (dropped > 0) await store.audit({ actor: userID, action: "concierge.updates_dropped", target: sessionID, detail: String(dropped) }).catch(() => undefined)
1235
+ await conciergeContext.retire(userID, sessionID)
1236
+ } },
1237
+ onStatus: (userID, status) => {
1238
+ broadcast({ type: "concierge", status }, { tenant: userID })
1239
+ 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}`))
1240
+ if (status.state === "disabled") void conciergeModulesNow?.notifier.disabled(userID).catch(() => undefined)
1241
+ },
1242
+ audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
1243
+ // A session that becomes the Concierge leaves every workspace (member, master) and team seat it held: hub-owned.
1244
+ onAdopted: async (userID, sessionID) => {
1245
+ const [members, seats] = await Promise.all([workspaceLifecycle.sessionEnded(userID, sessionID), teamStaffing.sessionEnded(userID, sessionID)])
1246
+ if (members.length || seats.length) await store.audit({ actor: userID, action: "concierge.detached", target: sessionID, detail: `${members.length} workspace memberships, ${seats.length} team seats` })
1247
+ },
1248
+ onSuspended: (userID, sessionID) => void notifier.notify("concierge", sessionID, "Concierge", undefined, userID, { key: `suspended:${sessionID}` }).catch(() => 0),
1249
+ users: allUsers,
1250
+ now,
1251
+ })
1252
+ conciergeService = new ConciergeService({
1253
+ settings: conciergeSettings,
1254
+ notes: conciergeNotes,
1255
+ supervisor: conciergeSupervisor,
1256
+ grants,
1257
+ machines: conciergeMachines,
1258
+ entitlements: C1_ENTITLEMENTS,
1259
+ audit: async (userID, action) => void (await store.audit({ actor: userID, action })),
1260
+ // The file store buffers writes (FILE_STATE_DEBOUNCE_MS); Postgres writes through.
1261
+ ...(backends.state.flush ? { flush: () => backends.state.flush!() } : {}),
1262
+ })
1263
+ const conciergePorts: ConciergePorts = {
1264
+ identity: conciergeIdentity,
1265
+ status: (userID) => conciergeSupervisor.status(userID),
1266
+ settings: (userID) => conciergeSettings.get(userID),
1267
+ beforeWake: async (userID) => {
1268
+ const { sessionID } = await conciergeIdentity.current(userID)
1269
+ return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
1270
+ },
1271
+ send: async (userID, text, sendOptions) => {
1272
+ const { sessionID } = await conciergeIdentity.current(userID)
1273
+ if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
1274
+ const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
1275
+ return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1276
+ },
1277
+ entitlements: C1_ENTITLEMENTS,
1278
+ users: allUsers,
1279
+ // CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
1280
+ journal: {
1281
+ events: agentEvents,
1282
+ state: backends.state,
1283
+ watched: (userID, hidden) => watchedSessions(conciergeBoardDeps, userID, hidden),
1284
+ boardRevision: async (userID, hidden) => (await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, hidden)).board.revision,
1285
+ decisionOf: async (userID, messageID) => {
1286
+ const decision = await decisions.byMessage(userID, messageID)
1287
+ return decision ? { id: decision.id, about: decision.about } : undefined
1288
+ },
1289
+ isLeader: () => conciergeLeader.isLeader(),
1290
+ owner: leaseOwner,
1291
+ now,
1292
+ ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
1293
+ },
1294
+ // CON-06: proposals run through the same acting engine as the act verbs (agent/routes.ts actVerbRoutes).
1295
+ tiers: {
1296
+ state: backends.state,
1297
+ counters: backends.counters,
1298
+ decisions,
1299
+ 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(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity) })),
1300
+ audit,
1301
+ protects: conciergeProtects(conciergeIdentity),
1302
+ published: (userID, message) => feed.publish(userID, message),
1303
+ describe: describeActor,
1304
+ present: (deviceID, sessionID) => registry.present(deviceID, sessionID),
1305
+ deviceOwner: (deviceID) => store.devices().find((d) => d.id === deviceID)?.ownerID,
1306
+ // The board shows the armed Decision: refresh the Concierge's context too.
1307
+ armed: (userID, armed) => (broadcast({ type: "concierge", armed }, { tenant: userID }), conciergeTouch(userID)),
1308
+ now,
1309
+ },
1310
+ }
1311
+ const conciergeParts = conciergeModules(conciergePorts)
1312
+ conciergeModulesNow = conciergeParts
1313
+ conciergeTierHooks = conciergeParts.tiers
1314
+ /** The user's current Concierge session, for a debounced context refresh. */
1315
+ const conciergeTouch = (userID: string, soon = false) => conciergeContext.touch(userID, async () => (await conciergeIdentity.current(userID)).sessionID, soon ? { soon } : {})
1316
+ conciergeOnAppend = (userID, event) => {
1317
+ try {
1318
+ // (CON-13) A blocker opened or closed reaches the Concierge's board within a second.
1319
+ conciergeTouch(userID, event.type === "blocker.opened" || event.type === "blocker.closed")
1320
+ conciergeParts.notifier.observe(userID, event)
1321
+ } catch (cause) {
1322
+ console.error(`[hub] concierge append hook failed: ${(cause as Error).message}`)
1323
+ }
1324
+ }
1325
+ // Kicks: a Concierge launch changed, a node connected, a spoke reported its sessions (also re-sends the context
1326
+ // to a restarted spoke: a new connection reports the session).
1327
+ launchFeed.subscribe((userID, launch) => (isConciergeLaunch(launch) ? conciergeSupervisor.kick(userID) : undefined), { origin: true })
1328
+ nodes.subscribe((event) => (event.type === "connected" && event.node.ownerID !== undefined ? conciergeSupervisor.kick(event.node.ownerID) : undefined), { remote: true })
1329
+ /**
1330
+ * What the spoke reports about the user's current Concierge session (each sessions frame): on another agent than
1331
+ * `concierge` → suspended at once (grant revoked, authority gone; only the user restarts it); without the
1332
+ * context revision the hub sent (a restarted or reconnected spoke) → the context is re-sent now, not debounced.
1333
+ */
1334
+ const conciergeForced = new Map<string, number>()
1335
+ const conciergeReported = async (tenant: string) => {
1336
+ const { sessionID, hiddenSessionIDs } = await conciergeIdentity.current(tenant)
1337
+ // (CON-09a) A previous Concierge session a spoke reports without the retired context's revision (a restarted
1338
+ // spoke, or one that lost it) is retired again (locked, powerless): forced, at most once per CONCIERGE_FORCED_PUSH_MS.
1339
+ for (const id of hiddenSessionIDs) {
1340
+ if (id === sessionID || registry.sessionTenant(id) !== tenant) continue
1341
+ const view = registry.session(id)
1342
+ if (!view || view.contextRevision === conciergeContext.revisionOf(tenant, id)) continue
1343
+ // A reported revision that is not ours: the spoke lost it, so the same connection gets it again (forced).
1344
+ // None reported (a new connection, an older spoke): sent once per connection (not forced).
1345
+ const force = view.contextRevision !== undefined
1346
+ const key = `retire\n${tenant}\n${id}`
1347
+ const at = now()
1348
+ if (force && at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) continue
1349
+ // Re-read: a relaunch may have adopted this very session meanwhile (never retire the current one).
1350
+ if ((await conciergeIdentity.current(tenant)).sessionID === id) continue
1351
+ if (force) conciergeForced.set(key, at)
1352
+ await conciergeContext.retire(tenant, id, { force })
1353
+ }
1354
+ if (sessionID === undefined || registry.sessionTenant(sessionID) !== tenant) return
1355
+ const view = registry.session(sessionID)
1356
+ if (!view) return
1357
+ // OpenCode's internal agents (title, compaction, summary) are not tamper; every other agent is.
1358
+ if (view.agent !== undefined && view.agent !== CONCIERGE_AGENT && !INTERNAL_AGENTS.has(view.agent)) {
1359
+ if (!KNOWN_PRIMARY_AGENTS.has(view.agent)) console.warn(`[hub] concierge session ${sessionID} reported on unknown agent ${view.agent.slice(0, 60)}: suspending`)
1360
+ await conciergeSupervisor.suspend(tenant, sessionID, CONCIERGE_TAMPERED)
1361
+ await backends.state.flush?.()
1362
+ return
1363
+ }
1364
+ const revision = conciergeContext.revisionOf(tenant, sessionID)
1365
+ if (revision !== undefined && view.contextRevision === revision) return
1366
+ // At most one forced push per session every CONCIERGE_FORCED_PUSH_MS (the debounced refresh still runs).
1367
+ const key = `${tenant}\n${sessionID}`
1368
+ const at = now()
1369
+ if (at - (conciergeForced.get(key) ?? -Infinity) < CONCIERGE_FORCED_PUSH_MS) return
1370
+ conciergeForced.set(key, at)
1371
+ if (conciergeForced.size > 1000) conciergeForced.delete(conciergeForced.keys().next().value as string)
1372
+ await conciergeContext.push(tenant, sessionID, { force: true })
1373
+ }
1374
+ registry.onSessions((connID) => {
1375
+ const tenant = registry.connTenant(connID)
1376
+ if (tenant === undefined) return
1377
+ conciergeSupervisor.kick(tenant)
1378
+ conciergeTouch(tenant)
1379
+ void conciergeReported(tenant).catch((cause) => console.error(`[hub] concierge report check failed: ${(cause as Error).message}`))
1380
+ })
1381
+ const conciergeLeader = leader("concierge", {
1382
+ onElected: () => (conciergeSupervisor.start(), conciergeParts.notifier.start()),
1383
+ onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1384
+ })
1385
+ void conciergeLeader.start()
1386
+ 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: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
913
1387
  const resolveActor = createResolver({
914
1388
  cookieName: COOKIE,
915
1389
  findDevice: (token) => deviceOfToken(token),
@@ -926,6 +1400,8 @@ export function startCentral(options: CentralOptions): Central {
926
1400
  void store.touchUser(user.id)
927
1401
  },
928
1402
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
1403
+ // CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
1404
+ gate: conciergeSessionGate(conciergeTimedIdentity, () => conciergeParts.tiers.routes()),
929
1405
  })
930
1406
 
931
1407
  // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
@@ -943,11 +1419,12 @@ export function startCentral(options: CentralOptions): Central {
943
1419
  const { pathname } = url
944
1420
  const method = request.method
945
1421
  try {
946
- if (method === "GET" && pathname === "/readyz") return readyz()
947
- if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
948
- if (method === "GET" && pathname === "/healthz") {
949
- return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
1422
+ // HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
1423
+ if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
1424
+ const response = pathname === "/readyz" ? await readyz() : healthz()
1425
+ return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response
950
1426
  }
1427
+ if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
951
1428
  // A shared (cluster) store loads its records on first use; the file store is ready at once.
952
1429
  await store.ready()
953
1430
  if (method === "POST" && pathname === "/spoke/enroll") {
@@ -987,6 +1464,7 @@ export function startCentral(options: CentralOptions): Central {
987
1464
  if (method === "GET" && pathname === "/login") return login(request, url)
988
1465
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
989
1466
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
1467
+ if (isArtifactHostPath(pathname)) return await artifactHostServer.handle(request, url)
990
1468
  if (pathname === MCP_PATH) return await mcp(request)
991
1469
  if (isAgentPath(pathname)) return await agent.handle(request, url)
992
1470
  if (pathname.startsWith("/api/")) return await api(request, server, url)
@@ -1093,19 +1571,24 @@ export function startCentral(options: CentralOptions): Central {
1093
1571
  ),
1094
1572
  route("GET", "/api/machines", "user", ({ user }) => Response.json({ machines: machines(user.id) })),
1095
1573
  route("GET", "/api/events", "user", ({ request, server, user, device, url }) => {
1574
+ const shown = streamSessionsQuery(url)
1575
+ if ("problem" in shown) return error(400, shown.problem)
1096
1576
  server.timeout(request, 0)
1097
- const session = url.searchParams.get("session")
1577
+ // Only the user's own sessions; others are left out silently (no hint whether they exist).
1578
+ const sessions = shown.sessions.filter((session) => registry.owns(user.id, session))
1098
1579
  return events(request.signal, user.id, device.id, {
1099
1580
  lastID: request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined,
1100
1581
  lite: url.searchParams.get("lite") === "1",
1101
- ...(session && session.length <= 300 && registry.owns(user.id, session) ? { session } : {}),
1582
+ ...(sessions.length ? { sessions } : {}),
1583
+ // A pane console learns what is streamed (an older console, with ?session= only, gets no new message).
1584
+ scope: shown.multi,
1102
1585
  })
1103
1586
  }),
1104
1587
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
1105
1588
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
1106
1589
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
1107
1590
  const before = new Set(store.viewPrefs(user.id).hiddenSessions)
1108
- const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1591
+ const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await jsonBody(request)))
1109
1592
  // The user's other devices only: never another user, and not the device that made the change.
1110
1593
  toUser(user.id, { type: "prefs", prefs }, device.id)
1111
1594
  // Archive changes made in the console go to the agent event journal like the agent API's.
@@ -1220,9 +1703,12 @@ export function startCentral(options: CentralOptions): Central {
1220
1703
  // The caller's own settings (pairing mode: the single owner's are the server default).
1221
1704
  route("GET", "/api/settings/speech", "user", ({ user }) => Response.json(speechSettings.view(speechScope(user.id)), { headers: NO_STORE })),
1222
1705
  // 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
- ),
1706
+ route("PUT", "/api/settings/speech", "user", async ({ request, user }) => {
1707
+ const view = await speechSettings.update(await jsonBody(request), speechScope(user.id))
1708
+ // (CON-10) The Concierge's instructions mention voice only while speech is ready: refresh its context.
1709
+ conciergeTouch(user.id)
1710
+ return Response.json(view, { headers: NO_STORE })
1711
+ }),
1226
1712
  route("GET", "/api/settings/speech/models", "user", async ({ url, user }) => {
1227
1713
  const kind = url.searchParams.get("kind")
1228
1714
  if (kind !== "stt" && kind !== "tts") return error(400, "Expected kind=stt or kind=tts")
@@ -1267,6 +1753,16 @@ export function startCentral(options: CentralOptions): Central {
1267
1753
  return Response.json(stopped.result, { headers: NO_STORE })
1268
1754
  }),
1269
1755
  route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
1756
+ // Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
1757
+ route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
1758
+ route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
1759
+ const messageID = messageIDInput(url.searchParams.get("messageID"))
1760
+ const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
1761
+ return Response.json(preview, { headers: NO_STORE })
1762
+ }),
1763
+ route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
1764
+ route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
1765
+ route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
1270
1766
  route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session!), true),
1271
1767
  route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
1272
1768
  Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
@@ -1281,7 +1777,7 @@ export function startCentral(options: CentralOptions): Central {
1281
1777
  return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
1282
1778
  }),
1283
1779
  route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
1284
- const change = modelPrefsInput(await request.json())
1780
+ const change = modelPrefsInput(await jsonBody(request))
1285
1781
  await checkModelSelections(ownerOf(params.session!), params.session!, change)
1286
1782
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
1287
1783
  const prefs = await store.setModelPrefs(user.id, params.session!, change)
@@ -1332,8 +1828,8 @@ export function startCentral(options: CentralOptions): Central {
1332
1828
  "session",
1333
1829
  async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
1334
1830
  ),
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!)),
1831
+ route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
1832
+ route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
1337
1833
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
1338
1834
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
1339
1835
  route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
@@ -1402,6 +1898,8 @@ export function startCentral(options: CentralOptions): Central {
1402
1898
  const decision = await decisions.get(user.id, params.id!)
1403
1899
  return decision ? Response.json({ decision }, { headers: NO_STORE }) : error(404, "No such decision", "not_found")
1404
1900
  }),
1901
+ // CON-06: `via: "voice" | "text"` (the Concierge panel) only for an armed confirm Decision on a device present on
1902
+ // the current Concierge session (tiers.ts); else 403 "Answer this decision with a tap".
1405
1903
  route("POST", "/api/decisions/:id/answer", "user", async ({ request, params, user, device }) => {
1406
1904
  const input = await body(request)
1407
1905
  const via = (await resolveActor.mode(request)) === "bearer" ? "device-token" : "cookie"
@@ -1416,7 +1914,7 @@ export function startCentral(options: CentralOptions): Central {
1416
1914
  }),
1417
1915
  route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
1418
1916
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
1419
- const view = await speechSettings.update(await request.json())
1917
+ const view = await speechSettings.update(await jsonBody(request))
1420
1918
  await store.audit({ actor: user.id, action: "speech.default" })
1421
1919
  return Response.json(view, { headers: NO_STORE })
1422
1920
  }),
@@ -1646,6 +2144,64 @@ export function startCentral(options: CentralOptions): Central {
1646
2144
  return connID
1647
2145
  }
1648
2146
 
2147
+ /**
2148
+ * The process that runs a fork or revert op: the session's owner, else another process reporting it, that
2149
+ * announced the feature; 409 unsupported {feature, harness} when none did (another harness, an older plugin, or
2150
+ * an OpenCode process that cannot reach its own server).
2151
+ */
2152
+ function featureOwner(sessionID: string, feature: "fork" | "revert"): string {
2153
+ const targets = registry.sessionTargets(sessionID)
2154
+ if (!targets.length) throw new HttpError(409, NO_OWNER)
2155
+ const connID = targets.find((c) => registry.supports(c, feature))
2156
+ if (!connID) throw unsupportedError({ feature, harness: registry.harnessOf(targets[0]!) ?? "unknown" })
2157
+ return connID
2158
+ }
2159
+
2160
+ /** A user message ID from a request (1–300 ID characters), else 400. */
2161
+ function messageIDInput(raw: unknown): string {
2162
+ if (typeof raw !== "string" || !MESSAGE_ID.test(raw)) throw new HttpError(400, "Expected {messageID}: one of the session's user messages")
2163
+ return raw
2164
+ }
2165
+
2166
+ /** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
2167
+ function busyRefusal(cause: unknown): unknown {
2168
+ return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
2169
+ }
2170
+
2171
+ /** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
2172
+ async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2173
+ const messageID = messageIDInput((await body(request)).messageID)
2174
+ const connID = featureOwner(sessionID, "fork")
2175
+ const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
2176
+ await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
2177
+ return Response.json(result, { status: 201, headers: NO_STORE })
2178
+ }
2179
+
2180
+ /** POST …/revert {messageID, files?}: stages a revert (files restored unless files: false); 409 while it runs. */
2181
+ async function revertSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2182
+ const input = await body(request)
2183
+ const messageID = messageIDInput(input.messageID)
2184
+ if (input.files !== undefined && typeof input.files !== "boolean") return error(400, "files must be true or false")
2185
+ const files = input.files !== false
2186
+ const connID = featureOwner(sessionID, "revert")
2187
+ if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
2188
+ const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
2189
+ throw busyRefusal(cause)
2190
+ })
2191
+ await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
2192
+ return Response.json(result, { headers: NO_STORE })
2193
+ }
2194
+
2195
+ /** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
2196
+ async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
2197
+ const connID = featureOwner(sessionID, "revert")
2198
+ await spokes.request(connID, { op, sessionID }).catch((cause) => {
2199
+ throw busyRefusal(cause)
2200
+ })
2201
+ await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
2202
+ return new Response(null, { status: 204 })
2203
+ }
2204
+
1649
2205
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
1650
2206
  async function utterance(request: Request, sessionID: string): Promise<Response> {
1651
2207
  const raw = (await body(request)).text
@@ -1766,17 +2322,27 @@ export function startCentral(options: CentralOptions): Central {
1766
2322
  return Response.json({ sessionID: result.sessionID, ...(openedIn ? { openedIn } : {}), ...(warning ? { warning } : {}) }, { status: 201 })
1767
2323
  }
1768
2324
 
1769
- async function permission(request: Request, rootID: string): Promise<Response> {
2325
+ /**
2326
+ * (CON-13) The user's own answer on a session's card, in the agent audit as actor user (as the acting engine records
2327
+ * a user's act through /api/agent): Settings → Agents → Activity then shows "you denied …" next to the agents'
2328
+ * answers. The asking session (a subagent's request is answered through its root) is the target. Best effort.
2329
+ */
2330
+ const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
2331
+ 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}`))
2332
+
2333
+ async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
1770
2334
  const input = await body(request)
1771
2335
  const decision = input.decision as PermissionDecision
1772
2336
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
2337
+ const asking = registry.permissionRequest(input.requestID)?.sessionID ?? rootID
1773
2338
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
1774
2339
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2340
+ if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
1775
2341
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1776
2342
  }
1777
2343
 
1778
2344
  /** 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> {
2345
+ async function form(request: Request, rootID: string, userID: string): Promise<Response> {
1780
2346
  const input = await body(request)
1781
2347
  if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
1782
2348
  const cancel = input.cancel === true
@@ -1785,10 +2351,12 @@ export function startCentral(options: CentralOptions): Central {
1785
2351
  const problem = formAnswerProblem(input.answer)
1786
2352
  if (problem) return error(400, problem)
1787
2353
  }
2354
+ const asking = registry.status(rootID)?.find((s) => s.blockers.some((b) => b.kind === "form" && b.id === input.formID))?.id ?? rootID
1788
2355
  const answered = await actions.answerForm(
1789
2356
  cancel ? { rootID, formID: input.formID, cancel: true } : { rootID, formID: input.formID, answer: input.answer as FormAnswer },
1790
2357
  )
1791
2358
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
2359
+ if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
1792
2360
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
1793
2361
  }
1794
2362
 
@@ -1798,8 +2366,7 @@ export function startCentral(options: CentralOptions): Central {
1798
2366
  if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
1799
2367
  return error(413, "Recording too large")
1800
2368
  }
1801
- const audio = new Uint8Array(await request.arrayBuffer())
1802
- if (audio.length > config.maxAudioBytes) return error(413, "Recording too large")
2369
+ const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
1803
2370
  if (audio.length === 0) return error(400, "Empty recording")
1804
2371
  return Response.json({ text: await speech.transcribe(audio, format) })
1805
2372
  }
@@ -1809,7 +2376,7 @@ export function startCentral(options: CentralOptions): Central {
1809
2376
  * after it when its log still covers the gap (one fresh catalog if catalogs changed meanwhile), else it starts
1810
2377
  * over: the catalog, then a `resync` marker so the page refetches what it shows. Heartbeats are `ping` events.
1811
2378
  */
1812
- function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; session?: string }): Response {
2379
+ function events(signal: AbortSignal, userID: string, deviceID: string, options: { lastID?: string; lite: boolean; sessions?: readonly string[]; scope?: boolean }): Response {
1813
2380
  // Draining: come back (to another pod) shortly.
1814
2381
  if (draining) return new Response(SSE_RETRY, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } })
1815
2382
  const encoder = new TextEncoder()
@@ -1827,7 +2394,7 @@ export function startCentral(options: CentralOptions): Central {
1827
2394
  userID,
1828
2395
  deviceID,
1829
2396
  lite: options.lite,
1830
- ...(options.session ? { session: options.session } : {}),
2397
+ ...(options.sessions ? { sessions: options.sessions } : {}),
1831
2398
  send: (message, id) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(message)}\n\n`),
1832
2399
  close: () => {
1833
2400
  cleanup()
@@ -1840,6 +2407,12 @@ export function startCentral(options: CentralOptions): Central {
1840
2407
  listener.close()
1841
2408
  },
1842
2409
  }
2410
+ // At once, whatever follows: Bun sends the response head with the first body chunk, and a resumed stream
2411
+ // with nothing to replay would otherwise stay silent until the first heartbeat, so the page's EventSource
2412
+ // would not open (its "Reconnecting…" showing) for up to heartbeatMs (RP-10). A comment: the page ignores it.
2413
+ write(": open\n\n")
2414
+ // Before anything else, so the page can tell a hub that streams several sessions from one that does not.
2415
+ if (options.scope) listener.send({ type: "scope", sessions: options.sessions ?? [] })
1843
2416
  if (live) return clusterStream(listener, write)
1844
2417
  eventLog.open(userID)
1845
2418
  const missed = options.lastID ? eventLog.since(userID, options.lastID) : undefined
@@ -1918,6 +2491,10 @@ export function startCentral(options: CentralOptions): Central {
1918
2491
  }
1919
2492
 
1920
2493
  /** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
2494
+ function healthz(): Response {
2495
+ return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
2496
+ }
2497
+
1921
2498
  async function readyz(): Promise<Response> {
1922
2499
  if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
1923
2500
  if (config.cluster) {
@@ -2012,7 +2589,11 @@ export function startCentral(options: CentralOptions): Central {
2012
2589
  launchTokens,
2013
2590
  webhooks,
2014
2591
  agentEvents,
2592
+ directory,
2593
+ concierge: conciergeService,
2015
2594
  agentEventWriters,
2595
+ intentRetention,
2596
+ teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2016
2597
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
2017
2598
  drain(drainOptions: DrainOptions = {}) {
2018
2599
  draining ??= runDrain(drainOptions)
@@ -2022,7 +2603,12 @@ export function startCentral(options: CentralOptions): Central {
2022
2603
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
2023
2604
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
2024
2605
  launches.shutdown()
2606
+ conciergeSupervisor.stop()
2607
+ conciergeContext.stop()
2608
+ conciergeParts.tiers.stop?.()
2025
2609
  webhooks.stop()
2610
+ artifactMaintenance?.stop()
2611
+ profileApplier?.stop()
2026
2612
  agentEventWriters.stop()
2027
2613
  tokenPush.stop()
2028
2614
  delivery.stop()
@@ -2047,18 +2633,19 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
2047
2633
  return state ? { state } : {}
2048
2634
  }
2049
2635
 
2050
- /** {option?, text?} of a Decision answer (types only; Decisions validates). */
2051
- function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string } {
2636
+ /** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
2637
+ function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
2052
2638
  for (const key of ["option", "text"] as const) {
2053
2639
  if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
2054
2640
  }
2055
- return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
2641
+ if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
2642
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
2056
2643
  }
2057
2644
 
2058
2645
  /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
2059
2646
  function decisionFailure(cause: unknown): Response {
2060
2647
  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 }
2648
+ 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
2649
  return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
2063
2650
  }
2064
2651
 
@@ -2172,13 +2759,32 @@ function param(raw: string): string {
2172
2759
 
2173
2760
  const sleep = (ms: number) => new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
2174
2761
 
2762
+ /**
2763
+ * Console and public JSON bodies: at most this many bytes (agent/body.ts bounds the bytes and time read). Generous:
2764
+ * a prompt may carry up to 4 images (MAX_PROMPT_IMAGES; the validator refuses more than MAX_PROMPT_IMAGES_BASE64).
2765
+ */
2766
+ const CONSOLE_JSON_BODY_MAX = 4 << 20
2767
+
2768
+ /** The bytes of a console/public request body, or HttpError 413 (too large) / 408 (too slow). */
2769
+ async function boundedBytes(request: Request, max: number, tooLarge: string): Promise<Uint8Array> {
2770
+ const read = await readBounded(request, max)
2771
+ if (read.ok) return read.bytes
2772
+ if (read.reason === "timeout") throw new HttpError(408, slowMessage(), undefined, true)
2773
+ throw new HttpError(413, tooLarge, undefined, read.close)
2774
+ }
2775
+
2776
+ /** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
2777
+ async function jsonBody(request: Request): Promise<unknown> {
2778
+ 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"))
2779
+ }
2780
+
2175
2781
  async function body(request: Request): Promise<Record<string, unknown>> {
2176
- const value = (await request.json()) as unknown
2782
+ const value = await jsonBody(request)
2177
2783
  return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
2178
2784
  }
2179
2785
 
2180
2786
  function failure(cause: unknown): Response {
2181
- if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code)
2787
+ if (cause instanceof HttpError) return error(cause.status, cause.message, cause.code, cause.close ? { connection: "close" } : undefined)
2182
2788
  if (cause instanceof BusyError) return error(409, cause.message)
2183
2789
  if (cause instanceof RateLimitError) return error(429, cause.message)
2184
2790
  if (cause instanceof ValidationError) return error(400, cause.message)