@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
@@ -0,0 +1,221 @@
1
+ // CON-04 the Concierge session's per-turn context (contract "Per-turn context"): the plugin's context hook is
2
+ // synchronous, so the hub PUSHES {instructions, board, notes}, tools.deny and lockAgent to the spoke that reports the
3
+ // session (op sessionContext, announced by the OpenCode spoke; the cluster's spokes.request routes it to the pod
4
+ // holding the spoke). The whole set replaces the previous one.
5
+ //
6
+ // - instructions: prompt.ts (versioned constant). board: the grant line, then renderBoard (its header carries "as
7
+ // of <time>", so a context the spoke kept after a failed send shows its age). notes: the Concierge's notes,
8
+ // framed as its own memory. Untrusted session text stays inside the board's «…» framing.
9
+ // - tools.allow: CONCIERGE_TOOLS exactly (hub, read, glob, grep, question): every other tool is removed on every
10
+ // turn, unknown future ones included (fail closed). No subagent/task, execute (Code Mode), web tools, shell, edit,
11
+ // MCP or plugin tools. The Brain connector is not offered in C1 (settings.ts refuses it).
12
+ // - Revisions grow with the clock (max(now, last + 1)), so a restarted hub never sends an older one; a "stale"
13
+ // answer (another pod's clock ran ahead) retries once with a later revision.
14
+ // - Unchanged context is not re-sent to the same connection; a session reported on another connection (a spoke
15
+ // restart or reconnect) gets it again. A failed send keeps the last revision and is retried by the next push.
16
+ // - touch(): board refreshes debounced per user (5 s quiet, 30 s at most; CON-13 `soon`, for a blocker opened or
17
+ // closed: within 1 s, and later plain touches never push it back). beforeWake(): a push awaited at most 2 s
18
+ // before a Concierge wake message (CON-05). Nothing here ever rejects.
19
+ import { CONCIERGE_TOOLS, SESSION_CONTEXT_FEATURE, SESSION_CONTEXT_LIMITS, type SessionContextRequest, type SessionContextResult } from "../../../packages/protocol/src/concierge"
20
+ import type { Clock, Timers } from "../backend/types"
21
+ import type { NotesStore } from "./notes"
22
+ import { conciergeInstructions, grantLine, type GrantLineInput, NOTES_HEADER, RETIRED_INSTRUCTIONS } from "./prompt"
23
+ import type { SettingsStore } from "./settings"
24
+
25
+ export { CONCIERGE_TOOLS } from "../../../packages/protocol/src/concierge"
26
+ export const CONTEXT_TIMING = { quietMs: 5_000, maxMs: 30_000, wakeBudgetMs: 2_000, soonMs: 1_000 } as const
27
+ /** At most one forced re-send (a report without the current revision) per session in this window. */
28
+ export const CONCIERGE_FORCED_PUSH_MS = 2_000
29
+ /** OpenCode's internal agents (title generation, compaction, summaries): never tamper. */
30
+ export const INTERNAL_AGENTS: ReadonlySet<string> = new Set(["title", "compaction", "summary"])
31
+ /** OpenCode's primary agents and the plugin's (an agent outside this list is logged when a Concierge is found on it). */
32
+ export const KNOWN_PRIMARY_AGENTS: ReadonlySet<string> = new Set(["build", "plan", "general", "explore", "voice"])
33
+ const STALE_BUMP_MS = 60_000
34
+
35
+ /** The grant the board header names (Grants.get with its state); undefined when unknown. */
36
+ export interface ContextGrant {
37
+ readonly id: string
38
+ readonly state: "active" | "expired" | "revoked"
39
+ readonly allow: readonly string[]
40
+ readonly scope: { readonly projects: readonly string[] }
41
+ readonly expiresAt: number
42
+ readonly revokedAt?: number
43
+ /** (CON-13) Its permission rules, for the grant line. */
44
+ readonly permissions?: { readonly allow: readonly string[]; readonly never: readonly string[] }
45
+ }
46
+
47
+ export interface ContextDeps {
48
+ readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE): boolean; connTenant(connID: string): string | undefined }
49
+ readonly send: (connID: string, req: SessionContextRequest) => Promise<SessionContextResult>
50
+ /** The rendered board of the user (renderBoard). */
51
+ readonly board: (userID: string) => Promise<string>
52
+ readonly notes: Pick<NotesStore, "get">
53
+ readonly settings: Pick<SettingsStore, "get">
54
+ readonly grant: (userID: string, grantID: string) => Promise<ContextGrant | undefined>
55
+ /** (CON-09a) Speech is set up for the user (the instructions mention voice and voice confirm). Absent: false. */
56
+ readonly voice?: (userID: string) => boolean
57
+ readonly now?: Clock
58
+ readonly timers?: Timers
59
+ }
60
+
61
+ const defaultTimers: Timers = {
62
+ set: (fn, ms) => {
63
+ const handle = setTimeout(fn, ms)
64
+ ;(handle as { unref?: () => void }).unref?.()
65
+ return handle
66
+ },
67
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
68
+ }
69
+
70
+ type Body = Omit<SessionContextRequest, "revision">
71
+
72
+ const cut = (text: string) => (text.length > SESSION_CONTEXT_LIMITS.partChars ? `${text.slice(0, SESSION_CONTEXT_LIMITS.partChars - 1)}…` : text)
73
+
74
+ export class ContextPusher {
75
+ private readonly now: Clock
76
+ private readonly timers: Timers
77
+ private last = 0
78
+ /** Per user + session: what the spoke has (its digest, connection and revision). */
79
+ private readonly sent = new Map<string, { digest: string; connID: string; revision: number }>()
80
+ private readonly debounces = new Map<string, { first: number; due: number; timer: unknown }>()
81
+
82
+ constructor(private readonly deps: ContextDeps) {
83
+ this.now = deps.now ?? Date.now
84
+ this.timers = deps.timers ?? defaultTimers
85
+ }
86
+
87
+ /** The user's connection reporting the session that serves sessionContext (the owner first). */
88
+ private target(userID: string, sessionID: string): string | undefined {
89
+ const { registry } = this.deps
90
+ return registry.sessionTargets(sessionID).find((connID) => registry.connTenant(connID) === userID && registry.supports(connID, SESSION_CONTEXT_FEATURE))
91
+ }
92
+
93
+ private revision(bump = 0): number {
94
+ this.last = Math.max(this.now() + bump, this.last + 1)
95
+ return this.last
96
+ }
97
+
98
+ private async grantInput(userID: string, grantID: string | undefined): Promise<GrantLineInput> {
99
+ if (grantID === undefined) return { state: "none" }
100
+ const grant = await this.deps.grant(userID, grantID)
101
+ if (!grant) return { state: "none" }
102
+ if (grant.state === "active") return { state: "active", id: grant.id, allow: grant.allow, projects: grant.scope.projects, expiresAt: grant.expiresAt, ...(grant.permissions ? { permissions: grant.permissions } : {}) }
103
+ return { state: "ended", id: grant.id, at: grant.revokedAt ?? grant.expiresAt }
104
+ }
105
+
106
+ private async build(userID: string, sessionID: string): Promise<Body> {
107
+ const [settings, notes, board] = await Promise.all([this.deps.settings.get(userID), this.deps.notes.get(userID), this.deps.board(userID)])
108
+ const grant = await this.grantInput(userID, settings.grantID)
109
+ const parts: SessionContextRequest["parts"][number][] = [
110
+ { key: "instructions", text: cut(conciergeInstructions({ voice: this.deps.voice?.(userID) === true, timeZone: settings.timeZone })) },
111
+ { key: "board", text: cut(`${grantLine(grant, settings.timeZone)}\n${board}`) },
112
+ ...(notes.text ? [{ key: "notes" as const, text: cut(`${NOTES_HEADER}\n${notes.text}`) }] : []),
113
+ ]
114
+ return { op: "sessionContext", sessionID, parts, tools: { allow: [...CONCIERGE_TOOLS] }, lockAgent: true }
115
+ }
116
+
117
+ private async send(key: string, connID: string, body: Body, digest: string): Promise<boolean> {
118
+ for (let attempt = 0; attempt < 2; attempt++) {
119
+ const revision = this.revision(attempt ? STALE_BUMP_MS : 0)
120
+ try {
121
+ const result = await this.deps.send(connID, { ...body, revision } as SessionContextRequest)
122
+ this.sent.set(key, { digest, connID, revision: result.revision })
123
+ return true
124
+ } catch (cause) {
125
+ if (attempt === 0 && /stale/i.test((cause as Error)?.message ?? "")) continue
126
+ console.error(`[hub] concierge context push failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
127
+ return false
128
+ }
129
+ }
130
+ return false
131
+ }
132
+
133
+ /** Builds and sends the session's context now (force: even when unchanged). True when the spoke has it. */
134
+ async push(userID: string, sessionID: string, options: { readonly force?: boolean } = {}): Promise<boolean> {
135
+ try {
136
+ const connID = this.target(userID, sessionID)
137
+ if (connID === undefined) return false
138
+ const body = await this.build(userID, sessionID)
139
+ const digest = JSON.stringify(body)
140
+ const key = `${userID}\n${sessionID}`
141
+ const known = this.sent.get(key)
142
+ if (!options.force && known && known.digest === digest && known.connID === connID) return true
143
+ return await this.send(key, connID, body, digest)
144
+ } catch (cause) {
145
+ console.error(`[hub] concierge context build failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
146
+ return false
147
+ }
148
+ }
149
+
150
+ /** A push awaited at most CONTEXT_TIMING.wakeBudgetMs (before a Concierge wake). */
151
+ beforeWake(userID: string, sessionID: string): Promise<boolean> {
152
+ return new Promise<boolean>((resolve) => {
153
+ const timer = this.timers.set(() => resolve(false), CONTEXT_TIMING.wakeBudgetMs)
154
+ void this.push(userID, sessionID).then((ok) => {
155
+ this.timers.clear(timer)
156
+ resolve(ok)
157
+ })
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Something on the user's board may have changed: a push after 5 s quiet (30 s at most from the first touch).
163
+ * (CON-13) `soon` (a blocker opened or closed: the Concierge must not tell the user about a form already answered)
164
+ * caps it at 1 s from now; no later touch moves a push past a cap already set.
165
+ */
166
+ touch(userID: string, session: () => Promise<string | undefined>, options: { readonly soon?: boolean } = {}): void {
167
+ const t = this.now()
168
+ const known = this.debounces.get(userID)
169
+ if (known) this.timers.clear(known.timer)
170
+ const first = known?.first ?? t
171
+ const due = Math.min(known?.due ?? first + CONTEXT_TIMING.maxMs, options.soon ? t + CONTEXT_TIMING.soonMs : Infinity)
172
+ const wait = Math.max(0, Math.min(CONTEXT_TIMING.quietMs, due - t))
173
+ const timer = this.timers.set(() => {
174
+ this.debounces.delete(userID)
175
+ void session()
176
+ .then((sessionID) => (sessionID !== undefined ? this.push(userID, sessionID) : false))
177
+ .catch(() => false)
178
+ }, wait)
179
+ this.debounces.set(userID, { first, due, timer })
180
+ }
181
+
182
+ /** The revision the spoke acknowledged last for the session (undefined: none). */
183
+ revisionOf(userID: string, sessionID: string): number | undefined {
184
+ return this.sent.get(`${userID}\n${sessionID}`)?.revision
185
+ }
186
+
187
+ /**
188
+ * (CON-09a) Retires a previous Concierge session: its context becomes RETIRED_INSTRUCTIONS only, no tools, the
189
+ * agent locked. Like push, unchanged is not re-sent to the same connection unless `force` (the spoke reported
190
+ * another revision: it lost it). True when the spoke has it. Never rejects.
191
+ */
192
+ async retire(userID: string, sessionID: string, options: { readonly force?: boolean } = {}): Promise<boolean> {
193
+ try {
194
+ const connID = this.target(userID, sessionID)
195
+ if (connID === undefined) return false
196
+ const body: Body = { op: "sessionContext", sessionID, parts: [{ key: "instructions", text: RETIRED_INSTRUCTIONS }], tools: { allow: [] }, lockAgent: true }
197
+ const digest = JSON.stringify(body)
198
+ const key = `${userID}\n${sessionID}`
199
+ const known = this.sent.get(key)
200
+ if (!options.force && known && known.digest === digest && known.connID === connID) return true
201
+ return await this.send(key, connID, body, digest)
202
+ } catch (cause) {
203
+ console.error(`[hub] concierge retire failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
204
+ return false
205
+ }
206
+ }
207
+
208
+ /** Drops the session's context on its spoke (disable). Never rejects. */
209
+ async clear(userID: string, sessionID: string): Promise<void> {
210
+ this.sent.delete(`${userID}\n${sessionID}`)
211
+ const connID = this.target(userID, sessionID)
212
+ if (connID === undefined) return
213
+ await this.deps.send(connID, { op: "sessionContext", sessionID, revision: this.revision(), parts: [], clear: true }).catch(() => undefined)
214
+ }
215
+
216
+ /** Forgets everything (the hub stops). */
217
+ stop(): void {
218
+ for (const { timer } of this.debounces.values()) this.timers.clear(timer)
219
+ this.debounces.clear()
220
+ }
221
+ }
@@ -0,0 +1,13 @@
1
+ // CON-04 entitlements hook (contract "C2 hook point (not built)"): the service calls check() on enable and before
2
+ // every launch or relaunch, and record() after each launch, wake or digest. C1 has no licensing, metering or
3
+ // hosting: the default always answers ok and records nothing.
4
+
5
+ export interface ConciergeEntitlements {
6
+ check(userID: string): Promise<{ ok: true } | { ok: false; reason: string }>
7
+ record(userID: string, usage: { kind: "launch" | "wake" | "digest"; n: number }): void
8
+ }
9
+
10
+ export const C1_ENTITLEMENTS: ConciergeEntitlements = {
11
+ check: async () => ({ ok: true }),
12
+ record: () => undefined,
13
+ }
@@ -0,0 +1,120 @@
1
+ // CON-04 manage paths that must not touch a Concierge session (current or hidden): its identity, prompt, presence
2
+ // and placement are the hub's. Apps and sessions (the Concierge itself included) get 403 forbidden {policy:
3
+ // "concierge"} on intent, archive and pin. EVERYONE (the user too) is refused on naming (the Concierge holds no
4
+ // @name), profile pinning (its prompt is hub-owned), and on making it a workspace member or master (by sessionID,
5
+ // @name, @@role or launchID), a side-chat source, or a team seat (attach, replace): it is hub-owned. Assignments
6
+ // and supervision name members, which it can never be. Role claims are guarded in directory-routes.ts, grants in
7
+ // grants.ts, messages in inbound.ts, act verbs in acting.ts.
8
+ //
9
+ // guardConciergeRoutes wraps the routes listed in CONCIERGE_GUARDED: before the route runs, it resolves the
10
+ // targets (the `:addr` path parameter, the listed body fields of a bounded clone of the body, or the calling
11
+ // session when a self route names none) for the caller's user and refuses when one is a Concierge session.
12
+ // Unresolvable targets are left to the route (its own not_found / invalid).
13
+ import type { AgentActor } from "../agent/actor"
14
+ import { boundedJson } from "../agent/body"
15
+ import { AgentError } from "../agent/errors"
16
+ import type { AgentContext, AgentRoute } from "../agent/routes"
17
+ import type { ConciergeIdentity } from "./watchlist-routes"
18
+
19
+ export interface GuardRule {
20
+ readonly method: string
21
+ readonly path: string
22
+ /** The user is refused too. */
23
+ readonly everyone?: boolean
24
+ /** The `:addr` path parameter names the target. */
25
+ readonly addr?: boolean
26
+ /** Body fields naming the target (an address or a session ID). */
27
+ readonly body?: readonly string[]
28
+ /** Body fields naming a launch: its session, and any Concierge launch itself, is the target. */
29
+ readonly launch?: readonly string[]
30
+ /** A session caller that names no target acts on itself. */
31
+ readonly self?: boolean
32
+ }
33
+
34
+ const A = "/api/agent"
35
+ export const CONCIERGE_GUARDED: readonly GuardRule[] = [
36
+ { method: "PUT", path: `${A}/sessions/:addr/intent`, addr: true },
37
+ { method: "PUT", path: `${A}/sessions/:addr/archive`, addr: true },
38
+ { method: "PUT", path: `${A}/sessions/:addr/pin`, addr: true },
39
+ { method: "POST", path: `${A}/name`, everyone: true, body: ["session"], self: true },
40
+ { method: "PUT", path: `${A}/sessions/:addr/profile`, everyone: true, addr: true },
41
+ { method: "POST", path: `${A}/workspaces/:id/members`, everyone: true, body: ["sessionID"] },
42
+ { method: "POST", path: `${A}/workspaces/:id/master`, everyone: true, body: ["sessionID"], launch: ["launchID"] },
43
+ { method: "POST", path: `${A}/workspaces/:id/side-chats`, everyone: true, body: ["sourceSessionID"] },
44
+ { method: "PUT", path: `${A}/teams/:id/staffing`, everyone: true, body: ["sessionID"] },
45
+ { method: "POST", path: `${A}/teams/:id/staffing/:staffing/replace`, everyone: true, body: ["sessionID"] },
46
+ ]
47
+
48
+ export const conciergeRefusal = (message = "This does not apply to the Concierge: the hub manages it") => new AgentError("forbidden", message, { extra: { policy: "concierge" } })
49
+
50
+ export interface GuardDeps {
51
+ readonly identity: ConciergeIdentity
52
+ /** The session IDs an address names for the user (empty when it names none). */
53
+ readonly resolve: (userID: string, addr: string) => readonly string[]
54
+ /** A launch of the user: its session and whether it is a Concierge launch (undefined: unknown). */
55
+ readonly launch?: (userID: string, launchID: string) => Promise<{ readonly sessionID?: string; readonly concierge: boolean } | undefined>
56
+ }
57
+
58
+ const CONCIERGE_LAUNCH = "\u0000concierge-launch"
59
+
60
+ async function targets(rule: GuardRule, context: AgentContext, deps: GuardDeps): Promise<string[]> {
61
+ const { user, actor, params, request } = context
62
+ const out: string[] = []
63
+ const add = (addr: unknown) => {
64
+ if (typeof addr !== "string" || !addr) return
65
+ out.push(addr)
66
+ try {
67
+ out.push(...deps.resolve(user.id, addr))
68
+ } catch {
69
+ // Unknown or malformed: the route answers it.
70
+ }
71
+ }
72
+ if (rule.addr && params.addr !== undefined) add(params.addr)
73
+ let named = false
74
+ if (rule.body || rule.launch) {
75
+ let body: unknown
76
+ try {
77
+ body = await boundedJson(request.clone())
78
+ } catch {
79
+ body = undefined
80
+ }
81
+ if (typeof body === "object" && body !== null) {
82
+ for (const key of rule.body ?? []) {
83
+ const value = (body as Record<string, unknown>)[key]
84
+ if (value !== undefined) named = true
85
+ add(value)
86
+ }
87
+ for (const key of rule.launch ?? []) {
88
+ const value = (body as Record<string, unknown>)[key]
89
+ if (typeof value !== "string" || !deps.launch) continue
90
+ named = true
91
+ const launch = await deps.launch(user.id, value).catch(() => undefined)
92
+ if (launch?.concierge) out.push(CONCIERGE_LAUNCH)
93
+ if (launch?.sessionID !== undefined) out.push(launch.sessionID)
94
+ }
95
+ }
96
+ }
97
+ if (rule.self && !named && actor.kind === "session") out.push(actor.sessionID)
98
+ return out
99
+ }
100
+
101
+ const applies = (rule: GuardRule, actor: AgentActor) => rule.everyone === true || actor.kind !== "user"
102
+
103
+ /** The routes with the Concierge guard in front of the listed ones. */
104
+ export function guardConciergeRoutes(routes: readonly AgentRoute[], deps: GuardDeps): AgentRoute[] {
105
+ return routes.map((route) => {
106
+ const rule = CONCIERGE_GUARDED.find((r) => r.method === route.method && r.path === route.path)
107
+ if (!rule) return route
108
+ return {
109
+ ...route,
110
+ handle: async (context: AgentContext) => {
111
+ if (applies(rule, context.actor)) {
112
+ const hidden = new Set((await deps.identity.current(context.user.id)).hiddenSessionIDs)
113
+ const named = await targets(rule, context, deps)
114
+ if (named.includes(CONCIERGE_LAUNCH) || (hidden.size && named.some((id) => hidden.has(id)))) throw conciergeRefusal()
115
+ }
116
+ return route.handle(context)
117
+ },
118
+ }
119
+ })
120
+ }
@@ -0,0 +1,86 @@
1
+ // CON-04 inbound rule (contract: docs/plans/2026-11-01-concierge-contracts.md, "Inbound rule"). The Concierge
2
+ // holds a grant, so other parties must not steer it: delivery TO any of the user's Concierge sessions (the current
3
+ // one and the hidden previous ones) is admitted only from
4
+ // - the user,
5
+ // - the fixed hub party app:concierge (conciergeActor: the Concierge service's events and digests), and
6
+ // - a reply or decline to an ask the Concierge session itself sent.
7
+ // Anything else is 403 forbidden {policy: "concierge"} before anything is stored, with an agent audit entry
8
+ // (verb msg, decision forbidden; never the text). Outbound (coordinator decision): a Concierge session messages and
9
+ // asks only @me, and replies or declines only asks addressed to it; it reaches other sessions only through its
10
+ // grant-gated act verbs. The acting guard (conciergeProtects) keeps agents from acting
11
+ // on a Concierge session under a grant (prompt, stop, approve…): only the user does. A failed identity read
12
+ // refuses (fails closed).
13
+ import { CONCIERGE_PARTY } from "../../../packages/protocol/src/concierge"
14
+ import type { AppActor } from "../agent/actor"
15
+ import type { AgentAudit } from "../agent/audit"
16
+ import type { InboundKind, InboundPolicy } from "../agent/delivery"
17
+ import type { Party } from "../agent/envelope"
18
+ import { AgentError } from "../agent/errors"
19
+ import type { Message } from "../agent/messages"
20
+ import type { ConciergeIdentity } from "./watchlist-routes"
21
+
22
+ /** The token ID of the fixed hub party app:concierge (never a real token: those are hat_<16 hex>). */
23
+ export const CONCIERGE_TOKEN = CONCIERGE_PARTY.slice("app:".length)
24
+
25
+ /** The Concierge service as a sender: launches and its event and digest messages (never the user). */
26
+ export const conciergeActor = (userID: string): AppActor => ({ kind: "app", userID, tokenID: CONCIERGE_TOKEN, scopes: [], label: "concierge" })
27
+
28
+ export const CONCIERGE_INBOUND_MESSAGE = "The Concierge takes messages only from you and the hub: sessions reach the user with ask @me"
29
+
30
+ const isConcierge = (current: { readonly sessionID?: string; readonly hiddenSessionIDs: readonly string[] }, sessionID: string) =>
31
+ current.sessionID === sessionID || current.hiddenSessionIDs.includes(sessionID)
32
+
33
+ /** Whether `from` may send `kind` to the Concierge session `sessionID` (see the header). */
34
+ export function admitted(from: Party, sessionID: string, kind: InboundKind, original?: Message): boolean {
35
+ if (from.kind === "user") return true
36
+ if (from.kind === "app" && from.tokenID === CONCIERGE_TOKEN) return true
37
+ if ((kind === "reply" || kind === "decline") && original?.kind === "ask" && original.from.kind === "session" && original.from.sessionID === sessionID) return true
38
+ return false
39
+ }
40
+
41
+ export interface ConciergeInboundDeps {
42
+ readonly identity: ConciergeIdentity
43
+ readonly audit?: Pick<AgentAudit, "record">
44
+ }
45
+
46
+ /** The Delivery inbound policy (Delivery deps.inbound). */
47
+ export function conciergeInbound(deps: ConciergeInboundDeps): InboundPolicy {
48
+ const current = async (userID: string) => {
49
+ try {
50
+ return await deps.identity.current(userID)
51
+ } catch (cause) {
52
+ // (FX-07) A timed-out read is `busy` (503, refused); any other failure an internal error (refused too).
53
+ if (cause instanceof AgentError) throw cause
54
+ throw new AgentError("internal", `Could not check the Concierge inbound rule: ${(cause as Error).message}`.slice(0, 200))
55
+ }
56
+ }
57
+ return {
58
+ async outbound(userID, from, kind, original) {
59
+ if (from.kind !== "session") return
60
+ if (!isConcierge(await current(userID), from.sessionID) || outboundAdmitted(kind, original)) return
61
+ await deps.audit
62
+ ?.record(userID, { actor: from, verb: "msg", target: {}, decision: "forbidden", reason: "concierge outbound rule", outcome: "refused" })
63
+ .catch((cause) => console.error(`[hub] concierge outbound audit failed: ${(cause as Error).message}`))
64
+ throw new AgentError("forbidden", "The Concierge messages only you (@me); it acts on sessions only through its grant", { extra: { policy: "concierge" } })
65
+ },
66
+ async admit(userID, from, sessionID, kind, original) {
67
+ if (from.kind === "user") return
68
+ const now = await current(userID)
69
+ if (!isConcierge(now, sessionID) || admitted(from, sessionID, kind, original)) return
70
+ await deps.audit
71
+ ?.record(userID, { actor: from, verb: "msg", target: { sessionID }, decision: "forbidden", reason: "concierge inbound rule", outcome: "refused" })
72
+ .catch((cause) => console.error(`[hub] concierge inbound audit failed: ${(cause as Error).message}`))
73
+ throw new AgentError("forbidden", CONCIERGE_INBOUND_MESSAGE, { extra: { policy: "concierge" } })
74
+ },
75
+ }
76
+ }
77
+
78
+ /** The outbound rule's verdict: the Concierge messages only @me (not via Delivery.sendTo) and answers asks to it. */
79
+ export function outboundAdmitted(kind: InboundKind, original?: Message): boolean {
80
+ return (kind === "reply" || kind === "decline") && original?.kind === "ask"
81
+ }
82
+
83
+ /** The acting guard: true for the user's current and previous Concierge sessions (only the user acts on them). */
84
+ export function conciergeProtects(identity: ConciergeIdentity): (userID: string, sessionID: string) => Promise<boolean> {
85
+ return async (userID, sessionID) => isConcierge(await identity.current(userID), sessionID)
86
+ }
@@ -0,0 +1,84 @@
1
+ // CON-04 the Concierge's module slots (contract "Parallel safety of CON-05/06/07"). The service wires these ports;
2
+ // CON-05 (notifier.ts) and CON-06 (tiers.ts) each fill one slot line in CONCIERGE_SLOTS and touch nothing else here.
3
+ // Every slot defaults to a no-op, so the hub runs the same without them.
4
+ import type { ConciergeSettings, ConciergeStatus } from "../../../packages/protocol/src/concierge"
5
+ import type { UserActor } from "../agent/actor"
6
+ import type { ActOutcome, Decision } from "../agent/decisions"
7
+ import type { AgentEvent } from "../agent/events"
8
+ import type { AgentRoute } from "../agent/routes"
9
+ import type { ConciergeEntitlements } from "./entitlements"
10
+ import { ConciergeNotifierImpl, type NotifierJournal } from "./notifier"
11
+ import { conciergeTiers, type TierPorts } from "./tiers"
12
+ import type { ConciergeIdentity } from "./watchlist-routes"
13
+
14
+ /** What the Concierge modules get from the hub. */
15
+ export interface ConciergePorts {
16
+ /** The user's current Concierge session and every hidden one (the Concierge-only authority). */
17
+ readonly identity: ConciergeIdentity
18
+ readonly status: (userID: string) => Promise<ConciergeStatus>
19
+ readonly settings: (userID: string) => Promise<ConciergeSettings>
20
+ /** Pushes the board context to the current session, awaited at most 2 s (before a wake). False: not there. */
21
+ readonly beforeWake: (userID: string) => Promise<boolean>
22
+ /**
23
+ * Sends a message to the user's current Concierge session as app:concierge (Delivery.send; the inbound rule admits
24
+ * it). Throws not_found when there is no current session.
25
+ */
26
+ readonly send: (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
27
+ readonly entitlements: ConciergeEntitlements
28
+ /** Every user of the hub (leader loops). */
29
+ readonly users: () => Promise<Iterable<string>>
30
+ /** (CON-05) The notifier's journal, ledger store, watchlist view, board revision and leadership; absent: inert. */
31
+ readonly journal?: NotifierJournal
32
+ /** (CON-06) What the tiers slot needs from the hub (Decisions, acting, presence, devices, SSE). */
33
+ readonly tiers?: TierPorts
34
+ }
35
+
36
+ /** CON-05: event-driven turns, digests, quiet hours (runs on the `concierge` leader). */
37
+ export interface ConciergeNotifier {
38
+ /** The concierge leader was elected (start) or deposed (stop). */
39
+ start(): void
40
+ stop(): void
41
+ /** Every journal append of the user, on the appending pod. Must never throw (never awaited). */
42
+ observe(userID: string, event: AgentEvent): void
43
+ /** The Concierge was disabled: forget the user's ledger. Must never reject. */
44
+ disabled(userID: string): Promise<void>
45
+ }
46
+
47
+ /** CON-06: approval tiers, proposals and arming (its Concierge-only routes). */
48
+ export interface ConciergeTiers {
49
+ routes(): AgentRoute[]
50
+ /** Runs a proposal Decision's `act` action when the user answers (absent: it cannot run on this hub). */
51
+ act?(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome>
52
+ /** Admits a voice/text answer (throws forbidden "Answer this decision with a tap"); absent: never admitted. */
53
+ checkVia?(userID: string, decision: Decision, via: "voice" | "text", by: UserActor): Promise<void>
54
+ /** A Decision changed (Decisions' changed listener): an answered/closed one is disarmed. Never throws. */
55
+ decisionChanged?(userID: string, decision: Decision): void
56
+ /** The Decision armed now by the current Concierge session, if any (the board's `armed` flag). */
57
+ armedID?(userID: string): Promise<string | undefined>
58
+ /** The hub is stopping: drop timers. */
59
+ stop?(): void
60
+ }
61
+
62
+ export const NO_NOTIFIER: ConciergeNotifier = { start: () => undefined, stop: () => undefined, observe: () => undefined, disabled: async () => undefined }
63
+ export const NO_TIERS: ConciergeTiers = { routes: () => [] }
64
+
65
+ export interface ConciergeSlots {
66
+ readonly notifier?: (ports: ConciergePorts) => ConciergeNotifier
67
+ readonly tiers?: (ports: ConciergePorts) => ConciergeTiers
68
+ }
69
+
70
+ /** The filled slots (one line each). */
71
+ export const CONCIERGE_SLOTS: ConciergeSlots = {
72
+ notifier: (ports) => new ConciergeNotifierImpl(ports), // CON-05
73
+ tiers: (ports) => conciergeTiers(ports), // CON-06
74
+ }
75
+
76
+ export interface ConciergeModules {
77
+ readonly notifier: ConciergeNotifier
78
+ readonly tiers: ConciergeTiers
79
+ }
80
+
81
+ /** The modules for these ports: each slot's own, else its no-op. */
82
+ export function conciergeModules(ports: ConciergePorts, slots: ConciergeSlots = CONCIERGE_SLOTS): ConciergeModules {
83
+ return { notifier: slots.notifier?.(ports) ?? NO_NOTIFIER, tiers: slots.tiers?.(ports) ?? NO_TIERS }
84
+ }
@@ -0,0 +1,41 @@
1
+ // CON-04 Concierge notes: the Concierge's own memory across relaunches (contract "GET/PUT /concierge/notes"). One
2
+ // record per user (collection `concierge`, key `notes`): {text ≤ 32 KiB UTF-8, revision, updatedAt}. Control
3
+ // characters other than newline and tab are dropped. Writes are version-checked (expectedRevision; a stale one is
4
+ // 409 conflict {currentRevision}). The text never reaches the journal, the board or the admin audit.
5
+ import { CONCIERGE_LIMITS } from "../../../packages/protocol/src/concierge"
6
+ import type { Clock, StateStore } from "../backend/types"
7
+ import { AgentError } from "../agent/errors"
8
+
9
+ export const CONCIERGE_COLLECTION = "concierge"
10
+ const KEY = "notes"
11
+ /** C0 controls except tab and newline, DEL and C1 controls. */
12
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
13
+
14
+ export interface ConciergeNotes {
15
+ readonly text: string
16
+ readonly revision: number
17
+ readonly updatedAt?: number
18
+ }
19
+
20
+ export class NotesStore {
21
+ constructor(
22
+ private readonly state: StateStore,
23
+ private readonly now: Clock = Date.now,
24
+ ) {}
25
+
26
+ async get(userID: string): Promise<ConciergeNotes> {
27
+ return (await this.state.get<ConciergeNotes>(userID, CONCIERGE_COLLECTION, KEY))?.value ?? { text: "", revision: 0 }
28
+ }
29
+
30
+ async put(userID: string, raw: string, expectedRevision: number): Promise<ConciergeNotes> {
31
+ const text = raw.replace(CONTROL, "")
32
+ if (Buffer.byteLength(text, "utf8") > CONCIERGE_LIMITS.notesBytes) throw new AgentError("invalid", `Notes are at most ${CONCIERGE_LIMITS.notesBytes} bytes`)
33
+ const stored = await this.state.get<ConciergeNotes>(userID, CONCIERGE_COLLECTION, KEY)
34
+ const current = stored?.value ?? { text: "", revision: 0 }
35
+ if (current.revision !== expectedRevision) throw new AgentError("conflict", "The notes changed meanwhile", { extra: { currentRevision: current.revision } })
36
+ const next: ConciergeNotes = { text, revision: current.revision + 1, updatedAt: this.now() }
37
+ const put = await this.state.put(userID, CONCIERGE_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
38
+ if (!put.ok) throw new AgentError("conflict", "The notes changed meanwhile", { extra: { currentRevision: (await this.get(userID)).revision } })
39
+ return next
40
+ }
41
+ }