@huynle/hub 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,243 @@
1
+ // CON-04 the Concierge service (contract "Concierge service: settings, launch, supervise, hide, context"): what the
2
+ // Settings routes do, on top of the settings and notes records, the supervisor (state record, launch, relaunch,
3
+ // disable) and the grant. Users only (routes.ts); every operation is keyed by the user (the tenant).
4
+ //
5
+ // - Machine eligibility (eligibilityOf, pure): one of the user's machines with a hub node (host runtime with OpenCode,
6
+ // workspace roots, not draining) and a connected OpenCode service spoke announcing `launch` and `sessionContext`.
7
+ // Each "no" has a reason Settings shows. An offline machine is not eligible now but may be enabled: the state is
8
+ // waiting_machine until it is back.
9
+ // - Enable: entitlements.check (403 with its reason), machineID and directory required (400), the machine must be
10
+ // the user's (404 otherwise, like anything of another user), an online machine must qualify and the directory be
11
+ // under its node's workspace roots (422 unsupported {feature: "conciergeMachine", reason}). Then the grant (when
12
+ // settings.grant: Grants.create/update, holder @@concierge), the settings (enabled, version-checked), and the
13
+ // supervisor starts it. A grant created for a refused enable is revoked again.
14
+ // - A settings change that picks a machine that is not the user's (unknown or another user's, alike): 404 (CON-09b).
15
+ // - A settings change while enabled: a new or removed grant applies at once (update restarts its expiry: Renew);
16
+ // another machine, directory or model restarts the Concierge.
17
+ // - Disable: settings first (enabled false, version-checked), then the supervisor's idempotent steps.
18
+ import type { ConciergeSettings, ConciergeStatus } from "../../../packages/protocol/src/concierge"
19
+ import type { NodeRuntimeInfo } from "../../protocol"
20
+ import { AgentError } from "../agent/errors"
21
+ import type { Grants } from "../agent/grants"
22
+ import type { ConciergeEntitlements } from "./entitlements"
23
+ import type { ConciergeNotes, NotesStore } from "./notes"
24
+ import { grantInput, parseSettingsPatch, type SettingsStore, validTimeZone } from "./settings"
25
+ import type { Eligibility, Supervisor } from "./supervisor"
26
+
27
+ /** What the hub knows about one of the user's machines (http.ts gathers it from the store, nodes and registry). */
28
+ export interface MachineFacts {
29
+ readonly id: string
30
+ readonly name: string
31
+ /** A process of the machine is connected (spoke or node). */
32
+ readonly online: boolean
33
+ readonly node?: { readonly runtimes: readonly NodeRuntimeInfo[]; readonly workspaceRoots: readonly string[]; readonly draining: boolean }
34
+ /** The machine's OpenCode service spoke and what it announces. */
35
+ readonly service?: { readonly launch: boolean; readonly sessionContext: boolean }
36
+ }
37
+
38
+ export interface MachineView extends Eligibility {
39
+ readonly id: string
40
+ readonly name: string
41
+ /** The node's workspace roots (the directory picker's start points). */
42
+ readonly roots: readonly string[]
43
+ }
44
+
45
+ /** Whether a machine can host the Concierge now, and why not. */
46
+ export function eligibilityOf(facts: Omit<MachineFacts, "id" | "name">): Eligibility {
47
+ const no = (reason: string): Eligibility => ({ eligible: false, online: facts.online, reason })
48
+ if (!facts.node && !facts.online) return no("Offline")
49
+ if (!facts.node) return no("No hub node runs on it: start `hub node` there with a workspace root")
50
+ if (facts.node.draining) return no("Its hub node is draining")
51
+ if (!facts.node.runtimes.some((r) => r.kind === "host" && r.harnesses?.includes("opencode"))) return no("Its hub node has no host runtime with OpenCode")
52
+ if (!facts.node.workspaceRoots.length) return no("Its hub node has no workspace roots")
53
+ if (!facts.service) return no("No OpenCode service runs on it (the hub plugin's background service)")
54
+ if (!facts.service.launch) return no("Its OpenCode plugin cannot launch sessions: update the hub plugin")
55
+ if (!facts.service.sessionContext) return no("Its OpenCode plugin is too old for the Concierge: update the hub plugin")
56
+ return { eligible: true, online: true }
57
+ }
58
+
59
+ const underRoot = (dir: string, root: string) => {
60
+ const base = root.replace(/\/+$/, "")
61
+ return dir === base || dir.startsWith(`${base}/`)
62
+ }
63
+
64
+ export interface ConciergeView {
65
+ readonly settings: ConciergeSettings
66
+ readonly status: ConciergeStatus
67
+ readonly machines: readonly MachineView[]
68
+ }
69
+
70
+ export interface ServiceDeps {
71
+ readonly settings: SettingsStore
72
+ readonly notes: NotesStore
73
+ readonly supervisor: Supervisor
74
+ readonly grants: Pick<Grants, "create" | "update" | "revoke">
75
+ /** The user's machines (not revoked) with their facts. */
76
+ readonly machines: (userID: string) => readonly MachineFacts[]
77
+ readonly entitlements: ConciergeEntitlements
78
+ /** The admin audit: {actor: owner, action: concierge.*} only. */
79
+ readonly audit: (userID: string, action: string) => Promise<void>
80
+ /**
81
+ * Makes buffered state writes durable (StateStore.flush; a write-through store has none). Awaited before every
82
+ * security-relevant answer (settings, enable, disable, restart): a 200 is never lost to a crash.
83
+ */
84
+ readonly flush?: () => Promise<void>
85
+ }
86
+
87
+ const ignorable = (cause: unknown) => cause instanceof AgentError && (cause.code === "gone" || cause.code === "not_found")
88
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
89
+
90
+ function revisionOf(body: unknown, allowed: readonly string[] = []): number {
91
+ if (!isObject(body)) throw new AgentError("invalid", "The body is {expectedRevision}")
92
+ for (const key of Object.keys(body)) if (key !== "expectedRevision" && !allowed.includes(key)) throw new AgentError("invalid", `Unknown field ${key.slice(0, 40)}`)
93
+ if (!Number.isSafeInteger(body.expectedRevision)) throw new AgentError("invalid", "expectedRevision is required")
94
+ return body.expectedRevision as number
95
+ }
96
+
97
+ const checkRevision = (settings: ConciergeSettings, expected: number) => {
98
+ if (settings.revision !== expected) throw new AgentError("conflict", "The Concierge settings changed meanwhile", { extra: { currentRevision: settings.revision } })
99
+ }
100
+
101
+ export class ConciergeService {
102
+ constructor(private readonly deps: ServiceDeps) {}
103
+
104
+ get supervisor(): Supervisor {
105
+ return this.deps.supervisor
106
+ }
107
+
108
+ /** The user's machines with their eligibility. */
109
+ machineViews(userID: string): MachineView[] {
110
+ return this.deps.machines(userID).map((m) => ({ id: m.id, name: m.name, ...eligibilityOf(m), roots: m.node ? [...m.node.workspaceRoots] : [] }))
111
+ }
112
+
113
+ /** The supervisor's eligibility port. */
114
+ eligibility(userID: string, machineID: string): Eligibility {
115
+ const facts = this.deps.machines(userID).find((m) => m.id === machineID)
116
+ return facts ? eligibilityOf(facts) : { eligible: false, online: false, reason: "Not one of your machines" }
117
+ }
118
+
119
+ async view(userID: string): Promise<ConciergeView> {
120
+ const [settings, status] = await Promise.all([this.deps.settings.get(userID), this.deps.supervisor.status(userID)])
121
+ return { settings, status, machines: this.machineViews(userID) }
122
+ }
123
+
124
+ /** Refuses a machine or directory that cannot host the Concierge (an offline machine is fine). */
125
+ private checkPlacement(userID: string, settings: ConciergeSettings): void {
126
+ if (settings.machineID === undefined || settings.directory === undefined) throw new AgentError("invalid", "Pick a machine and a directory (a git repo on it) first")
127
+ const facts = this.deps.machines(userID).find((m) => m.id === settings.machineID)
128
+ if (!facts) throw new AgentError("not_found", "No such machine")
129
+ const unsupported = (reason: string) => new AgentError("unsupported", `This machine cannot host the Concierge: ${reason}`, { extra: { feature: "conciergeMachine", reason } })
130
+ const verdict = eligibilityOf(facts)
131
+ if (!verdict.eligible && verdict.online) throw unsupported(verdict.reason ?? "not eligible")
132
+ if (facts.node && !facts.node.workspaceRoots.some((root) => underRoot(settings.directory!, root))) throw unsupported("The directory is not under the machine's workspace roots")
133
+ }
134
+
135
+ /** Creates, renews or revokes the @@concierge grant for the settings; the grant ID (undefined: none). */
136
+ private async applyGrant(userID: string, settings: ConciergeSettings): Promise<string | undefined> {
137
+ if (!settings.grant) {
138
+ if (settings.grantID !== undefined) await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => (ignorable(cause) ? undefined : Promise.reject(cause)))
139
+ return undefined
140
+ }
141
+ if (settings.grantID !== undefined) {
142
+ try {
143
+ await this.deps.grants.update(userID, settings.grantID, grantInput(settings.grant), { managed: true })
144
+ return settings.grantID
145
+ } catch (cause) {
146
+ if (!ignorable(cause)) throw cause
147
+ }
148
+ }
149
+ return (await this.deps.grants.create(userID, grantInput(settings.grant), { managed: true })).id
150
+ }
151
+
152
+ private async withGrantID(userID: string, expected: number, grantID: string | undefined, change: (s: ConciergeSettings) => ConciergeSettings): Promise<ConciergeSettings> {
153
+ return this.deps.settings.update(userID, (current) => {
154
+ checkRevision(current, expected)
155
+ const next = change(current)
156
+ const { grantID: _old, ...rest } = next
157
+ return grantID !== undefined ? { ...rest, grantID } : rest
158
+ })
159
+ }
160
+
161
+ async updateSettings(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
162
+ const current = await this.deps.settings.get(userID)
163
+ const next = parseSettingsPatch(body, current)
164
+ // CON-09b: a machine that is not the user's is refused when it is saved, not only at enable. An unknown ID and
165
+ // another user's answer alike (no oracle). A machine saved earlier and removed since is only checked at enable.
166
+ if (next.machineID !== undefined && next.machineID !== current.machineID && !this.deps.machines(userID).some((m) => m.id === next.machineID)) {
167
+ throw new AgentError("not_found", "That machine is not one of yours: pick one of your machines")
168
+ }
169
+ const placementChanged = next.machineID !== current.machineID || next.directory !== current.directory || next.model !== current.model
170
+ if (current.enabled && placementChanged) this.checkPlacement(userID, next)
171
+ const grantTouched = isObject(body) && "grant" in body
172
+ const grantID = current.enabled && grantTouched ? await this.applyGrant(userID, next) : current.grantID
173
+ const settings = await this.withGrantID(userID, current.revision, grantID, () => next)
174
+ await this.deps.audit(userID, "concierge.settings")
175
+ const status = current.enabled && placementChanged ? await this.deps.supervisor.restart(userID) : await this.deps.supervisor.status(userID)
176
+ await this.durable()
177
+ return { settings, status }
178
+ }
179
+
180
+ async enable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
181
+ const expected = revisionOf(body, ["timeZone"])
182
+ const timeZone = (body as { timeZone?: unknown }).timeZone
183
+ if (timeZone !== undefined && !validTimeZone(timeZone)) throw new AgentError("invalid", "timeZone is an IANA time zone")
184
+ const entitled = await this.deps.entitlements.check(userID)
185
+ if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
186
+ const current = await this.deps.settings.get(userID)
187
+ checkRevision(current, expected)
188
+ this.checkPlacement(userID, current)
189
+ const grantID = await this.applyGrant(userID, current)
190
+ let settings: ConciergeSettings
191
+ try {
192
+ settings = await this.withGrantID(userID, expected, grantID, (s) => ({ ...s, enabled: true, ...(typeof timeZone === "string" && s.updatedAt === undefined ? { timeZone } : {}) }))
193
+ } catch (cause) {
194
+ if (grantID !== undefined && grantID !== current.grantID) await this.deps.grants.revoke(userID, grantID).catch(() => undefined)
195
+ throw cause
196
+ }
197
+ await this.deps.audit(userID, "concierge.enable")
198
+ const status = await this.deps.supervisor.enable(userID)
199
+ await this.durable()
200
+ return { settings, status }
201
+ }
202
+
203
+ async disable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
204
+ const expected = revisionOf(body)
205
+ const current = await this.deps.settings.get(userID)
206
+ checkRevision(current, expected)
207
+ if (current.enabled) await this.withGrantID(userID, expected, current.grantID, (s) => ({ ...s, enabled: false }))
208
+ const status = await this.deps.supervisor.disable(userID)
209
+ await this.durable()
210
+ return { settings: await this.deps.settings.get(userID), status }
211
+ }
212
+
213
+ async restart(userID: string): Promise<{ status: ConciergeStatus }> {
214
+ const settings = await this.deps.settings.get(userID)
215
+ if (!settings.enabled) throw new AgentError("conflict", "The Concierge is disabled: enable it first")
216
+ const entitled = await this.deps.entitlements.check(userID)
217
+ if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
218
+ // A suspended Concierge lost its grant: the user's Restart grants it again (the standing setting).
219
+ if (settings.grant && settings.grantID === undefined) {
220
+ const grantID = await this.applyGrant(userID, settings)
221
+ if (grantID !== undefined) await this.deps.settings.update(userID, (s) => ({ ...s, grantID }))
222
+ }
223
+ const status = await this.deps.supervisor.restart(userID)
224
+ await this.durable()
225
+ return { status }
226
+ }
227
+
228
+ /** Flushes the state store (see ServiceDeps.flush). */
229
+ private async durable(): Promise<void> {
230
+ await this.deps.flush?.()
231
+ }
232
+
233
+ notes(userID: string): Promise<ConciergeNotes> {
234
+ return this.deps.notes.get(userID)
235
+ }
236
+
237
+ async setNotes(userID: string, body: unknown): Promise<ConciergeNotes> {
238
+ const expected = revisionOf(body, ["text"])
239
+ const text = (body as { text?: unknown }).text
240
+ if (typeof text !== "string") throw new AgentError("invalid", "text is required (a string, at most 32 KiB)")
241
+ return this.deps.notes.put(userID, text, expected)
242
+ }
243
+ }
@@ -0,0 +1,69 @@
1
+ // CON-04 the routes a Concierge session may call (round-3 verify): its session tokens (act AND shell, the current
2
+ // session and every previous one) reach ONLY the routes listed here; every other /api/agent route answers 403
3
+ // forbidden {policy: "concierge"} before its handler runs. The check is central (agentApi's gate), so a route added
4
+ // later is refused until it is listed here (fail closed).
5
+ //
6
+ // What the list keeps: reads (who, status, sessions, its inbox and messages), messages to @me and replies/declines
7
+ // to asks addressed to it (the outbound rule narrows msg/ask/reply/decline further, inbound.ts), the grant-gated act
8
+ // verbs, its own notes, its board and Decision reads, and the watchlist verbs. Nothing that puts its text in front
9
+ // of another session (intake, artifacts, workspaces, side chats, profiles, names, intents, webhooks, teams…).
10
+ // CON-06's own routes are added through the tiers slot (ConciergeTiers.routes), which are Concierge routes by design.
11
+ import type { AgentActor } from "../agent/actor"
12
+ import { AgentError } from "../agent/errors"
13
+ import type { AgentRoute } from "../agent/routes"
14
+ import type { ConciergeIdentity } from "./watchlist-routes"
15
+
16
+ const A = "/api/agent"
17
+ const C = `${A}/concierge`
18
+
19
+ export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; readonly path: string }[] = [
20
+ { method: "GET", path: `${A}/whoami` },
21
+ { method: "GET", path: `${A}/who` },
22
+ { method: "GET", path: `${A}/status` },
23
+ { method: "GET", path: `${A}/sessions` },
24
+ { method: "GET", path: `${A}/sessions/:addr` },
25
+ { method: "POST", path: `${A}/msg` },
26
+ { method: "POST", path: `${A}/ask` },
27
+ { method: "POST", path: `${A}/reply` },
28
+ { method: "POST", path: `${A}/decline` },
29
+ { method: "GET", path: `${A}/inbox` },
30
+ { method: "GET", path: `${A}/messages/:id` },
31
+ ...["approve", "deny", "answer", "prompt", "stop"].map((verb) => ({ method: "POST", path: `${A}/sessions/:addr/${verb}` })),
32
+ { method: "GET", path: `${C}/notes` },
33
+ { method: "PUT", path: `${C}/notes` },
34
+ { method: "GET", path: `${C}/board` },
35
+ { method: "GET", path: `${C}/decisions` },
36
+ { method: "GET", path: `${C}/decisions/:id` },
37
+ { method: "GET", path: `${C}/watchlist` },
38
+ ...["watch", "unwatch", "mute", "unmute"].map((verb) => ({ method: "POST", path: `${C}/watchlist/${verb}` })),
39
+ // CON-06 (tiers.ts): proposals and arming. Proposals put no Concierge text in front of another session: free text
40
+ // (prompt, form answers) runs only after the user's tap.
41
+ { method: "POST", path: `${C}/proposals` },
42
+ { method: "GET", path: `${C}/arm` },
43
+ { method: "POST", path: `${C}/arm` },
44
+ // CON-13 (tiers.ts): a pending form's schema (fields, options), so its answers fit the form.
45
+ { method: "GET", path: `${C}/sessions/:addr/forms/:form` },
46
+ ]
47
+
48
+ const key = (method: string, path: string) => `${method} ${path}`
49
+
50
+ /**
51
+ * The gate agentApi runs for every session actor before the route's own checks: a Concierge session (current or
52
+ * previous) may call only the allow-list and `extra` (the tiers slot's routes). A failed identity read refuses.
53
+ */
54
+ export function conciergeSessionGate(identity: ConciergeIdentity, extra: () => readonly AgentRoute[] = () => []): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
55
+ const allowed = new Set(CONCIERGE_SESSION_ROUTES.map((r) => key(r.method, r.path)))
56
+ return async (actor, route) => {
57
+ if (actor.kind !== "session") return
58
+ let concierge: boolean
59
+ try {
60
+ const current = await identity.current(actor.userID)
61
+ concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
62
+ } catch {
63
+ concierge = true
64
+ }
65
+ if (!concierge) return
66
+ if (allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)) return
67
+ throw new AgentError("forbidden", "The Concierge cannot use this route", { extra: { policy: "concierge" } })
68
+ }
69
+ }
@@ -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
+ }