@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,217 @@
1
+ // CON-04 Concierge settings (contract "Concierge service: settings, launch, supervise, hide, context"): one record
2
+ // per user (collection `concierge`, key `settings`), edited only by the user (Settings → Concierge). A partial update
3
+ // names `expectedRevision` (409 conflict {currentRevision} on a mismatch); unknown and read-only fields (enabled,
4
+ // grantID, revision, updatedAt) are 400 invalid: enabling goes through POST /concierge/enable, the grant ID is the
5
+ // hub's. `where: "hosted"` is reserved for C2 (422 unsupported {feature: "conciergeHosted"}). The grant spec becomes
6
+ // a real grant held by @@concierge (grantInput); `launch` and `roles` are refused in C1. No grant is the default (a
7
+ // read-and-notify Concierge); PRESET_GRANT is the conservative one Settings offers.
8
+ import { CONCIERGE_LIMITS, CONCIERGE_ROLE, type ConciergeDigest, type ConciergeGrantSpec, type ConciergeGrantVerb, type ConciergeQuietHours, type ConciergeSettings } from "../../../packages/protocol/src/concierge"
9
+ import type { Clock, StateStore } from "../backend/types"
10
+ import { AgentError } from "../agent/errors"
11
+ import { canonicalProject, GRANT_MAX_ACTIONS_PER_HOUR, GRANT_PROJECTS_MAX, parseDuration, validateGrant } from "../agent/policy"
12
+ import { CONCIERGE_COLLECTION } from "./notes"
13
+
14
+ const KEY = "settings"
15
+ export const BRAIN_UNSUPPORTED = "Brain is not available to the Concierge yet: its tools are an exact allow-list, and Brain's MCP tools cannot be pinned by name"
16
+ const FIELDS = new Set(["where", "machineID", "directory", "model", "grant", "connectors", "timeZone", "quietHours", "quietAllowsUrgent", "digests", "expectedRevision"])
17
+ const GRANT_FIELDS = new Set(["allow", "projects", "permissions", "allowAlways", "expires", "maxActionsPerHour"])
18
+ export const CONCIERGE_GRANT_VERBS: readonly ConciergeGrantVerb[] = ["approve", "deny", "answer", "prompt", "stop"]
19
+ const MACHINE = /^[A-Za-z0-9._-]{1,80}$/
20
+ const MODEL = /^[A-Za-z0-9._-]{1,80}\/[!-~]{1,200}$/
21
+ const CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/
22
+ const EXPIRES = /^(\d{1,4})([hd])$/
23
+ const DIRECTORY_MAX = 4096
24
+ const HOUR_MS = 3_600_000
25
+ const EXPIRES_MAX_MS = 30 * 24 * HOUR_MS
26
+
27
+ const invalid = (message: string) => new AgentError("invalid", message)
28
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
29
+
30
+ export function defaultSettings(timeZone = "UTC"): ConciergeSettings {
31
+ return { enabled: false, where: "machine", connectors: { brain: false }, timeZone, quietAllowsUrgent: true, digests: [], revision: 0 }
32
+ }
33
+
34
+ /** The conservative grant Settings offers: answer and deny; approve read/edit inside the projects; never shell push/rm; 7 days. */
35
+ export const PRESET_GRANT = (projects: readonly string[]): ConciergeGrantSpec => ({
36
+ allow: ["answer", "deny", "approve"],
37
+ projects: [...projects],
38
+ permissions: { allow: ["read", "edit"], never: ["shell:git push*", "shell:rm *"] },
39
+ allowAlways: false,
40
+ expires: "7d",
41
+ maxActionsPerHour: 60,
42
+ })
43
+
44
+ /** The grant routes' body for a Concierge grant spec (holder @@concierge). */
45
+ export function grantInput(spec: ConciergeGrantSpec): Record<string, unknown> {
46
+ return {
47
+ holder: `@@${CONCIERGE_ROLE}`,
48
+ scope: { projects: [...spec.projects] },
49
+ allow: [...spec.allow],
50
+ permissions: { allow: [...spec.permissions.allow], never: [...spec.permissions.never] },
51
+ // (CON-13 decision) Never "always" for the Concierge, whatever an older stored spec says.
52
+ allowAlways: false,
53
+ expires: spec.expires,
54
+ maxActionsPerHour: spec.maxActionsPerHour,
55
+ }
56
+ }
57
+
58
+ export function validTimeZone(value: unknown): value is string {
59
+ if (typeof value !== "string" || !value || value.length > 64) return false
60
+ try {
61
+ new Intl.DateTimeFormat("en-GB", { timeZone: value })
62
+ return true
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ function grantSpec(raw: unknown): ConciergeGrantSpec {
69
+ if (!isObject(raw)) throw invalid("grant is {allow, projects, permissions?, allowAlways?, expires?, maxActionsPerHour?}")
70
+ for (const key of Object.keys(raw)) if (!GRANT_FIELDS.has(key)) throw invalid(`Unknown grant field ${key.slice(0, 40)}`)
71
+ if (!Array.isArray(raw.allow) || raw.allow.length === 0) throw invalid(`grant.allow lists the verbs: ${CONCIERGE_GRANT_VERBS.join(", ")}`)
72
+ const allow: ConciergeGrantVerb[] = []
73
+ for (const verb of raw.allow) {
74
+ if (verb === "launch" || verb === "roles") throw invalid(`grant.allow: the Concierge cannot hold ${verb} in this version`)
75
+ if (!CONCIERGE_GRANT_VERBS.includes(verb)) throw invalid(`grant.allow: unknown verb ${String(verb).slice(0, 40)}`)
76
+ if (!allow.includes(verb)) allow.push(verb)
77
+ }
78
+ if (!Array.isArray(raw.projects) || raw.projects.length === 0 || raw.projects.length > GRANT_PROJECTS_MAX) throw invalid(`grant.projects lists 1–${GRANT_PROJECTS_MAX} project directories`)
79
+ const projects: string[] = []
80
+ for (const item of raw.projects) {
81
+ const canonical = canonicalProject(item as string)
82
+ if (canonical === undefined) throw invalid(`grant.projects: ${String(item).slice(0, 80)} is not an absolute project directory`)
83
+ if (!projects.includes(canonical)) projects.push(canonical)
84
+ }
85
+ let permissions = { allow: [] as string[], never: [] as string[] }
86
+ if (raw.permissions !== undefined) {
87
+ if (!isObject(raw.permissions)) throw invalid("grant.permissions is {allow?, never?}")
88
+ permissions = { allow: (raw.permissions.allow ?? []) as string[], never: (raw.permissions.never ?? []) as string[] }
89
+ }
90
+ if (raw.allowAlways !== undefined && typeof raw.allowAlways !== "boolean") throw invalid("grant.allowAlways is true or false")
91
+ if (raw.allowAlways === true) throw invalid("grant.allowAlways: the Concierge never approves “always” (that is always your tap)")
92
+ const expires = raw.expires ?? "30d"
93
+ const match = typeof expires === "string" ? EXPIRES.exec(expires) : null
94
+ const ms = match ? parseDuration(expires) : undefined
95
+ if (ms === undefined || ms < HOUR_MS || ms > EXPIRES_MAX_MS) throw invalid('grant.expires is "1h" to "30d"')
96
+ const cap = raw.maxActionsPerHour ?? 60
97
+ if (!Number.isInteger(cap) || (cap as number) < 1 || (cap as number) > GRANT_MAX_ACTIONS_PER_HOUR) throw invalid(`grant.maxActionsPerHour is a whole number 1–${GRANT_MAX_ACTIONS_PER_HOUR}`)
98
+ const spec: ConciergeGrantSpec = { allow, projects, permissions, allowAlways: false, expires: expires as string, maxActionsPerHour: cap as number }
99
+ // The grant validator has the last word on patterns (the same rules as every grant).
100
+ const checked = validateGrant(grantInput(spec))
101
+ return { ...spec, permissions: { allow: [...checked.permissions.allow], never: [...checked.permissions.never] } }
102
+ }
103
+
104
+ function quietHours(raw: unknown): ConciergeQuietHours {
105
+ if (!isObject(raw) || Object.keys(raw).some((k) => !["start", "end", "timeZone"].includes(k))) throw invalid("quietHours is {start, end, timeZone}")
106
+ if (typeof raw.start !== "string" || !CLOCK.test(raw.start) || typeof raw.end !== "string" || !CLOCK.test(raw.end)) throw invalid("quietHours start and end are HH:MM")
107
+ if (!validTimeZone(raw.timeZone)) throw invalid("quietHours.timeZone is an IANA time zone")
108
+ return { start: raw.start, end: raw.end, timeZone: raw.timeZone }
109
+ }
110
+
111
+ function digests(raw: unknown): ConciergeDigest[] {
112
+ if (!Array.isArray(raw) || raw.length > CONCIERGE_LIMITS.digests) throw invalid(`digests lists at most ${CONCIERGE_LIMITS.digests} entries`)
113
+ return raw.map((d) => {
114
+ if (!isObject(d) || Object.keys(d).some((k) => k !== "at" && k !== "days")) throw invalid("a digest is {at, days?}")
115
+ if (typeof d.at !== "string" || !CLOCK.test(d.at)) throw invalid("digest.at is HH:MM")
116
+ if (d.days === undefined) return { at: d.at }
117
+ if (!Array.isArray(d.days) || d.days.length === 0 || d.days.length > 7 || d.days.some((n) => !Number.isInteger(n) || n < 0 || n > 6) || new Set(d.days).size !== d.days.length) {
118
+ throw invalid("digest.days lists weekdays 0–6 (Sunday is 0)")
119
+ }
120
+ return { at: d.at, days: [...(d.days as (0 | 1 | 2 | 3 | 4 | 5 | 6)[])].sort() }
121
+ })
122
+ }
123
+
124
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
125
+
126
+ /**
127
+ * The settings after a partial update by the user. Throws invalid, unsupported (hosted) or conflict (revision).
128
+ * Does not bump the revision (the store does).
129
+ */
130
+ export function parseSettingsPatch(body: unknown, current: ConciergeSettings): ConciergeSettings {
131
+ if (!isObject(body)) throw invalid("The body is a partial settings object with expectedRevision")
132
+ for (const key of Object.keys(body)) {
133
+ if (key === "enabled") throw invalid("enabled changes through POST /api/agent/concierge/enable or /disable")
134
+ if (!FIELDS.has(key)) throw invalid(`Unknown or read-only field ${key.slice(0, 40)}`)
135
+ }
136
+ const expected = body.expectedRevision
137
+ if (!Number.isSafeInteger(expected)) throw invalid("expectedRevision is required")
138
+ if (expected !== current.revision) throw new AgentError("conflict", "The Concierge settings changed meanwhile", { extra: { currentRevision: current.revision } })
139
+ let next: { -readonly [K in keyof ConciergeSettings]: ConciergeSettings[K] } = { ...current }
140
+ const drop = (key: "machineID" | "directory" | "model" | "grant" | "quietHours") => {
141
+ const { [key]: _gone, ...rest } = next
142
+ next = rest as typeof next
143
+ }
144
+ if (body.where !== undefined) {
145
+ if (body.where === "hosted") throw new AgentError("unsupported", "A hosted Concierge is not available", { extra: { feature: "conciergeHosted" } })
146
+ if (body.where !== "machine") throw invalid('where is "machine"')
147
+ }
148
+ if (body.machineID !== undefined) {
149
+ if (body.machineID === null) drop("machineID")
150
+ else if (typeof body.machineID !== "string" || !MACHINE.test(body.machineID)) throw invalid("machineID is malformed")
151
+ else next.machineID = body.machineID
152
+ }
153
+ if (body.directory !== undefined) {
154
+ if (body.directory === null) drop("directory")
155
+ else {
156
+ const raw = body.directory
157
+ const canonical = typeof raw === "string" && raw.length <= DIRECTORY_MAX && !CONTROL.test(raw) ? canonicalProject(raw) : undefined
158
+ if (canonical === undefined || !canonical.startsWith("/")) throw invalid("directory is an absolute directory on the machine (a git repo under its workspace roots)")
159
+ next.directory = canonical
160
+ }
161
+ }
162
+ if (body.model !== undefined) {
163
+ if (body.model === null) drop("model")
164
+ else if (typeof body.model !== "string" || !MODEL.test(body.model)) throw invalid("model is provider/model")
165
+ else next.model = body.model
166
+ }
167
+ if (body.grant !== undefined) {
168
+ if (body.grant === null) drop("grant")
169
+ else next.grant = grantSpec(body.grant)
170
+ }
171
+ if (body.connectors !== undefined) {
172
+ const c = body.connectors
173
+ if (!isObject(c) || Object.keys(c).some((k) => k !== "brain") || typeof c.brain !== "boolean") throw invalid("connectors is {brain: true|false}")
174
+ // The Concierge's tools are an exact allow-list; Brain's MCP tools cannot be named exactly across machines (and
175
+ // Code Mode would expose every MCP tool), so Brain is not offered in C1.
176
+ if (c.brain) throw new AgentError("unsupported", BRAIN_UNSUPPORTED, { extra: { feature: "conciergeBrain", reason: BRAIN_UNSUPPORTED } })
177
+ next.connectors = { brain: false }
178
+ }
179
+ if (body.timeZone !== undefined) {
180
+ if (!validTimeZone(body.timeZone)) throw invalid("timeZone is an IANA time zone")
181
+ next.timeZone = body.timeZone
182
+ }
183
+ if (body.quietHours !== undefined) {
184
+ if (body.quietHours === null) drop("quietHours")
185
+ else next.quietHours = quietHours(body.quietHours)
186
+ }
187
+ if (body.quietAllowsUrgent !== undefined) {
188
+ if (typeof body.quietAllowsUrgent !== "boolean") throw invalid("quietAllowsUrgent is true or false")
189
+ next.quietAllowsUrgent = body.quietAllowsUrgent
190
+ }
191
+ if (body.digests !== undefined) next.digests = digests(body.digests)
192
+ return next
193
+ }
194
+
195
+ /** The user's settings record: version-checked writes, revision bumped by every write. */
196
+ export class SettingsStore {
197
+ constructor(
198
+ private readonly state: StateStore,
199
+ private readonly now: Clock = Date.now,
200
+ ) {}
201
+
202
+ async get(userID: string): Promise<ConciergeSettings> {
203
+ return (await this.state.get<ConciergeSettings>(userID, CONCIERGE_COLLECTION, KEY))?.value ?? defaultSettings()
204
+ }
205
+
206
+ /** Applies `change` to the stored settings (re-read on a lost race; `change` may throw) and stores revision + 1. */
207
+ async update(userID: string, change: (current: ConciergeSettings) => ConciergeSettings): Promise<ConciergeSettings> {
208
+ for (let attempt = 0; attempt < 16; attempt++) {
209
+ const stored = await this.state.get<ConciergeSettings>(userID, CONCIERGE_COLLECTION, KEY)
210
+ const current = stored?.value ?? defaultSettings()
211
+ const next: ConciergeSettings = { ...change(current), revision: current.revision + 1, updatedAt: this.now() }
212
+ const put = await this.state.put(userID, CONCIERGE_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
213
+ if (put.ok) return next
214
+ }
215
+ throw new AgentError("conflict", "The Concierge settings kept changing; retry")
216
+ }
217
+ }