@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
@@ -3,7 +3,7 @@
3
3
  // and every catalog change syncs the affected users (records, lastSeen, derived names).
4
4
  //
5
5
  // Routes (user actors always; session tokens may read, and write for their OWN session only):
6
- // GET /api/agent/who?project=&role=
6
+ // GET /api/agent/who?project=&role= (entries carry a stored `intent`, CON-02)
7
7
  // POST /api/agent/name {name, session?}
8
8
  // POST /api/agent/roles/claim {role, exclusive?, project?, session?}
9
9
  // POST /api/agent/roles/release {role, project?, session?}
@@ -16,6 +16,7 @@
16
16
  // order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
17
17
  // roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
18
18
  // journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
19
+ import { boundedJson } from "./body"
19
20
  import type { ActingSwitch } from "./acting-switch"
20
21
  import { auditOf } from "./acting"
21
22
  import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
@@ -31,6 +32,10 @@ import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
31
32
  import { slugify } from "./names"
32
33
  import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
33
34
  import type { CursorKeys } from "./event-cursor"
35
+ import type { TeamDiscovery } from "../teams/discovery"
36
+ import type { SessionIntent } from "../../protocol"
37
+ import { intentField } from "./intents"
38
+ import { conciergeSelf } from "./read-routes"
34
39
 
35
40
  /** What the directory reads from the Registry. */
36
41
  export interface DirectoryRegistry {
@@ -58,10 +63,11 @@ export function registrySource(registry: Pick<DirectoryRegistry, "catalog" | "se
58
63
  if (out.has(s.id) || registry.sessionTenant(s.id) !== userID) continue
59
64
  const project = projectOf(s)
60
65
  const scope = project ? { project } : {}
61
- out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state })
66
+ const harness = process.harness ? { harness: process.harness } : {}
67
+ out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state, ...harness })
62
68
  for (const child of s.status) {
63
69
  if (child.id === s.id || out.has(child.id)) continue
64
- out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state })
70
+ out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state, ...harness })
65
71
  }
66
72
  }
67
73
  }
@@ -104,7 +110,7 @@ export function hubDirectory(options: { registry: DirectoryRegistry; store: Pick
104
110
  type Body = Record<string, unknown>
105
111
 
106
112
  async function readBody(request: Request): Promise<Body> {
107
- const body = await request.json()
113
+ const body = await boundedJson(request)
108
114
  if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object")
109
115
  return body as Body
110
116
  }
@@ -152,6 +158,12 @@ export interface DirectoryRoutesDeps {
152
158
  readonly acting?: Pick<ActingSwitch, "ensureActing">
153
159
  readonly audit?: Pick<AgentAudit, "record">
154
160
  readonly previewKeys?: CursorKeys
161
+ /** Team-aware who (HWA-40); without it, the owner-wide directory. */
162
+ readonly discovery?: TeamDiscovery
163
+ /** (CON-02) The stored intents of these sessions: who entries carry `intent` when present. */
164
+ readonly intents?: (userID: string, sessionIDs: readonly string[]) => Promise<ReadonlyMap<string, SessionIntent>>
165
+ /** (CON-04) The user's Concierge sessions: who marks them `concierge: true`; archive-inactive never archives them. */
166
+ readonly concierge?: (userID: string) => Promise<readonly string[]>
155
167
  }
156
168
 
157
169
  const MANAGE = { session: true, scope: "sessions:manage" } as const
@@ -202,7 +214,10 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
202
214
  const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
203
215
  const body = await readBody(context.request)
204
216
  const prefs = store.viewPrefs(context.user.id)
205
- const entries = (store.agentData(context.user.id).sessions ?? []).map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
217
+ const concierge = new Set(deps.concierge ? await deps.concierge(context.user.id) : [])
218
+ const entries = (store.agentData(context.user.id).sessions ?? [])
219
+ .filter((s) => !concierge.has(s.sessionID))
220
+ .map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
206
221
  if (body.preview === true) {
207
222
  const limit = body.limit
208
223
  if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
@@ -261,10 +276,20 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
261
276
  path: `${AGENT_PREFIX}/who`,
262
277
  // Reading the directory: sessions, and app tokens holding sessions:read; writes stay users and sessions.
263
278
  allow: { session: true, scope: "sessions:read" },
264
- handle: ({ url, user }) => {
279
+ handle: async ({ url, user, actor }) => {
265
280
  const project = url.searchParams.get("project") || undefined
266
281
  const role = url.searchParams.get("role") || undefined
267
- return ok({ sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) })
282
+ const team = url.searchParams.get("team") || undefined
283
+ const answer = deps.discovery
284
+ ? deps.discovery.who(await deps.discovery.census(user.id, actor), { ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}) })
285
+ : { sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) }
286
+ if (!answer.sessions.length || (!deps.intents && !deps.concierge)) return ok(answer)
287
+ // (CON-09a) The Concierge's own who leaves out its sessions (current and previous).
288
+ const self = await conciergeSelf(deps.concierge, user.id, actor)
289
+ const shown = self.size ? answer.sessions.filter((entry) => !self.has(entry.sessionID)) : answer.sessions
290
+ const intents = deps.intents ? await deps.intents(user.id, shown.map((entry) => entry.sessionID)) : new Map<string, SessionIntent>()
291
+ const concierge = new Set(deps.concierge ? await deps.concierge(user.id) : [])
292
+ return ok({ ...answer, sessions: shown.map((entry) => ({ ...entry, ...intentField(intents.get(entry.sessionID)), ...(concierge.has(entry.sessionID) ? { concierge: true } : {}) })) })
268
293
  },
269
294
  },
270
295
  {
@@ -291,6 +316,11 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
291
316
  const exclusive = flag(body, "exclusive")
292
317
  const project = text(body, "project")
293
318
  const sessionID = target(directory, context, text(body, "session"))
319
+ // (CON-04) A Concierge session holds @@concierge only (claimed by the hub): no other role, for anyone, so it
320
+ // never inherits another role's grants.
321
+ if (deps.concierge && (await deps.concierge(context.user.id)).includes(sessionID)) {
322
+ throw new AgentError("forbidden", "The Concierge holds no role but @@concierge", { extra: { policy: "concierge" } })
323
+ }
294
324
  const claim = await roleWrite(context, "roles", sessionID, async () => {
295
325
  const before = directory.resolveOne(context.user.id, sessionID).roles
296
326
  const claim = await directory.claimRole(context.user.id, sessionID, role, {
@@ -28,7 +28,7 @@
28
28
  import { roleKey, type AgentData, type AgentName, type AgentRole, type AgentSession } from "../agent-data"
29
29
  import type { Store } from "../store"
30
30
  import { AgentError } from "./errors"
31
- import { deriveName, isPlaceholderTitle, isReservedName, isReservedRole, parseAddress, validName, withSuffix, type Address } from "./names"
31
+ import { addressProblem, deriveName, isManagedRole, isPlaceholderTitle, isReservedName, isReservedRole, MANAGED_ROLE_MESSAGE, parseAddress, validName, withSuffix, type Address } from "./names"
32
32
 
33
33
  const MINUTE = 60_000
34
34
  const HOUR = 60 * MINUTE
@@ -52,6 +52,8 @@ export interface ReportedSession {
52
52
  /** Project name, lowercased (the `.project` scope). */
53
53
  readonly project?: string
54
54
  readonly state?: string
55
+ /** The harness kind of the process reporting it (kept on the record as the last-known harness). */
56
+ readonly harness?: string
55
57
  }
56
58
 
57
59
  /** What the directory needs to know about the user's live sessions. */
@@ -97,6 +99,11 @@ export interface DirectoryEntry {
97
99
  /** Hears an explicit name change (setName); derived names are never announced. */
98
100
  export type NamedListener = (userID: string, sessionID: string, name: string) => void
99
101
 
102
+ /** Team-aware discovery (HWA-40): sessions the caller may not see resolve like unknown ones. */
103
+ export interface ResolveOptions {
104
+ readonly visible?: (sessionID: string) => boolean
105
+ }
106
+
100
107
  export interface Resolved {
101
108
  readonly address: Address
102
109
  /** One session for an ID or @name; every live holder for a @@role. */
@@ -118,6 +125,9 @@ const ended = (s: AgentSession | undefined): number | undefined => {
118
125
  return times.length ? Math.min(...times) : undefined
119
126
  }
120
127
 
128
+ /** Sessions remembered as unnamed before the directory knew them (Directory.unname). */
129
+ export const UNNAMED_MAX = 500
130
+
121
131
  export class Directory {
122
132
  private readonly store: DirectoryStore
123
133
  private readonly source: SessionSource
@@ -202,16 +212,39 @@ export class Directory {
202
212
  })
203
213
  }
204
214
 
215
+ /**
216
+ * (CON-09a) Sessions that hold no @name (the Concierge's: hub-managed): their names and aliases are dropped (free
217
+ * again at once) and they are never derived one later. A session the directory does not know yet is remembered
218
+ * (UNNAMED_MAX, oldest dropped) until its record appears. Answers how many names went.
219
+ */
220
+ async unname(userID: string, sessionIDs: readonly string[]): Promise<number> {
221
+ const ids = new Set(sessionIDs)
222
+ return this.update(userID, (draft) => {
223
+ const pending = new Set(draft.data.unnamed ?? [])
224
+ for (const id of ids) {
225
+ const record = draft.byID.get(id)
226
+ if (!record) pending.add(id)
227
+ else if (record.named !== true) record.named = true
228
+ }
229
+ if (pending.size) draft.data.unnamed = [...pending].slice(-UNNAMED_MAX)
230
+ const before = draft.names.length
231
+ draft.names = draft.names.filter((n) => !ids.has(n.sessionID))
232
+ return before - draft.names.length
233
+ })
234
+ }
235
+
205
236
  // ---------- roles ----------
206
237
 
207
238
  /**
208
239
  * Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
209
240
  * (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
210
241
  * can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
211
- * (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
242
+ * (grants with `roles` come in phase 3). Managed roles (concierge, CON-04) need `managed`: only the hub's own
243
+ * service passes it. Exclusivity and generations are per (role, project scope).
212
244
  */
213
- async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
245
+ async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean; managed?: boolean } = {}): Promise<RoleView> {
214
246
  const { role, project } = parseRole(requested, options.project)
247
+ if (isManagedRole(role) && !options.managed) throw new AgentError("forbidden", MANAGED_ROLE_MESSAGE(role))
215
248
  if (isReservedRole(role) && !options.byUser) throw new AgentError("forbidden", `@@${role} is reserved: only the user can assign it`)
216
249
  return this.update(userID, (draft) => {
217
250
  this.known(draft, sessionID)
@@ -236,9 +269,10 @@ export class Directory {
236
269
  })
237
270
  }
238
271
 
239
- /** Gives a role up; not_found when the session does not hold it. */
240
- async releaseRole(userID: string, sessionID: string, requested: string, scope?: string): Promise<{ role: string; project?: string }> {
272
+ /** Gives a role up; not_found when the session does not hold it. A managed role (CON-04) needs `managed`. */
273
+ async releaseRole(userID: string, sessionID: string, requested: string, scope?: string, options: { managed?: boolean } = {}): Promise<{ role: string; project?: string }> {
241
274
  const { role, project } = parseRole(requested, scope)
275
+ if (isManagedRole(role) && !options.managed) throw new AgentError("forbidden", MANAGED_ROLE_MESSAGE(role))
242
276
  return this.update(userID, (draft) => {
243
277
  const held = draft.roles.find((r) => r.sessionID === sessionID && r.role === role && r.project === project)
244
278
  if (!held) throw new AgentError("not_found", `Not holding @@${scoped(role, project)}`)
@@ -263,16 +297,31 @@ export class Directory {
263
297
  )
264
298
  }
265
299
 
300
+ /** (RP-08) The entries of these sessions in one read (unknown and deleted ones are absent). */
301
+ entries(userID: string, sessionIDs: readonly string[]): Map<string, DirectoryEntry> {
302
+ return this.read(userID, (draft) => {
303
+ const out = new Map<string, DirectoryEntry>()
304
+ for (const id of sessionIDs) {
305
+ const record = draft.byID.get(id)
306
+ if (record && record.deletedAt === undefined) out.set(id, this.entry(draft, id))
307
+ }
308
+ return out
309
+ })
310
+ }
311
+
266
312
  // ---------- resolution ----------
267
313
 
268
314
  /**
269
315
  * The sessions an address names, for this user only: `ses_…` (an ID or a unique ID prefix), `@name[.project]`
270
316
  * or `@@role[.project]` (every live holder). Throws invalid (malformed), not_found or ambiguous {candidates}.
271
317
  */
272
- resolve(userID: string, addr: string): Resolved {
318
+ resolve(userID: string, addr: string, options: ResolveOptions = {}): Resolved {
273
319
  const address = parseAddress(addr)
274
- if (!address) throw new AgentError("invalid", `Not an address: use ses_…, @name or @@role`)
275
- return this.read(userID, (draft) => ({ address, sessions: this.lookup(draft, address, addr) }))
320
+ if (!address) {
321
+ const { message, hint } = addressProblem(addr)
322
+ throw new AgentError("invalid", message, { hint })
323
+ }
324
+ return this.read(userID, (draft) => ({ address, sessions: this.lookup(draft, address, addr, options.visible ?? (() => true)) }))
276
325
  }
277
326
 
278
327
  /** Exactly one session; several (a role with several holders) is ambiguous. */
@@ -282,11 +331,11 @@ export class Directory {
282
331
  return sessions[0]!
283
332
  }
284
333
 
285
- private lookup(draft: Draft, address: Address, addr: string): DirectoryEntry[] {
334
+ private lookup(draft: Draft, address: Address, addr: string, visible: (sessionID: string) => boolean): DirectoryEntry[] {
286
335
  const missing = () => new AgentError("not_found", `No session ${addr}`)
287
336
  if (address.kind === "id") {
288
- if (draft.byID.has(address.id)) return [this.entry(draft, address.id)]
289
- const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id)).map((s) => this.entry(draft, s.sessionID))
337
+ if (draft.byID.has(address.id) && visible(address.id)) return [this.entry(draft, address.id)]
338
+ const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id) && visible(s.sessionID)).map((s) => this.entry(draft, s.sessionID))
290
339
  if (matches.length > 1) throw ambiguous(addr, matches)
291
340
  if (!matches.length) throw missing()
292
341
  return matches
@@ -294,11 +343,11 @@ export class Directory {
294
343
  if (address.kind === "name") {
295
344
  const held = draft.names.find((n) => n.name === address.name)
296
345
  const entry = held && this.entry(draft, held.sessionID)
297
- if (!entry || (address.project !== undefined && entry.project !== address.project)) throw missing()
346
+ if (!entry || !visible(entry.sessionID) || (address.project !== undefined && entry.project !== address.project)) throw missing()
298
347
  return [entry]
299
348
  }
300
349
  const holders = draft.roles.filter((r) => r.role === address.role && inScope(r.project, draft.byID.get(r.sessionID)?.project, address.project))
301
- const ids = [...new Set(holders.map((r) => r.sessionID))]
350
+ const ids = [...new Set(holders.map((r) => r.sessionID))].filter(visible)
302
351
  if (!ids.length) throw missing()
303
352
  return ids.map((id) => this.entry(draft, id))
304
353
  }
@@ -377,10 +426,17 @@ export class Directory {
377
426
  let record = byID.get(live.sessionID)
378
427
  if (!record) {
379
428
  record = { sessionID: live.sessionID, firstSeen: t }
429
+ // (CON-09a) Unnamed before it was known: never derived a name.
430
+ if (data.unnamed?.includes(live.sessionID)) {
431
+ record.named = true
432
+ const rest = data.unnamed.filter((id) => id !== live.sessionID)
433
+ if (rest.length) data.unnamed = rest
434
+ else delete data.unnamed
435
+ }
380
436
  sessions.push(record)
381
437
  byID.set(live.sessionID, record)
382
438
  }
383
- for (const field of ["machineID", "parentID", "project", "title"] as const) {
439
+ for (const field of ["machineID", "parentID", "project", "title", "harness"] as const) {
384
440
  const value = live[field]
385
441
  if (value !== undefined && value !== "" && record[field] !== value) record[field] = value
386
442
  }
@@ -21,6 +21,8 @@ export const AGENT_ERROR_STATUS = {
21
21
  ambiguous: 409,
22
22
  /** Explicit name or exclusive role already held. */
23
23
  name_taken: 409,
24
+ /** A stale expectedRevision / base revision, or an Idempotency-Key reused with another body (`currentRevision`). */
25
+ conflict: 409,
24
26
  /** Already answered, expired or ended. */
25
27
  gone: 410,
26
28
  /** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
@@ -41,6 +43,8 @@ export interface AgentErrorOptions {
41
43
  readonly hint?: string
42
44
  /** More fields in the envelope, e.g. candidates, retryAfter, feature; never code or message. */
43
45
  readonly extra?: Record<string, unknown>
46
+ /** Answer with `Connection: close` (the request body was left unread on the socket: agent/body.ts). */
47
+ readonly close?: boolean
44
48
  }
45
49
 
46
50
  /** Throw from an agent handler; answered as the envelope with the code's status. */
@@ -59,6 +63,7 @@ export function agentError(error: AgentError): Response {
59
63
  const headers: Record<string, string> = { "cache-control": "no-store" }
60
64
  const retryAfter = extra.retryAfter
61
65
  if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) headers["retry-after"] = String(Math.ceil(retryAfter))
66
+ if (error.options.close) headers.connection = "close"
62
67
  return Response.json(
63
68
  { error: { ...extra, code: error.code, message: error.message, ...(hint ? { hint } : {}) } },
64
69
  { status: AGENT_ERROR_STATUS[error.code], headers },
@@ -13,7 +13,9 @@
13
13
  //
14
14
  // Who sees what: the user everything. App tokens need events:subscribe for the route and, per family, sessions:read
15
15
  // (session), sessions:manage (manage), messages:read and being a party (message), sessions:launch and having started
16
- // it (launch). Session tokens see session and manage events, messages they are a party to and launches they started.
16
+ // it (launch). Session tokens see session and manage events, messages they are a party to and launches they started;
17
+ // a session holding a team seat (team-scoped, HWA-40) additionally sees no event naming a session or team that team
18
+ // discovery hides from it (deps.scope), with or without filters, so IDs of hidden sessions never leak.
17
19
  // Reader-only fields (family, audience, position) never leave the hub.
18
20
  import type { AgentActor } from "./actor"
19
21
  import type { Directory } from "./directory"
@@ -29,7 +31,7 @@ export const EVENTS_LIMIT_MAX = 200
29
31
  export const EVENTS_LIMIT_DEFAULT = 50
30
32
  export const INSPECT_MAX = 1000
31
33
  export const RECHECK_MS = 1000
32
- export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches"] as const
34
+ export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches", "workspaces"] as const
33
35
  const FILTER_MAX = 20
34
36
  const TYPE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*(\.\*)?$/
35
37
 
@@ -39,6 +41,16 @@ export interface EventsRouteDeps {
39
41
  readonly events: Pick<AgentEvents, "latest" | "since" | "appended">
40
42
  readonly keys: CursorKeys
41
43
  readonly directory: Pick<Directory, "resolve">
44
+ /**
45
+ * Narrows what a caller reads beyond scopes (HWA-28/40: a team-scoped session sees no events naming sessions or
46
+ * teams hidden from it). Read fresh per inspected batch; undefined: no narrowing.
47
+ */
48
+ readonly scope?: (userID: string, actor: AgentActor) => Promise<((subject: AgentEvent["subject"]) => boolean) | undefined>
49
+ /**
50
+ * Resolves `?session=` addresses for the caller (HWA-28/40: a session hidden from a team-scoped caller is not_found
51
+ * exactly like an unknown one, as watch?session= answers). Absent: the directory.
52
+ */
53
+ readonly resolve?: (userID: string, actor: AgentActor, addr: string) => Promise<readonly string[]>
42
54
  }
43
55
 
44
56
  export interface EventsPage {
@@ -73,6 +85,9 @@ export function visible(record: EventRecord, actor: AgentActor): boolean {
73
85
  return has("messages:read") && (record.audience?.parties ?? []).includes(party)
74
86
  case "launch":
75
87
  return has("sessions:launch") && record.audience?.by === launchedBy(actor)
88
+ case "artifact":
89
+ // Artifact IDs of the user's library: app tokens with artifacts:read; sessions never (most are private).
90
+ return actor.kind === "app" && actor.scopes.includes("artifacts:read")
76
91
  }
77
92
  }
78
93
 
@@ -89,7 +104,8 @@ export function eventsRoutes(deps: EventsRouteDeps): AgentRoute[] {
89
104
  const addrs = url.searchParams.getAll("session").filter(Boolean)
90
105
  if (types.length > FILTER_MAX || addrs.length > FILTER_MAX) throw invalid(`At most ${FILTER_MAX} type and ${FILTER_MAX} session filters`)
91
106
  if (types.some((t) => t.length > 100 || !TYPE.test(t))) throw invalid("type must be an event type such as session.changed or launch.*")
92
- const sessions = addrs.length ? new Set(addrs.flatMap((addr) => deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID))) : undefined
107
+ const resolve = deps.resolve ?? (async (userID: string, _actor: AgentActor, addr: string) => deps.directory.resolve(userID, addr).sessions.map((s) => s.sessionID))
108
+ const sessions = addrs.length ? new Set((await Promise.all(addrs.map((addr) => resolve(user.id, actor, addr)))).flat()) : undefined
93
109
  const page = await read(deps, user.id, actor, { cursor: url.searchParams.get("cursor") ?? undefined, wait, limit, types, sessions }, request.signal)
94
110
  return Response.json(page, { headers: NO_STORE })
95
111
  },
@@ -131,8 +147,9 @@ export async function read(deps: EventsRouteDeps, userID: string, actor: AgentAc
131
147
  if ("reset" in result) return { events: [], cursor: encodeCursor(keys, userID, await events.latest(userID)), more: false, reset: true, refresh: RESET_REFRESH }
132
148
  const found: AgentEvent[] = []
133
149
  let more = result.records.length > INSPECT_MAX
150
+ const scoped = result.records.length && deps.scope && actor.kind === "session" ? await deps.scope(userID, actor) : undefined
134
151
  for (const record of result.records.slice(0, INSPECT_MAX)) {
135
- if (visible(record, actor) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
152
+ if (visible(record, actor) && (!scoped || scoped(record.event.subject)) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
136
153
  if (found.length === options.limit) {
137
154
  more = true
138
155
  break
@@ -23,8 +23,8 @@ export const AGENT_EVENTS_AGE_MS = 86_400_000
23
23
  /** Record format version (readers reject others rather than skip them). */
24
24
  export const AGENT_EVENT_VERSION = 1
25
25
 
26
- /** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
27
- export type EventFamily = "session" | "manage" | "message" | "launch"
26
+ /** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch, artifacts:read. */
27
+ export type EventFamily = "session" | "manage" | "message" | "launch" | "artifact"
28
28
 
29
29
  export type EventSubject = Readonly<Record<string, string>>
30
30
  export type EventData = Readonly<Record<string, string | number | boolean>>
@@ -66,7 +66,7 @@ export interface EventRecord {
66
66
  /** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
67
67
  export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
68
68
 
69
- const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
69
+ const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch", "artifact"])
70
70
  const EVENT_ID = /^evt_[0-9a-f]{20}$/
71
71
 
72
72
  export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
@@ -203,6 +203,14 @@ export const archiveEvent = (sessionID: string, archived: boolean): EventInput =
203
203
 
204
204
  export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
205
205
 
206
+ /**
207
+ * (CON-04) The user's Concierge changed: its status (state, holder generation) or a record's revision. IDs, enums and
208
+ * counts only: never settings, notes, intents or board text.
209
+ */
210
+ export function conciergeEvent(data: { readonly what: "status"; readonly state: string; readonly generation: number } | { readonly what: "watchlist" | "settings" | "notes"; readonly revision: number }): EventInput {
211
+ return { type: "concierge.changed", family: "manage", subject: {}, data: { ...data } }
212
+ }
213
+
206
214
  /** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
207
215
  export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
208
216
 
@@ -0,0 +1,85 @@
1
+ // CON-13 an agent's form answer, checked against the form's schema (the blocker's fields, src/status.ts) before the
2
+ // hub acts on it or proposes it: one problem at a time, naming the field and what it takes, so a model corrects
3
+ // itself instead of sending OpenCode an answer it refuses. Only the fields the hub knows are checked: a form without
4
+ // fields (an older spoke, or fields it could not render) is left to OpenCode.
5
+ import type { FormAnswer } from "../../protocol"
6
+ import type { FormFieldSpec } from "../../status"
7
+
8
+ type Value = FormAnswer[string]
9
+
10
+ const quote = (s: string) => JSON.stringify(s.length > 80 ? `${s.slice(0, 80)}…` : s)
11
+ const listed = (items: readonly string[], max = 10) => items.slice(0, max).map(quote).join(", ") + (items.length > max ? ` (${items.length - max} more)` : "")
12
+
13
+ /** Whether the field's `when` conditions hold for the answer (as OpenCode compares them). */
14
+ function visible(field: FormFieldSpec, answer: FormAnswer): boolean {
15
+ return (field.when ?? []).every((w) => (w.op === "eq" ? answer[w.key] === w.value : answer[w.key] !== w.value))
16
+ }
17
+
18
+ function optionsProblem(field: FormFieldSpec, values: readonly string[]): string | undefined {
19
+ if (!field.options?.length || field.custom) return undefined
20
+ const allowed = field.options.map((o) => o.value)
21
+ const wrong = values.find((v) => !allowed.includes(v))
22
+ if (wrong === undefined) return undefined
23
+ return `Form field ${quote(field.key)}: ${quote(wrong)} is not one of the options; use an option value: ${listed(allowed)}`
24
+ }
25
+
26
+ function valueProblem(field: FormFieldSpec, value: Value): string | undefined {
27
+ const name = `Form field ${quote(field.key)}`
28
+ switch (field.type) {
29
+ case "external":
30
+ return `${name} is a link for the user: leave it out`
31
+ case "boolean":
32
+ return typeof value === "boolean" ? undefined : `${name} takes true or false`
33
+ case "number":
34
+ case "integer": {
35
+ if (typeof value !== "number" || !Number.isFinite(value)) return `${name} takes a number`
36
+ if (field.type === "integer" && !Number.isInteger(value)) return `${name} takes a whole number`
37
+ if (field.minimum !== undefined && value < field.minimum) return `${name} is at least ${field.minimum}`
38
+ if (field.maximum !== undefined && value > field.maximum) return `${name} is at most ${field.maximum}`
39
+ return undefined
40
+ }
41
+ case "multiselect": {
42
+ if (!Array.isArray(value)) return `${name} takes a list of option values`
43
+ if (field.minItems !== undefined && value.length < field.minItems) return `${name} takes at least ${field.minItems} choice${field.minItems === 1 ? "" : "s"}`
44
+ if (field.maxItems !== undefined && value.length > field.maxItems) return `${name} takes at most ${field.maxItems} choice${field.maxItems === 1 ? "" : "s"}`
45
+ return optionsProblem(field, value)
46
+ }
47
+ case "string": {
48
+ if (typeof value !== "string") return `${name} takes text`
49
+ const options = optionsProblem(field, [value])
50
+ if (options) return options
51
+ if (field.minLength !== undefined && value.length < field.minLength) return `${name} is at least ${field.minLength} characters`
52
+ if (field.maxLength !== undefined && value.length > field.maxLength) return `${name} is at most ${field.maxLength} characters`
53
+ if (field.pattern) {
54
+ let pattern: RegExp | undefined
55
+ try {
56
+ pattern = new RegExp(field.pattern)
57
+ } catch {
58
+ // a pattern the hub cannot compile is left to OpenCode
59
+ }
60
+ if (pattern && !pattern.test(value)) return `${name} does not match its pattern ${field.pattern}`
61
+ }
62
+ return undefined
63
+ }
64
+ }
65
+ }
66
+
67
+ /** The first problem of `answer` for these fields (undefined: none, or no fields known). */
68
+ export function formAnswerFieldProblem(fields: readonly FormFieldSpec[], answer: FormAnswer): string | undefined {
69
+ if (!fields.length) return undefined
70
+ const byKey = new Map(fields.map((f) => [f.key, f]))
71
+ for (const key of Object.keys(answer)) {
72
+ if (!byKey.has(key)) return `Unknown form field ${quote(key)}: the form's fields are ${listed(fields.map((f) => f.key))} (read them with the form's schema)`
73
+ }
74
+ for (const field of fields) {
75
+ const value = answer[field.key]
76
+ // (CON-13 decision) A hidden field's value is checked too; only its required flag waits for its condition.
77
+ if (value === undefined) {
78
+ if (visible(field, answer) && field.required && field.type !== "external" && field.type !== "boolean") return `Form field ${quote(field.key)} is required`
79
+ continue
80
+ }
81
+ const problem = valueProblem(field, value)
82
+ if (problem) return problem
83
+ }
84
+ return undefined
85
+ }
@@ -50,7 +50,7 @@ export interface GrantRequestsDeps {
50
50
  readonly state: StateStore
51
51
  readonly counters: Counters
52
52
  readonly decisions: Pick<Decisions, "ask" | "get">
53
- readonly grants: Pick<Grants, "create">
53
+ readonly grants: Pick<Grants, "create"> & Partial<Pick<Grants, "assertHolder">>
54
54
  readonly directory: Pick<Directory, "resolve">
55
55
  readonly audit?: Pick<AgentAudit, "record">
56
56
  /** The requester as envelopes name it; default: from the actor alone. */
@@ -83,6 +83,7 @@ export class GrantRequests {
83
83
  const own = ownHolder(actor)
84
84
  const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
85
85
  const holder = this.holder(userID, actor, spec.holder, own)
86
+ await this.deps.grants.assertHolder?.(userID, holder)
86
87
  const grant = canonical({ ...spec, holder })
87
88
 
88
89
  const requester = this.describe(userID, actor)
@@ -9,6 +9,7 @@
9
9
  // Agents only request one (grant-requests.ts): POST /api/agent/grants/requests {grant, why} from a session (any of
10
10
  // its tokens) or an app token with messages:send → 201 {message, decision} (a tap Decision for the user); the user
11
11
  // gets invalid (create it directly).
12
+ import { boundedJson } from "./body"
12
13
  import type { GrantRequests } from "./grant-requests"
13
14
  import { type Grant, type Grants, grantState } from "./grants"
14
15
  import type { AgentRoute } from "./routes"
@@ -32,7 +33,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
32
33
  method: "POST",
33
34
  path: `${PREFIX}/requests`,
34
35
  allow: { session: true, scope: "messages:send" },
35
- handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await request.json()), { status: 201, headers: NO_STORE }),
36
+ handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await boundedJson(request)), { status: 201, headers: NO_STORE }),
36
37
  } satisfies AgentRoute,
37
38
  ]
38
39
  : []),
@@ -40,7 +41,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
40
41
  method: "POST",
41
42
  path: PREFIX,
42
43
  allow: {},
43
- handle: async ({ request, user }) => one(await grants.create(user.id, await request.json()), 201),
44
+ handle: async ({ request, user }) => one(await grants.create(user.id, await boundedJson(request)), 201),
44
45
  },
45
46
  {
46
47
  method: "GET",
@@ -58,7 +59,7 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
58
59
  method: "PUT",
59
60
  path: `${PREFIX}/:id`,
60
61
  allow: {},
61
- handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await request.json())),
62
+ handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await boundedJson(request))),
62
63
  },
63
64
  {
64
65
  method: "DELETE",
@@ -20,6 +20,7 @@ import { AgentError } from "./errors"
20
20
  import type { AgentActor, AppActor, SessionActor } from "./actor"
21
21
  import { type GrantSpec, type GrantVerb, inProjects, validateGrant } from "./policy"
22
22
  import { updateVersioned } from "./versioned"
23
+ import { isManagedRole } from "./names"
23
24
 
24
25
  /** Active (unexpired, unrevoked) grants per user. */
25
26
  export const GRANTS_ACTIVE_MAX = 50
@@ -63,6 +64,8 @@ export interface GrantsDeps {
63
64
  /** The hub-wide admin audit (Store): summaries only. */
64
65
  readonly admin?: { audit(entry: Omit<AuditEntry, "at">): void | Promise<void> }
65
66
  readonly now?: () => number
67
+ /** (CON-04) The user's Concierge sessions: only the Concierge service (managed) grants to them or to @@concierge. */
68
+ readonly concierge?: (owner: string) => Promise<readonly string[]>
66
69
  }
67
70
 
68
71
  const notFound = () => new AgentError("not_found", "No such grant")
@@ -74,10 +77,33 @@ export class Grants {
74
77
  this.now = deps.now ?? Date.now
75
78
  }
76
79
 
80
+ /**
81
+ * (CON-04) Refuses a holder that is the Concierge (a concierge* role, or a Concierge session by ID or name) unless
82
+ * the Concierge service itself (managed) grants it: 403 {policy: "concierge"}.
83
+ */
84
+ async assertHolder(owner: string, holder: string, options: { readonly managed?: boolean } = {}): Promise<void> {
85
+ if (options.managed) return
86
+ const role = holder.startsWith("@@") ? holder.slice(2).split(".")[0]! : undefined
87
+ let concierge = role !== undefined && isManagedRole(role)
88
+ if (!concierge && role === undefined && !holder.startsWith("app:") && this.deps.concierge) {
89
+ const hidden = await this.deps.concierge(owner)
90
+ if (hidden.includes(holder)) concierge = true
91
+ else {
92
+ try {
93
+ concierge = this.deps.directory.resolve(owner, holder).sessions.some((s) => hidden.includes(s.sessionID))
94
+ } catch {
95
+ concierge = false
96
+ }
97
+ }
98
+ }
99
+ if (concierge) throw new AgentError("forbidden", "The Concierge's grant is managed by the hub", { hint: "Use Settings → Concierge", extra: { policy: "concierge" } })
100
+ }
101
+
77
102
  /** Validates and stores a new grant of the owner (the holder resolved and pinned for the owner). */
78
- async create(owner: string, input: unknown): Promise<Grant> {
103
+ async create(owner: string, input: unknown, options: { readonly managed?: boolean } = {}): Promise<Grant> {
79
104
  this.owner(owner)
80
105
  const spec = validateGrant(input)
106
+ await this.assertHolder(owner, spec.holder, options)
81
107
  const holderSessionID = this.pin(owner, spec.holder)
82
108
  const all = await this.all(owner)
83
109
  const at = this.now()
@@ -109,8 +135,9 @@ export class Grants {
109
135
  * Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
110
136
  * stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
111
137
  */
112
- async update(owner: string, id: string, input: unknown): Promise<Grant> {
138
+ async update(owner: string, id: string, input: unknown, options: { readonly managed?: boolean } = {}): Promise<Grant> {
113
139
  const spec = validateGrant(input)
140
+ await this.assertHolder(owner, spec.holder, options)
114
141
  return this.change(owner, id, "grant.update", (grant, at) => {
115
142
  if (spec.holder !== grant.holder) throw new AgentError("invalid", "A grant's holder cannot change: revoke it and create another")
116
143
  return { ...grant, ...fields(spec, at), updated: at }