@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -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`). */
@@ -33,6 +35,8 @@ export const AGENT_ERROR_STATUS = {
33
35
  internal: 500,
34
36
  /** The machine is offline (actions only; messages queue instead). */
35
37
  offline: 503,
38
+ /** (FX-07) The hub's state did not answer in time (`retryAfter`, seconds): refused, try again. */
39
+ busy: 503,
36
40
  } as const satisfies Record<string, number>
37
41
 
38
42
  export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
@@ -41,6 +45,8 @@ export interface AgentErrorOptions {
41
45
  readonly hint?: string
42
46
  /** More fields in the envelope, e.g. candidates, retryAfter, feature; never code or message. */
43
47
  readonly extra?: Record<string, unknown>
48
+ /** Answer with `Connection: close` (the request body was left unread on the socket: agent/body.ts). */
49
+ readonly close?: boolean
44
50
  }
45
51
 
46
52
  /** Throw from an agent handler; answered as the envelope with the code's status. */
@@ -59,6 +65,7 @@ export function agentError(error: AgentError): Response {
59
65
  const headers: Record<string, string> = { "cache-control": "no-store" }
60
66
  const retryAfter = extra.retryAfter
61
67
  if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) headers["retry-after"] = String(Math.ceil(retryAfter))
68
+ if (error.options.close) headers.connection = "close"
62
69
  return Response.json(
63
70
  { error: { ...extra, code: error.code, message: error.message, ...(hint ? { hint } : {}) } },
64
71
  { status: AGENT_ERROR_STATUS[error.code], headers },
@@ -25,7 +25,7 @@ import type { BlockerRecommendation } from "../../status"
25
25
  import type { Escalation } from "./acting"
26
26
  import type { AgentActor, UserActor } from "./actor"
27
27
  import type { AgentAudit } from "./audit"
28
- import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
28
+ import { type ActOutcome, type Decision, type DecisionActor, type Decisions, decisionRequest, WHY_MAX } from "./decisions"
29
29
  import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
30
30
  import { AgentError } from "./errors"
31
31
  import type { Message } from "./messages"
@@ -168,7 +168,15 @@ export class Escalations {
168
168
  about: [escalation.sessionID],
169
169
  },
170
170
  {
171
- action: { kind: "permission", requestID: escalation.requestID, rootID: escalation.rootID, sessionID: escalation.sessionID, always: escalation.always },
171
+ action: {
172
+ kind: "permission",
173
+ requestID: escalation.requestID,
174
+ rootID: escalation.rootID,
175
+ sessionID: escalation.sessionID,
176
+ always: escalation.always,
177
+ ...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
178
+ ...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
179
+ },
172
180
  quiet: pushes > 1,
173
181
  },
174
182
  )
@@ -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")}`
@@ -193,7 +193,13 @@ export function launchEvent(launch: Launch): EventInput {
193
193
  type: "launch.changed",
194
194
  family: "launch",
195
195
  subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
196
- data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
196
+ data: {
197
+ state: launch.state,
198
+ ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
199
+ ...(launch.name !== undefined ? { name: launch.name } : {}),
200
+ // FX-02: a queued launch's wait kind (never its text); the engine journals a change of kind only.
201
+ ...(launch.state === "queued" && launch.waiting ? { wait: launch.waiting.wait } : {}),
202
+ },
197
203
  ...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
198
204
  }
199
205
  }
@@ -203,6 +209,14 @@ export const archiveEvent = (sessionID: string, archived: boolean): EventInput =
203
209
 
204
210
  export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
205
211
 
212
+ /**
213
+ * (CON-04) The user's Concierge changed: its status (state, holder generation) or a record's revision. IDs, enums and
214
+ * counts only: never settings, notes, intents or board text.
215
+ */
216
+ export function conciergeEvent(data: { readonly what: "status"; readonly state: string; readonly generation: number } | { readonly what: "watchlist" | "settings" | "notes"; readonly revision: number }): EventInput {
217
+ return { type: "concierge.changed", family: "manage", subject: {}, data: { ...data } }
218
+ }
219
+
206
220
  /** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
207
221
  export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
208
222
 
@@ -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 }