@huynle/hub 0.7.1 → 0.7.2

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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -5,6 +5,9 @@ import { sseEvents } from "./sse.ts"
5
5
  import type {
6
6
  Approved,
7
7
  AppTokenView,
8
+ Archived,
9
+ ArchiveInactivePreview,
10
+ ArchiveInactiveResult,
8
11
  Asked,
9
12
  CreatedAppToken,
10
13
  Decision,
@@ -30,6 +33,7 @@ import type {
30
33
  Named,
31
34
  PromptDelivery,
32
35
  Prompted,
36
+ Pinned,
33
37
  Replied,
34
38
  RoleClaim,
35
39
  RoleRelease,
@@ -40,7 +44,10 @@ import type {
40
44
  Status,
41
45
  Stopped,
42
46
  WatchEvent,
47
+ EventsPage,
43
48
  Whoami,
49
+ WebhookDelivery,
50
+ WebhookView,
44
51
  } from "./types.ts"
45
52
 
46
53
  const AGENT = "/api/agent"
@@ -296,15 +303,16 @@ export class HubClient {
296
303
  }
297
304
 
298
305
  /**
299
- * GET /api/agent/watch: the events (session.*, blocker.*, …) as they happen, until the hub closes the stream or
300
- * `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one address.
306
+ * GET /api/agent/watch: the journal's compact events (the same as events()) as they happen, until the hub closes
307
+ * the stream or `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
308
+ * address; cursor: resume after an event's `id` (or an events() cursor).
301
309
  */
302
- async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
310
+ async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string; readonly cursor?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
303
311
  const { signal } = options
304
312
  if (signal?.aborted) return
305
313
  let response: Response
306
314
  try {
307
- response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session }, ...signalOf(options) })
315
+ response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session, cursor: options.cursor }, ...signalOf(options) })
308
316
  } catch (cause) {
309
317
  if (signal?.aborted) return
310
318
  throw cause
@@ -312,6 +320,49 @@ export class HubClient {
312
320
  yield* sseEvents(response, signal)
313
321
  }
314
322
 
323
+ /**
324
+ * GET /api/agent/events: the compact events after `cursor` (none: start at the latest). wait: seconds (0–60) to
325
+ * hold while nothing matches; limit 1–200; types (exact, or `launch.*`) and sessions filter but still advance the
326
+ * cursor. App tokens need events:subscribe plus each family's scope.
327
+ */
328
+ events(options: CallOptions & { readonly cursor?: string; readonly wait?: number; readonly limit?: number; readonly types?: readonly string[]; readonly sessions?: readonly string[] } = {}): Promise<EventsPage> {
329
+ return this.call("GET", `${AGENT}/events`, { query: { cursor: options.cursor, wait: options.wait, limit: options.limit, type: options.types, session: options.sessions }, ...signalOf(options) })
330
+ }
331
+
332
+ webhookCreate(input: { readonly url: string; readonly events?: readonly string[]; readonly label?: string }, options: CallOptions = {}): Promise<{ webhook: WebhookView; secret: string }> {
333
+ return this.call("POST", `${AGENT}/webhooks`, { body: input, ...signalOf(options) })
334
+ }
335
+
336
+ webhooks(options: CallOptions = {}): Promise<{ webhooks: WebhookView[] }> {
337
+ return this.call("GET", `${AGENT}/webhooks`, signalOf(options))
338
+ }
339
+
340
+ webhook(id: string, options: CallOptions = {}): Promise<{ webhook: WebhookView; deliveries: WebhookDelivery[] }> {
341
+ return this.call("GET", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
342
+ }
343
+
344
+ webhookDelete(id: string, options: CallOptions = {}): Promise<void> {
345
+ return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
346
+ }
347
+
348
+ // Organization (sessions:manage).
349
+
350
+ setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
351
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/archive`, { body: { archived }, ...signalOf(options) })
352
+ }
353
+
354
+ setPinned(addr: string, pinned: boolean, options: CallOptions = {}): Promise<Pinned> {
355
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/pin`, { body: { pinned }, ...signalOf(options) })
356
+ }
357
+
358
+ previewArchiveInactive(inactiveFor: string, limit: number, options: CallOptions = {}): Promise<ArchiveInactivePreview> {
359
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { preview: true, inactiveFor, limit }, ...signalOf(options) })
360
+ }
361
+
362
+ archiveInactive(previewToken: string, options: CallOptions = {}): Promise<ArchiveInactiveResult> {
363
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { previewToken, confirm: true }, ...signalOf(options) })
364
+ }
365
+
315
366
  // Naming.
316
367
 
317
368
  name(name: string, options: TargetOptions = {}): Promise<Named> {
@@ -6,6 +6,7 @@ import type { Evaluation } from "./types.ts"
6
6
  /** The codes the hub answers with (src/central/agent/errors.ts); later phases may add more, so any string is kept. */
7
7
  export type ErrorCode =
8
8
  | "invalid"
9
+ | "invalid_cursor"
9
10
  | "unauthorized"
10
11
  | "token_expired"
11
12
  | "needs_user"
@@ -5,6 +5,8 @@ import type { WatchEvent } from "./types.ts"
5
5
  /** Parses SSE text incrementally: feed chunks, get complete events (events without data are skipped). */
6
6
  export class SseParser {
7
7
  #buffer = ""
8
+ /** The last `id:` seen (it carries over to later events, as in EventSource). */
9
+ #id = ""
8
10
 
9
11
  push(chunk: string): WatchEvent[] {
10
12
  this.#buffer += chunk.replace(/\r\n?/g, "\n")
@@ -22,8 +24,9 @@ export class SseParser {
22
24
  const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "")
23
25
  if (field === "event") event = value
24
26
  else if (field === "data") data.push(value)
27
+ else if (field === "id" && !value.includes("\0")) this.#id = value
25
28
  }
26
- if (data.length) out.push({ event, data: data.join("\n") })
29
+ if (data.length) out.push({ event, data: data.join("\n"), ...(this.#id ? { id: this.#id } : {}) })
27
30
  }
28
31
  return out
29
32
  }
@@ -121,6 +121,43 @@ export interface DirectoryEntry {
121
121
  readonly roles: readonly RoleView[]
122
122
  }
123
123
 
124
+ /** One compact event of GET /api/agent/events (no free text). Unknown types are valid: ignore them. */
125
+ export interface AgentEvent {
126
+ readonly id: string
127
+ readonly at: number
128
+ readonly type: string
129
+ readonly subject: Readonly<Record<string, string>>
130
+ readonly data: Readonly<Record<string, string | number | boolean>>
131
+ }
132
+
133
+ /**
134
+ * A page of GET /api/agent/events: pass `cursor` to the next call. `more`: records remain unread. `reset`: the
135
+ * cursor was no longer covered (expired, trimmed, hub restarted); re-read the `refresh` snapshots, then go on.
136
+ */
137
+ export interface EventsPage {
138
+ readonly events: AgentEvent[]
139
+ readonly cursor: string
140
+ readonly more: boolean
141
+ readonly reset: boolean
142
+ readonly refresh?: string[]
143
+ }
144
+
145
+ export interface Archived {
146
+ readonly sessionID: string
147
+ readonly archived: boolean
148
+ readonly changed: boolean
149
+ }
150
+
151
+ export interface Pinned {
152
+ readonly sessionID: string
153
+ readonly pinned: boolean
154
+ readonly changed: boolean
155
+ }
156
+
157
+ export type ArchiveSkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
158
+ export interface ArchiveInactivePreview { readonly candidates: readonly string[]; readonly previewToken: string; readonly expiresAt: number }
159
+ export interface ArchiveInactiveResult { readonly changed: readonly string[]; readonly skipped: readonly { readonly sessionID: string; readonly reason: ArchiveSkipReason }[] }
160
+
124
161
  export interface Named {
125
162
  readonly sessionID: string
126
163
  readonly name: string
@@ -271,6 +308,8 @@ export interface Inbox {
271
308
  export interface WatchEvent {
272
309
  readonly event: string
273
310
  readonly data: string
311
+ /** The stream's cursor at this event (GET /api/agent/watch?cursor= resumes after it). */
312
+ readonly id?: string
274
313
  }
275
314
 
276
315
  // Acting (POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop}; src/central/agent/acting.ts).
@@ -430,6 +469,26 @@ export interface LaunchArtifacts {
430
469
  readonly dirty?: boolean
431
470
  }
432
471
 
472
+ export interface WebhookView {
473
+ readonly id: string
474
+ readonly url: string
475
+ readonly events: readonly string[]
476
+ readonly by: string
477
+ readonly label?: string
478
+ readonly created: number
479
+ }
480
+
481
+ export interface WebhookDelivery {
482
+ readonly id: string
483
+ readonly event: string
484
+ readonly type: string
485
+ readonly state: "pending" | "delivered" | "failed"
486
+ readonly attempts: number
487
+ readonly created: number
488
+ readonly nextAt?: number
489
+ readonly last?: { readonly at: number; readonly status?: number; readonly error?: string }
490
+ }
491
+
433
492
  /** A launch as the hub reports it (the spec with its defaults filled in). */
434
493
  export interface LaunchView {
435
494
  readonly id: string
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -10,19 +10,40 @@ export interface EnrollOptions {
10
10
  readonly signal?: AbortSignal
11
11
  readonly fetch?: typeof fetch
12
12
  readonly now?: () => number
13
+ /**
14
+ * "follow" (default) follows HTTP redirects. "error" refuses them: a redirect could send the device code to
15
+ * another origin or downgrade to plain http. A refused redirect throws EnrollmentRefused, which polling does not
16
+ * retry.
17
+ */
18
+ readonly redirect?: "follow" | "error"
19
+ /**
20
+ * The hub hands out an approved credential exactly once. With keepRedeemed, `signal` never aborts a poll request
21
+ * in flight (its reply may carry that credential); it still stops polling before the next request.
22
+ */
23
+ readonly keepRedeemed?: boolean
24
+ }
25
+
26
+ /** The hub answered in a way the caller's policy refuses (a redirect under redirect: "error"); not retried. */
27
+ export class EnrollmentRefused extends Error {
28
+ override readonly name = "EnrollmentRefused"
13
29
  }
14
30
 
15
31
  const REQUEST_TIMEOUT_MS = 15_000
16
32
 
17
- async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
33
+ async function post(server: string, path: string, body: unknown, options: EnrollOptions, abortable = true): Promise<unknown> {
18
34
  const doFetch = options.fetch ?? fetch
19
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
35
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal && abortable ? [options.signal] : [])]
36
+ const strict = options.redirect === "error"
20
37
  const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
21
38
  method: "POST",
22
39
  headers: { "content-type": "application/json" },
23
40
  body: JSON.stringify(body),
24
41
  signal: AbortSignal.any(signals),
42
+ ...(strict ? { redirect: "manual" as const } : {}),
25
43
  })
44
+ if (strict && (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400))) {
45
+ throw new EnrollmentRefused(`${path} was redirected; enrollment does not follow redirects`)
46
+ }
26
47
  if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
27
48
  return response.json()
28
49
  }
@@ -67,8 +88,9 @@ export async function pollEnrollment(server: string, start: EnrollStart, options
67
88
  if (options.signal?.aborted) return { status: "cancelled" }
68
89
  let reply: Partial<EnrollPoll> & Record<string, unknown>
69
90
  try {
70
- reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
71
- } catch {
91
+ reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options, !options.keepRedeemed)) as typeof reply
92
+ } catch (cause) {
93
+ if (cause instanceof EnrollmentRefused) throw cause
72
94
  continue
73
95
  }
74
96
  if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
@@ -19,8 +19,10 @@ import type { AgentRoute } from "./routes"
19
19
  * acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
20
20
  * needs a grant held by the token (with the act verb, or `launch` covering the repo).
21
21
  */
22
- export const APP_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read", "sessions:act", "sessions:launch"]
22
+ export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
23
23
  export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
24
+ /** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
25
+ export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
24
26
  export const MAX_APP_TOKENS = 50
25
27
  export const MAX_APP_TOKEN_LABEL = 100
26
28
 
@@ -88,6 +90,29 @@ const view = (token: AppToken) => ({
88
90
  ...(typeof token.created === "number" ? { created: token.created } : {}),
89
91
  })
90
92
 
93
+ /**
94
+ * Issues an app token of `userID` (the POST route below, and an approved CLI login): the record stored (sha256 of the
95
+ * token only) and the token, which the caller hands out once and never logs. rate_limited at MAX_APP_TOKENS.
96
+ */
97
+ export async function issueAppToken(store: Store, userID: string, input: { readonly label?: string; readonly scopes: readonly string[] }): Promise<{ record: AppToken; token: string }> {
98
+ const id = randomBytes(8).toString("hex")
99
+ const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
100
+ const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: [...input.scopes], created: Date.now() }
101
+ let full = false
102
+ await store.updateAgentData(userID, (data) => {
103
+ const tokens = data.appTokens ?? []
104
+ full = false
105
+ if (tokens.length >= MAX_APP_TOKENS) {
106
+ full = true
107
+ return
108
+ }
109
+ data.appTokens = [...tokens, record]
110
+ })
111
+ if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
112
+ await store.audit({ actor: userID, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
113
+ return { record, token }
114
+ }
115
+
91
116
  /** POST, GET and DELETE /api/agent/app-tokens: user actors only. */
92
117
  export function appTokenRoutes(store: Store): AgentRoute[] {
93
118
  return [
@@ -96,22 +121,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
96
121
  path: PREFIX,
97
122
  allow: {},
98
123
  async handle({ request, user }) {
99
- const input = parseCreate(await request.json())
100
- const id = randomBytes(8).toString("hex")
101
- const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
102
- const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: input.scopes, created: Date.now() }
103
- let full = false
104
- await store.updateAgentData(user.id, (data) => {
105
- const tokens = data.appTokens ?? []
106
- full = false
107
- if (tokens.length >= MAX_APP_TOKENS) {
108
- full = true
109
- return
110
- }
111
- data.appTokens = [...tokens, record]
112
- })
113
- if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
114
- await store.audit({ actor: user.id, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
124
+ const { record, token } = await issueAppToken(store, user.id, parseCreate(await request.json()))
115
125
  return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
116
126
  },
117
127
  },
@@ -0,0 +1,66 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto"
2
+ import type { DirectoryEntry } from "./directory"
3
+ import { AgentError } from "./errors"
4
+
5
+ const PREFIX = "hai1_"
6
+ const DOMAIN = "hub-archive-inactive/v1"
7
+ const TTL_MS = 5 * 60_000
8
+ const invalid = () => new AgentError("invalid", "This inactive-archive preview is invalid or expired; preview again")
9
+
10
+ interface Payload { readonly userID: string; readonly at: number; readonly inactiveForMs: number; readonly candidates: readonly string[] }
11
+ export interface ArchiveSets { readonly pinned: ReadonlySet<string>; readonly archived: ReadonlySet<string> }
12
+ export type SkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
13
+
14
+ export class ArchiveInactive {
15
+ constructor(private readonly options: { readonly secret: Buffer; readonly now?: () => number }) {}
16
+ private now() { return (this.options.now ?? Date.now)() }
17
+
18
+ preview(userID: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly inactiveForMs: number; readonly limit: number }) {
19
+ const candidates = sessions
20
+ .filter((s) => !s.online && typeof s.lastSeen === "number" && this.now() - s.lastSeen >= options.inactiveForMs && !options.pinned.has(s.sessionID) && !options.archived.has(s.sessionID))
21
+ .map((s) => s.sessionID).slice(0, options.limit)
22
+ const payload: Payload = { userID, at: this.now(), inactiveForMs: options.inactiveForMs, candidates }
23
+ return { candidates, previewToken: this.encode(payload), expiresAt: payload.at + TTL_MS }
24
+ }
25
+
26
+ decode(userID: string, token: string): Payload {
27
+ if (!token.startsWith(PREFIX) || token.length > 10_000) throw invalid()
28
+ const [body, tag, ...rest] = token.slice(PREFIX.length).split(".")
29
+ if (rest.length || !body || !tag) throw invalid()
30
+ const expected = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16)
31
+ const given = Buffer.from(tag, "base64url")
32
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw invalid()
33
+ let payload: Payload
34
+ try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as Payload } catch { throw invalid() }
35
+ if (payload.userID !== userID || !Array.isArray(payload.candidates) || this.now() - payload.at > TTL_MS) throw invalid()
36
+ return payload
37
+ }
38
+
39
+ revalidate(userID: string, token: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly open: ReadonlySet<string>; readonly active: ReadonlySet<string> }) {
40
+ const payload = this.decode(userID, token)
41
+ const byID = new Map(sessions.map((s) => [s.sessionID, s]))
42
+ const candidates: string[] = []
43
+ const skipped: Array<{ sessionID: string; reason: SkipReason }> = []
44
+ for (const sessionID of payload.candidates) {
45
+ const s = byID.get(sessionID)
46
+ const reason: SkipReason | undefined = !s ? "not_found" : options.archived.has(sessionID) ? "archived" : options.pinned.has(sessionID) ? "pinned" : options.open.has(sessionID) ? "open" : options.active.has(sessionID) || s.online ? "active" : undefined
47
+ if (reason) skipped.push({ sessionID, reason }); else candidates.push(sessionID)
48
+ }
49
+ return { candidates, skipped }
50
+ }
51
+
52
+ private encode(payload: Payload) {
53
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
54
+ const tag = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16).toString("base64url")
55
+ return `${PREFIX}${body}.${tag}`
56
+ }
57
+ }
58
+
59
+ export function parseInactiveFor(value: unknown): number {
60
+ if (typeof value !== "string") throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
61
+ const m = /^(\d+)(h|d)$/.exec(value)
62
+ if (!m) throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
63
+ const ms = Number(m[1]) * (m[2] === "d" ? 86_400_000 : 3_600_000)
64
+ if (ms < 3_600_000 || ms > 365 * 86_400_000) throw new AgentError("invalid", "inactiveFor must be from 1h to 365d")
65
+ return ms
66
+ }
@@ -7,15 +7,30 @@
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?}
10
+ // PUT /api/agent/sessions/:addr/archive {archived}
10
11
  // `session` is an address (ses_…, ID prefix, @name, …) resolved for the caller; a session token defaults to
11
- // its own session, a user must name one.
12
+ // its own session, a user or app must name one. Foreign and unknown sessions are both not_found.
13
+ //
14
+ // App tokens (lifecycle design, decision xwzersmm): naming and archiving need sessions:manage; a role claim or
15
+ // release also needs a grant allowing `roles` whose projects cover the (live) session's directory, in the acting
16
+ // order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
17
+ // roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
18
+ // journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
19
+ import type { ActingSwitch } from "./acting-switch"
20
+ import { auditOf } from "./acting"
21
+ import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
22
+ import { partyFromActor } from "./envelope"
23
+ import { type AgentEvents, archiveEvent, type EventInput, namedEvent, pinnedEvent, roleEvent } from "./events"
24
+ import type { Grant, Grants } from "./grants"
12
25
  import path from "node:path"
13
- import type { CatalogMachine, RegistryMessage } from "../registry"
26
+ import type { CatalogMachine, Registry, RegistryMessage } from "../registry"
14
27
  import type { Store } from "../store"
15
28
  import { Directory, type ReportedSession, type SessionSource } from "./directory"
16
29
  import { AgentError } from "./errors"
30
+ import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
17
31
  import { slugify } from "./names"
18
32
  import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
33
+ import type { CursorKeys } from "./event-cursor"
19
34
 
20
35
  /** What the directory reads from the Registry. */
21
36
  export interface DirectoryRegistry {
@@ -127,8 +142,120 @@ function target(directory: Directory, { actor, user }: AgentContext, addr: strin
127
142
  const NO_STORE = { "cache-control": "no-store" }
128
143
  const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
129
144
 
130
- export function directoryRoutes(directory: Directory): AgentRoute[] {
145
+ export interface DirectoryRoutesDeps {
146
+ readonly directory: Directory
147
+ readonly store: Pick<Store, "setSessionArchived" | "setSessionPinned" | "archiveSessionIfUnpinned" | "viewPrefs" | "agentData" | "audit">
148
+ readonly events?: Pick<AgentEvents, "append">
149
+ /** App role writes: the live session (its directory, for the grant's projects). */
150
+ readonly registry?: Pick<Registry, "sessionTenant" | "session">
151
+ readonly grants?: Pick<Grants, "usable" | "consume">
152
+ readonly acting?: Pick<ActingSwitch, "ensureActing">
153
+ readonly audit?: Pick<AgentAudit, "record">
154
+ readonly previewKeys?: CursorKeys
155
+ }
156
+
157
+ const MANAGE = { session: true, scope: "sessions:manage" } as const
158
+
159
+ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
160
+ const { directory, store, events } = deps
161
+
162
+ /** The admin audit and the journal, once per change. */
163
+ async function changed(context: AgentContext, action: string, sessionID: string, event: EventInput): Promise<void> {
164
+ await store.audit({ actor: context.user.id, action, target: sessionID, detail: context.actor.kind })
165
+ await events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
166
+ }
167
+
168
+ /** A role write: users and sessions as before; an app under a `roles` grant on the session's project, audited. */
169
+ async function roleWrite<T>(context: AgentContext, verb: "roles" | "roles.release", sessionID: string, run: () => Promise<T>): Promise<T> {
170
+ const { actor, user } = context
171
+ if (actor.kind !== "app") return run()
172
+ const { registry, grants, acting, audit } = deps
173
+ if (!registry || !grants || !acting || !audit) throw new AgentError("forbidden", "This hub does not let app tokens change roles")
174
+ let grant: Grant | undefined
175
+ const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
176
+ audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
177
+ try {
178
+ acting.ensureActing(user.id)
179
+ const live = registry.sessionTenant(sessionID) === user.id ? registry.session(sessionID) : undefined
180
+ if (!live) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
181
+ const usable = await grants.usable(actor, "roles", { directory: live.directory })
182
+ if (!usable.length) throw new AgentError("forbidden", "No grant lets this app change roles in this session's project")
183
+ grant = usable[0]!
184
+ await grants.consume(grant)
185
+ acting.ensureActing(user.id)
186
+ const result = await run()
187
+ await record("allow", "ok")
188
+ return result
189
+ } catch (cause) {
190
+ const [decision, outcome] = auditOf(cause)
191
+ await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
192
+ throw cause
193
+ }
194
+ }
195
+
131
196
  return [
197
+ ...(deps.previewKeys ? [{
198
+ method: "POST" as const,
199
+ path: `${AGENT_PREFIX}/sessions/archive-inactive`,
200
+ allow: { scope: "sessions:manage" },
201
+ async handle(context: AgentContext) {
202
+ const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
203
+ const body = await readBody(context.request)
204
+ 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: [] }))
206
+ if (body.preview === true) {
207
+ const limit = body.limit
208
+ if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
209
+ return ok(inactive.preview(context.user.id, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), inactiveForMs: parseInactiveFor(body.inactiveFor), limit: Number(limit) }))
210
+ }
211
+ if (body.confirm !== true || typeof body.previewToken !== "string") throw new AgentError("invalid", "Use preview:true first, then confirm:true with its previewToken")
212
+ const open = new Set(entries.filter((s) => deps.registry?.session(s.sessionID)?.open).map((s) => s.sessionID))
213
+ const active = new Set(entries.filter((s) => { const v = deps.registry?.session(s.sessionID); return !!v && (v.busy || v.status.some((x) => x.state === "working" || x.state === "blocked")) }).map((s) => s.sessionID))
214
+ const checked = inactive.revalidate(context.user.id, body.previewToken, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), open, active })
215
+ const changedIDs: string[] = []
216
+ const skipped = [...checked.skipped]
217
+ for (const sessionID of checked.candidates) {
218
+ const result = await store.archiveSessionIfUnpinned(context.user.id, sessionID)
219
+ if (result === "changed") { changedIDs.push(sessionID); await changed(context, "session.archive", sessionID, archiveEvent(sessionID, true)) }
220
+ else skipped.push({ sessionID, reason: result === "pinned" ? "pinned" : result === "not_found" ? "not_found" : "archived" })
221
+ }
222
+ if (changedIDs.length) await directory.sync(context.user.id)
223
+ return ok({ changed: changedIDs, skipped })
224
+ },
225
+ }] : []),
226
+ {
227
+ method: "PUT",
228
+ path: `${AGENT_PREFIX}/sessions/:addr/pin`,
229
+ allow: { scope: "sessions:manage" },
230
+ async handle(context) {
231
+ const body = await readBody(context.request)
232
+ if (flag(body, "pinned") === undefined) throw new AgentError("invalid", "pinned must be true or false")
233
+ const sessionID = target(directory, context, context.params.addr)
234
+ const pinned = body.pinned === true
235
+ const result = await store.setSessionPinned(context.user.id, sessionID, pinned)
236
+ if (!result) throw new AgentError("not_found", `No session ${context.params.addr}`)
237
+ if (result.changed) await changed(context, pinned ? "session.pin" : "session.unpin", sessionID, pinnedEvent(sessionID, pinned))
238
+ return ok({ sessionID, pinned, changed: result.changed })
239
+ },
240
+ },
241
+ {
242
+ method: "PUT",
243
+ path: `${AGENT_PREFIX}/sessions/:addr/archive`,
244
+ allow: { scope: "sessions:manage" },
245
+ async handle(context) {
246
+ const body = await readBody(context.request)
247
+ if (flag(body, "archived") === undefined) throw new AgentError("invalid", "archived must be true or false")
248
+ const sessionID = target(directory, context, context.params.addr)
249
+ const archived = body.archived === true
250
+ const result = await store.setSessionArchived(context.user.id, sessionID, archived)
251
+ if (!result) throw new AgentError("invalid", "The archive is full; unarchive a session first")
252
+ if (result.changed) {
253
+ await directory.sync(context.user.id)
254
+ await changed(context, archived ? "session.archive" : "session.unarchive", sessionID, archiveEvent(sessionID, archived))
255
+ }
256
+ return ok({ sessionID, archived, changed: result.changed })
257
+ },
258
+ },
132
259
  {
133
260
  method: "GET",
134
261
  path: `${AGENT_PREFIX}/who`,
@@ -143,28 +270,39 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
143
270
  {
144
271
  method: "POST",
145
272
  path: `${AGENT_PREFIX}/name`,
146
- allow: { session: true },
273
+ allow: MANAGE,
147
274
  handle: async (context) => {
148
275
  const body = await readBody(context.request)
149
276
  const name = text(body, "name", true)
150
277
  const sessionID = target(directory, context, text(body, "session"))
151
- return ok(await directory.setName(context.user.id, sessionID, name))
278
+ const before = directory.resolveOne(context.user.id, sessionID).name
279
+ const named = await directory.setName(context.user.id, sessionID, name)
280
+ if (named.name !== before) await changed(context, "session.name", sessionID, namedEvent(sessionID, named.name))
281
+ return ok(named)
152
282
  },
153
283
  },
154
284
  {
155
285
  method: "POST",
156
286
  path: `${AGENT_PREFIX}/roles/claim`,
157
- allow: { session: true },
287
+ allow: MANAGE,
158
288
  handle: async (context) => {
159
289
  const body = await readBody(context.request)
160
290
  const role = text(body, "role", true)
161
291
  const exclusive = flag(body, "exclusive")
162
292
  const project = text(body, "project")
163
293
  const sessionID = target(directory, context, text(body, "session"))
164
- const claim = await directory.claimRole(context.user.id, sessionID, role, {
165
- ...(exclusive !== undefined ? { exclusive } : {}),
166
- ...(project !== undefined ? { project } : {}),
167
- byUser: context.actor.kind === "user",
294
+ const claim = await roleWrite(context, "roles", sessionID, async () => {
295
+ const before = directory.resolveOne(context.user.id, sessionID).roles
296
+ const claim = await directory.claimRole(context.user.id, sessionID, role, {
297
+ ...(exclusive !== undefined ? { exclusive } : {}),
298
+ ...(project !== undefined ? { project } : {}),
299
+ byUser: context.actor.kind === "user",
300
+ })
301
+ const prev = before.find((r) => r.role === claim.role && r.project === claim.project)
302
+ if (!prev || prev.generation !== claim.generation || !!prev.exclusive !== !!claim.exclusive) {
303
+ await changed(context, "role.claim", sessionID, roleEvent(sessionID, true, claim))
304
+ }
305
+ return claim
168
306
  })
169
307
  return ok({ sessionID, ...claim })
170
308
  },
@@ -172,13 +310,18 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
172
310
  {
173
311
  method: "POST",
174
312
  path: `${AGENT_PREFIX}/roles/release`,
175
- allow: { session: true },
313
+ allow: MANAGE,
176
314
  handle: async (context) => {
177
315
  const body = await readBody(context.request)
178
316
  const role = text(body, "role", true)
179
317
  const project = text(body, "project")
180
318
  const sessionID = target(directory, context, text(body, "session"))
181
- return ok({ sessionID, ...(await directory.releaseRole(context.user.id, sessionID, role, project)), released: true })
319
+ const released = await roleWrite(context, "roles.release", sessionID, async () => {
320
+ const released = await directory.releaseRole(context.user.id, sessionID, role, project)
321
+ await changed(context, "role.release", sessionID, roleEvent(sessionID, false, released))
322
+ return released
323
+ })
324
+ return ok({ sessionID, ...released, released: true })
182
325
  },
183
326
  },
184
327
  ]
@@ -207,7 +207,7 @@ export class Directory {
207
207
  /**
208
208
  * Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
209
209
  * (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
210
- * can claim while an exclusive holder is live. Reserved roles (orchestrator…, concierge…) need `byUser`
210
+ * can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
211
211
  * (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
212
212
  */
213
213
  async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
@@ -5,6 +5,8 @@
5
5
  export const AGENT_ERROR_STATUS = {
6
6
  /** Malformed request (bad JSON, bad fields, cookie and bearer together). */
7
7
  invalid: 400,
8
+ /** An events cursor this hub did not give this user, or cannot read (start again without one). */
9
+ invalid_cursor: 400,
8
10
  /** No credential, or an unknown, revoked or disabled one. */
9
11
  unauthorized: 401,
10
12
  /** A session or app token that expired: get a new one. */