@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -10,6 +10,7 @@
10
10
  // refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
11
11
  import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
12
12
  import { readCredentials } from "./agent/actor"
13
+ import { bodyError, readBounded } from "./agent/body"
13
14
 
14
15
  export const MCP_PATH = "/mcp"
15
16
 
@@ -44,25 +45,12 @@ const answer = (response: RpcResponse) =>
44
45
  // A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
45
46
  Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
46
47
 
47
- /** The body as text, or undefined when it is over `max` bytes (read no further). */
48
- async function readCapped(request: Request, max: number): Promise<string | undefined> {
49
- const length = Number(request.headers.get("content-length") ?? "")
50
- if (Number.isFinite(length) && length > max) return undefined
51
- if (!request.body) return ""
52
- const reader = request.body.getReader()
53
- const chunks: Uint8Array[] = []
54
- let size = 0
55
- for (;;) {
56
- const { done, value } = await reader.read()
57
- if (done) break
58
- size += value.byteLength
59
- if (size > max) {
60
- void reader.cancel().catch(() => {})
61
- return undefined
62
- }
63
- chunks.push(value)
64
- }
65
- return new TextDecoder().decode(Buffer.concat(chunks))
48
+ /** The body as text, or why not (over `max` bytes or too slow; agent/body.ts bounds the bytes and time read). */
49
+ async function readCapped(request: Request, max: number): Promise<{ readonly text: string } | { readonly status: 400 | 413; readonly message: string; readonly close: boolean }> {
50
+ const body = await readBounded(request, max)
51
+ if (body.ok) return { text: new TextDecoder().decode(body.bytes) }
52
+ if (body.reason === "timeout") return { status: 400, message: bodyError(body, max).message, close: true }
53
+ return { status: 413, message: `Request body over ${max} bytes`, close: body.close }
66
54
  }
67
55
 
68
56
  const acceptsJSON = (accept: string | null) => {
@@ -89,8 +77,9 @@ export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Respon
89
77
  const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
90
78
  if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
91
79
  if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
92
- const text = await readCapped(request, max)
93
- if (text === undefined) return failure(413, "invalid", `Request body over ${max} bytes`)
80
+ const read = await readCapped(request, max)
81
+ if (!("text" in read)) return failure(read.status, "invalid", read.message, read.close ? { connection: "close" } : {})
82
+ const text = read.text
94
83
  let message: unknown
95
84
  try {
96
85
  message = JSON.parse(text)
@@ -0,0 +1,333 @@
1
+ // Profile application (HWA-15; contract: Profiles in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md and
2
+ // packages/protocol/src/profiles.ts). Plugs into ProfileStore's `ack` seam and applies a session's pinned version
3
+ // through the session's spoke:
4
+ //
5
+ // - Only the spoke that owns the session (registry.owner) and belongs to the pin's tenant is ever sent the op, and
6
+ // only when it announced the spoke feature `profileApply` (never implied for older spokes). Otherwise the pin is
7
+ // recorded `unsupported` (naming the harness) or `pending` with `offline` (no spoke reports the session now).
8
+ // - The PUT answers at once with `pending`; the spoke's ProfileApplyResult is recorded when it arrives: applied,
9
+ // partial, rejected, restart_required or unsupported. A result that names another session, profile, version or
10
+ // digest, or is malformed, is recorded as `rejected` ("invalid acknowledgment"), never as applied. A result for a
11
+ // pin that changed meanwhile is dropped (recordAck checks the revision). applied/partial become the session's
12
+ // `effective` version; restart_required does not (it becomes effective when the restarted session's spoke is
13
+ // sent the pin again).
14
+ // - Per session, sends are serialized: a newer pin waits for the one in flight, and pins superseded while waiting
15
+ // are skipped (only the latest is sent). Sends overall are bounded (`concurrency`).
16
+ // - Reconnect: when a sessions frame shows a session's owner on a connection the pin was not last sent through, the
17
+ // pin is sent again (adapters treat a repeated digest as idempotent), except a `rejected` one (the harness refused
18
+ // that digest; the user re-pins) and an `unsupported` one the new connection still cannot serve. The last-sent
19
+ // map is in memory and bounded (`trackedSessions`); losing it (hub restart, another pod) only costs an idempotent
20
+ // re-send. Each pod handles the spokes connected to it; requests to another pod's spoke go through SpokeHub's pod
21
+ // RPC with the pin's tenant.
22
+ // - Errors: the spoke's own error is `rejected` with its reason (bounded, control characters removed); a timeout
23
+ // or unreachable spoke stays `pending` (re-sent on the spoke's next sessions frame or reconnect); a disconnect is
24
+ // `pending` + `offline`.
25
+ // - Events: `profile.ack` (family manage) carries IDs, version, revision and status only, never notes.
26
+ // - Launch: applyAtLaunch pins the launch's explicit version and waits (bounded) for the acknowledgment so the
27
+ // launch's prompt runs with it; every outcome becomes a launch note, never a failed launch.
28
+ // - Authority: pinning confers nothing. An app token pinning a profile also needs a usable prompt grant on the
29
+ // session (pinAuthorizer), which is spent like a prompt; the acting switch pauses it.
30
+ // - Secrets: the op carries `{{secret:NAME}}` references only. Central never resolves them; the spoke/adapter
31
+ // resolves them on its machine when a command is rendered, and never reports values back (notes name secrets at
32
+ // most).
33
+ import {
34
+ type AgentProfileVersion,
35
+ type LaunchAgentProfile,
36
+ PROFILE_APPLY_FEATURE,
37
+ PROFILE_APPLY_LIMITS,
38
+ type ProfileAck,
39
+ type ProfileApplyRequest,
40
+ type ProfileApplyResult,
41
+ profileApplyResultProblem,
42
+ type ProfileAssignment,
43
+ type ServerRequest,
44
+ type SpokeFeature,
45
+ } from "../../protocol"
46
+ import type { ActingSwitch } from "../agent/acting-switch"
47
+ import type { AgentActor } from "../agent/actor"
48
+ import { AgentError } from "../agent/errors"
49
+ import type { AgentEvents } from "../agent/events"
50
+ import type { Grants } from "../agent/grants"
51
+ import type { Clock } from "../backend/types"
52
+ import { RequestTimeoutError, RequestTooLargeError, SpokeError } from "../spoke-ws"
53
+ import { profileAckEvent } from "./events"
54
+ import type { ProfileStore } from "./store"
55
+
56
+ /** What the service reads of the registry (Registry implements it). */
57
+ export interface ApplyRegistry {
58
+ owner(sessionID: string): string | undefined
59
+ supports(connID: string, feature: SpokeFeature): boolean
60
+ harnessOf(connID: string): string | undefined
61
+ sessionTenant(sessionID: string): string | undefined
62
+ reportedSessions(connID: string): string[]
63
+ session(sessionID: string): { readonly directory: string } | undefined
64
+ onSessions(listener: (connID: string) => void): () => void
65
+ }
66
+
67
+ /** Hub → spoke requests (SpokeHub.request). */
68
+ export interface ApplySpokes {
69
+ request(connID: string, req: ServerRequest, options?: { readonly tenant?: string }): Promise<unknown>
70
+ }
71
+
72
+ export interface ProfileApplyLimits {
73
+ /** Spoke requests in flight at once (per hub process). */
74
+ readonly concurrency: number
75
+ /** Sessions whose last-sent connection is remembered for reconnects. */
76
+ readonly trackedSessions: number
77
+ /** How long a launch waits for its profile's acknowledgment before prompting anyway. */
78
+ readonly launchWaitMs: number
79
+ }
80
+
81
+ export const PROFILE_APPLY_SERVICE_LIMITS: ProfileApplyLimits = { concurrency: 4, trackedSessions: 10_000, launchWaitMs: 30_000 }
82
+
83
+ export interface ProfileApplierDeps {
84
+ readonly profiles: Pick<ProfileStore, "assignment" | "version" | "recordAck" | "assign">
85
+ readonly registry: ApplyRegistry
86
+ readonly spokes: ApplySpokes
87
+ readonly events?: Pick<AgentEvents, "append">
88
+ readonly now?: Clock
89
+ readonly limits?: Partial<ProfileApplyLimits>
90
+ }
91
+
92
+ type Target = { readonly kind: "offline" } | { readonly kind: "unsupported"; readonly connID: string; readonly harness: string } | { readonly kind: "online"; readonly connID: string }
93
+
94
+ interface SendOptions {
95
+ /** Send only while the pin still has this revision (a pin's own send); absent: the current pin (reconnect). */
96
+ readonly revision?: number
97
+ /** Sent because the session showed up on a new connection. */
98
+ readonly reconnect?: boolean
99
+ }
100
+
101
+ const OFFLINE_NOTE = "no spoke reports this session now; the hub applies the pin when one does"
102
+ const TIMEOUT_NOTE = "the spoke did not answer in time; the hub sends the pin again with the spoke's next sessions update or when it reconnects"
103
+
104
+ /** Plain text of at most noteChars characters (control and format characters become spaces). */
105
+ export function noteText(text: string): string {
106
+ const clean = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
107
+ return clean.length <= PROFILE_APPLY_LIMITS.noteChars ? clean : `${clean.slice(0, PROFILE_APPLY_LIMITS.noteChars - 1)}…`
108
+ }
109
+
110
+ const key = (userID: string, sessionID: string) => `${userID}\u0000${sessionID}`
111
+
112
+ export class ProfileApplier {
113
+ private readonly deps: ProfileApplierDeps
114
+ private readonly now: Clock
115
+ private readonly limits: ProfileApplyLimits
116
+ /** Per session: the tail of its serialized sends. */
117
+ private readonly chains = new Map<string, Promise<void>>()
118
+ private readonly inflight = new Set<Promise<void>>()
119
+ /** Per session: the connection its pin was last sent through (or checked against); insertion order = age. */
120
+ private readonly sentVia = new Map<string, string>()
121
+ private active = 0
122
+ private readonly waiting: Array<() => void> = []
123
+ private unsubscribe: (() => void) | undefined
124
+
125
+ constructor(deps: ProfileApplierDeps) {
126
+ this.deps = deps
127
+ this.now = deps.now ?? Date.now
128
+ this.limits = { ...PROFILE_APPLY_SERVICE_LIMITS, ...deps.limits }
129
+ }
130
+
131
+ /** ProfileStore's `ack` option: the immediate status of a new pin; the spoke's answer is recorded later. */
132
+ readonly ackFor = async (userID: string, assignment: ProfileAssignment, version: AgentProfileVersion): Promise<ProfileAck> => {
133
+ const at = this.now()
134
+ const target = this.target(userID, assignment.sessionID)
135
+ if (target.kind === "offline") return offlineAck(version.digest, at)
136
+ if (target.kind === "unsupported") {
137
+ this.remember(key(userID, assignment.sessionID), target.connID)
138
+ return unsupportedAck(version.digest, target.harness, at)
139
+ }
140
+ void this.schedule(userID, assignment.sessionID, { revision: assignment.revision })
141
+ return { status: "pending", digest: version.digest, at }
142
+ }
143
+
144
+ /** Starts re-applying pins when sessions show up on a new connection. */
145
+ start(): void {
146
+ this.unsubscribe ??= this.deps.registry.onSessions((connID) => this.sessionsFrom(connID))
147
+ }
148
+
149
+ stop(): void {
150
+ this.unsubscribe?.()
151
+ this.unsubscribe = undefined
152
+ }
153
+
154
+ /** Resolves when every send started so far has finished (tests, shutdown). */
155
+ async idle(): Promise<void> {
156
+ while (this.inflight.size) await Promise.allSettled([...this.inflight])
157
+ }
158
+
159
+ /** Sessions whose last-sent connection is remembered (bounded by trackedSessions). */
160
+ tracked(): number {
161
+ return this.sentVia.size
162
+ }
163
+
164
+ /**
165
+ * Pins the launch's explicit profile version to its new session and waits (at most launchWaitMs) for the
166
+ * acknowledgment. Returns notes for the launch reason; never throws.
167
+ */
168
+ async applyAtLaunch(userID: string, sessionID: string, ref: LaunchAgentProfile): Promise<string[]> {
169
+ const label = `profile ${ref.profileID} v${ref.version}`
170
+ try {
171
+ const current = await this.deps.profiles.assignment(userID, sessionID)
172
+ const pinned = await this.deps.profiles.assign(userID, sessionID, { profileID: ref.profileID, version: ref.version, expectedRevision: current?.revision ?? 0 })
173
+ const chain = this.chains.get(key(userID, sessionID))
174
+ if (chain) await withTimeout(chain, this.limits.launchWaitMs)
175
+ const after = await this.deps.profiles.assignment(userID, sessionID)
176
+ const ack = after?.revision === pinned.revision ? after.ack : pinned.ack
177
+ return [`${label} ${ack?.status ?? "pending"}${ack?.offline ? " (offline)" : ""}`]
178
+ } catch (cause) {
179
+ return [`${label} not applied: ${noteText(cause instanceof Error ? cause.message : String(cause))}`]
180
+ }
181
+ }
182
+
183
+ // ---------- internals ----------
184
+
185
+ private target(userID: string, sessionID: string): Target {
186
+ const { registry } = this.deps
187
+ const connID = registry.owner(sessionID)
188
+ if (!connID || registry.sessionTenant(sessionID) !== userID) return { kind: "offline" }
189
+ if (!registry.supports(connID, PROFILE_APPLY_FEATURE)) return { kind: "unsupported", connID, harness: registry.harnessOf(connID) ?? "unknown" }
190
+ return { kind: "online", connID }
191
+ }
192
+
193
+ /** A sessions frame: re-applies pins of sessions this connection now owns and was not last sent through. */
194
+ private sessionsFrom(connID: string): void {
195
+ const { registry } = this.deps
196
+ for (const sessionID of registry.reportedSessions(connID)) {
197
+ if (registry.owner(sessionID) !== connID) continue
198
+ const userID = registry.sessionTenant(sessionID)
199
+ if (!userID) continue
200
+ const k = key(userID, sessionID)
201
+ if (this.sentVia.get(k) === connID) continue
202
+ this.remember(k, connID)
203
+ void this.schedule(userID, sessionID, { reconnect: true })
204
+ }
205
+ }
206
+
207
+ private remember(k: string, connID: string): void {
208
+ this.sentVia.delete(k)
209
+ this.sentVia.set(k, connID)
210
+ while (this.sentVia.size > this.limits.trackedSessions) this.sentVia.delete(this.sentVia.keys().next().value!)
211
+ }
212
+
213
+ /** Queues a send behind the session's previous one. */
214
+ private schedule(userID: string, sessionID: string, options: SendOptions): Promise<void> {
215
+ const k = key(userID, sessionID)
216
+ const previous = this.chains.get(k) ?? Promise.resolve()
217
+ const next = previous
218
+ .then(() => this.limited(() => this.send(userID, sessionID, options)))
219
+ .catch((cause) => console.error(`[hub] profile apply for ${sessionID} failed: ${cause instanceof Error ? cause.message : String(cause)}`))
220
+ this.chains.set(k, next)
221
+ this.inflight.add(next)
222
+ void next.finally(() => {
223
+ this.inflight.delete(next)
224
+ if (this.chains.get(k) === next) this.chains.delete(k)
225
+ })
226
+ return next
227
+ }
228
+
229
+ private async limited<T>(run: () => Promise<T>): Promise<T> {
230
+ if (this.active >= this.limits.concurrency) await new Promise<void>((resolve) => this.waiting.push(resolve))
231
+ this.active++
232
+ try {
233
+ return await run()
234
+ } finally {
235
+ this.active--
236
+ this.waiting.shift()?.()
237
+ }
238
+ }
239
+
240
+ private async send(userID: string, sessionID: string, options: SendOptions): Promise<void> {
241
+ const { profiles, spokes } = this.deps
242
+ const assignment = await profiles.assignment(userID, sessionID)
243
+ if (!assignment || (options.revision !== undefined && assignment.revision !== options.revision)) return
244
+ if (options.reconnect && assignment.ack?.status === "rejected") return
245
+ const version = await profiles.version(userID, assignment.profileID, assignment.version)
246
+ if (!version) return
247
+ const k = key(userID, sessionID)
248
+ const target = this.target(userID, sessionID)
249
+ if (target.kind === "offline") {
250
+ this.sentVia.delete(k)
251
+ return this.record(userID, assignment, offlineAck(version.digest, this.now()))
252
+ }
253
+ this.remember(k, target.connID)
254
+ if (target.kind === "unsupported") {
255
+ if (options.reconnect && assignment.ack?.status === "unsupported") return
256
+ return this.record(userID, assignment, unsupportedAck(version.digest, target.harness, this.now()))
257
+ }
258
+ const req: ProfileApplyRequest = { op: PROFILE_APPLY_FEATURE, sessionID, profileID: assignment.profileID, version: assignment.version, digest: version.digest, content: version.content }
259
+ let result: unknown
260
+ try {
261
+ result = await spokes.request(target.connID, req, { tenant: userID })
262
+ } catch (cause) {
263
+ const ack = this.failure(cause, version.digest)
264
+ if (ack.status === "pending") this.sentVia.delete(k)
265
+ return this.record(userID, assignment, ack)
266
+ }
267
+ const problem = profileApplyResultProblem(result, req)
268
+ if (problem) return this.record(userID, assignment, { status: "rejected", digest: version.digest, notes: [noteText(`invalid acknowledgment from the spoke: ${problem}`)], at: this.now() })
269
+ const answer = result as ProfileApplyResult
270
+ await this.record(userID, assignment, { status: answer.status, digest: version.digest, ...(answer.notes?.length ? { notes: [...answer.notes] } : {}), at: this.now() })
271
+ }
272
+
273
+ private failure(cause: unknown, digest: string): ProfileAck {
274
+ const at = this.now()
275
+ if (cause instanceof RequestTimeoutError) return { status: "pending", digest, notes: [TIMEOUT_NOTE], at }
276
+ if (cause instanceof RequestTooLargeError) return { status: "rejected", digest, notes: ["the profile version is too large to send in one frame"], at }
277
+ if (cause instanceof SpokeError) {
278
+ if (/disconnected/i.test(cause.message)) return offlineAck(digest, at)
279
+ return { status: "rejected", digest, notes: [noteText(`the spoke refused: ${cause.message}`)], at }
280
+ }
281
+ return { status: "pending", digest, notes: [noteText(`the spoke could not be reached (${cause instanceof Error ? cause.name : "error"}); the hub sends the pin again when it reconnects`)], at }
282
+ }
283
+
284
+ private async record(userID: string, assignment: ProfileAssignment, ack: ProfileAck): Promise<void> {
285
+ const written = await this.deps.profiles.recordAck(userID, assignment.sessionID, assignment.revision, ack)
286
+ if (!written || written.ack?.status !== ack.status || written.ack.at !== ack.at) return
287
+ await this.deps.events
288
+ ?.append(userID, profileAckEvent(assignment.sessionID, assignment.profileID, assignment.version, assignment.revision, ack.status))
289
+ .catch((cause) => console.error(`[hub] profile.ack event failed: ${(cause as Error).message}`))
290
+ }
291
+ }
292
+
293
+ const offlineAck = (digest: string, at: number): ProfileAck => ({ status: "pending", digest, notes: [OFFLINE_NOTE], at, offline: true })
294
+ const unsupportedAck = (digest: string, harness: string, at: number): ProfileAck => ({
295
+ status: "unsupported",
296
+ digest,
297
+ notes: [noteText(`the ${harness} spoke does not announce ${PROFILE_APPLY_FEATURE}; update it or its harness adapter`)],
298
+ at,
299
+ })
300
+
301
+ async function withTimeout(promise: Promise<unknown>, ms: number): Promise<void> {
302
+ let timer: ReturnType<typeof setTimeout> | undefined
303
+ await Promise.race([promise, new Promise<void>((resolve) => (timer = setTimeout(resolve, ms)))])
304
+ clearTimeout(timer)
305
+ }
306
+
307
+ // ---------- authority ----------
308
+
309
+ export interface PinAuthorizerDeps {
310
+ readonly registry: Pick<ApplyRegistry, "session" | "sessionTenant">
311
+ readonly grants: Pick<Grants, "usable" | "consume">
312
+ readonly acting?: Pick<ActingSwitch, "ensureActing">
313
+ }
314
+
315
+ /** Checks a pin's caller beyond its scopes: (actor, userID, sessionID) → throws the agent error. */
316
+ export type PinAuthorizer = (actor: AgentActor, userID: string, sessionID: string) => Promise<void>
317
+
318
+ /**
319
+ * An app token that pins a profile (which changes what a running session is told) also needs a usable prompt grant
320
+ * on the session, and spends one action of it. Users need nothing more; sessions never pin (routes refuse them).
321
+ */
322
+ export function pinAuthorizer(deps: PinAuthorizerDeps): PinAuthorizer {
323
+ return async (actor, userID, sessionID) => {
324
+ if (actor.kind === "user") return
325
+ if (actor.kind !== "app") throw new AgentError("forbidden", "Only users and app tokens pin profiles")
326
+ deps.acting?.ensureActing(userID)
327
+ const live = deps.registry.sessionTenant(sessionID) === userID ? deps.registry.session(sessionID) : undefined
328
+ if (!live) throw new AgentError("offline", "No spoke reports this session now, so the prompt grant a pin needs cannot be checked")
329
+ const usable = await deps.grants.usable(actor, "prompt", { directory: live.directory })
330
+ if (!usable.length) throw new AgentError("forbidden", "Pinning a profile as an app also needs a prompt grant on the session")
331
+ await deps.grants.consume(usable[0]!)
332
+ }
333
+ }
@@ -0,0 +1,174 @@
1
+ // Profile content rules of the registry (HWA-14), on top of the frozen protocol check (profileContentProblem):
2
+ //
3
+ // - Command templates: literal text with `{{name}}` parameters (lowercase, digits, `_`; `{{arguments}}` is the
4
+ // whole argument string by convention) and `{{secret:NAME}}` secret references. Nothing else: no expressions,
5
+ // conditionals, loops, filters or includes. `\{{` writes a literal `{{`. Rendering substitutes each value
6
+ // literally, once; a value is never parsed again, so it cannot inject placeholders.
7
+ // - Secrets: profiles carry secret REFERENCES only (`{{secret:NAME}}`), which central never resolves; the harness
8
+ // side resolves them, or leaves them, as it reports. A literal credential anywhere (instructions, skills,
9
+ // commands) is refused, and the refusal names where and what kind, never the value.
10
+ // - No setup hooks or dependency installation: such fields are `unsupported` (refused, never ignored); other
11
+ // unknown fields are `invalid`. Content is normalized to the known fields.
12
+ // - The digest is the sha256 hex of the canonical JSON (keys sorted at every level) of the normalized content.
13
+ import { createHash } from "node:crypto"
14
+ import { canonicalProfileJSON, isText, PROFILE_LIMITS, profileContentProblem, profileTemplateParts, type ProfileCommand, type ProfileSkill, type ProfileTemplatePart, type ProfileVersionContent } from "../../protocol"
15
+
16
+ export const TEMPLATE_LIMITS = {
17
+ /** Distinct parameters per template. */
18
+ params: 16,
19
+ /** Characters of one parameter value. */
20
+ valueChars: 4_000,
21
+ /** Characters of a rendered command. */
22
+ renderedChars: 16_000,
23
+ } as const
24
+
25
+ /** A refused profile body: `invalid` (400) or `unsupported` (422, feature `setupHooks`). */
26
+ export class ProfileContentError extends Error {
27
+ constructor(
28
+ readonly code: "invalid" | "unsupported",
29
+ message: string,
30
+ ) {
31
+ super(message)
32
+ this.name = "ProfileContentError"
33
+ }
34
+ }
35
+
36
+ const PARAM = /^[a-z][a-z0-9_]{0,39}$/
37
+
38
+ export type TemplateParse = { readonly ok: true; readonly params: string[]; readonly secrets: string[] } | { readonly ok: false; readonly problem: string }
39
+
40
+ type Piece = ProfileTemplatePart
41
+
42
+ /** Splits a template into literal text, parameters and secret references (the shared protocol tokenizer). */
43
+ const pieces = (template: string): Piece[] | string => profileTemplateParts(template)
44
+
45
+ /** The parameters and secret references of a template, or why it is refused. */
46
+ export function parseTemplate(template: string): TemplateParse {
47
+ const parsed = pieces(template)
48
+ if (typeof parsed === "string") return { ok: false, problem: parsed }
49
+ const params: string[] = []
50
+ const secrets: string[] = []
51
+ for (const piece of parsed) {
52
+ if ("param" in piece && !params.includes(piece.param)) params.push(piece.param)
53
+ if ("secret" in piece && !secrets.includes(piece.secret)) secrets.push(piece.secret)
54
+ }
55
+ if (params.length > TEMPLATE_LIMITS.params) return { ok: false, problem: `at most ${TEMPLATE_LIMITS.params} parameters` }
56
+ return { ok: true, params, secrets }
57
+ }
58
+
59
+ /**
60
+ * Renders a template with exactly its parameters. Values are text up to TEMPLATE_LIMITS.valueChars; secret
61
+ * references stay as written (central never resolves them); the result is bounded by renderedChars.
62
+ */
63
+ export function renderTemplate(template: string, values: Readonly<Record<string, unknown>>): string {
64
+ const parsed = pieces(template)
65
+ if (typeof parsed === "string") throw new ProfileContentError("invalid", `template: ${parsed}`)
66
+ const { params } = parseTemplate(template) as { params: string[] }
67
+ const missing = params.filter((name) => !Object.hasOwn(values, name))
68
+ if (missing.length) throw new ProfileContentError("invalid", `missing parameter(s) ${missing.join(", ")}`)
69
+ const unknown = Object.keys(values).filter((name) => !params.includes(name))
70
+ if (unknown.length) throw new ProfileContentError("invalid", `unknown parameter(s) ${unknown.map((n) => (PARAM.test(n) ? n : "?")).join(", ")}`)
71
+ for (const name of params) {
72
+ const value = values[name]
73
+ if (typeof value !== "string" || !isText(value, TEMPLATE_LIMITS.valueChars)) throw new ProfileContentError("invalid", `parameter ${name} must be text of at most ${TEMPLATE_LIMITS.valueChars} characters`)
74
+ }
75
+ let out = ""
76
+ for (const piece of parsed) {
77
+ out += "text" in piece ? piece.text : "param" in piece ? (values[piece.param] as string) : piece.raw
78
+ if (out.length > TEMPLATE_LIMITS.renderedChars) throw new ProfileContentError("invalid", `rendered command is too long (more than ${TEMPLATE_LIMITS.renderedChars} characters)`)
79
+ }
80
+ return out
81
+ }
82
+
83
+ /** Distinctive credential formats; prose about secrets ("set API_KEY=…") is not matched. */
84
+ const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
85
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
86
+ ["hub app token", /\bhat_[0-9a-f]{16}_[A-Za-z0-9_-]{43,}/],
87
+ ["github token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
88
+ ["github token", /\bgithub_pat_[A-Za-z0-9_]{22,}/],
89
+ ["api key", /\bsk-[A-Za-z0-9_-]{20,}/],
90
+ ["api key", /\bAIza[0-9A-Za-z_-]{35}/],
91
+ ["aws access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
92
+ ["slack token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
93
+ ["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
94
+ ["bearer token", /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}/],
95
+ ]
96
+
97
+ /** The kind of literal credential in a text, or undefined. */
98
+ export function credentialFinding(text: string): string | undefined {
99
+ for (const [kind, pattern] of CREDENTIALS) if (pattern.test(text)) return kind
100
+ return undefined
101
+ }
102
+
103
+ /** Fields that would run code or install things on a machine: refused as unsupported. */
104
+ const HOOK_FIELDS = new Set(["hooks", "hook", "setup", "install", "postinstall", "preinstall", "dependencies", "devdependencies", "requirements", "scripts"])
105
+ const CONTENT_FIELDS = new Set(["instructions", "model", "skills", "commands"])
106
+ const SKILL_FIELDS = new Set(["name", "description", "body"])
107
+ const COMMAND_FIELDS = new Set(["name", "description", "template"])
108
+
109
+ const fieldLabel = (key: string) => (/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)")
110
+
111
+ function checkFields(record: Record<string, unknown>, allowed: ReadonlySet<string>, where: string): void {
112
+ for (const key of Object.keys(record)) {
113
+ if (allowed.has(key)) continue
114
+ if (HOOK_FIELDS.has(key.toLowerCase())) {
115
+ throw new ProfileContentError("unsupported", `${where}${fieldLabel(key)}: profiles carry no setup hooks, scripts or dependency installation`)
116
+ }
117
+ throw new ProfileContentError("invalid", `${where}unknown field ${fieldLabel(key)}`)
118
+ }
119
+ }
120
+
121
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
122
+
123
+ /** Validates a profile version body and returns it normalized to the known fields; throws ProfileContentError. */
124
+ export function checkContent(v: unknown): ProfileVersionContent {
125
+ if (!isRecord(v)) throw new ProfileContentError("invalid", "content must be an object")
126
+ checkFields(v, CONTENT_FIELDS, "")
127
+ if (Array.isArray(v.skills)) for (const item of v.skills) if (isRecord(item)) checkFields(item, SKILL_FIELDS, "skills: ")
128
+ if (Array.isArray(v.commands)) for (const item of v.commands) if (isRecord(item)) checkFields(item, COMMAND_FIELDS, "commands: ")
129
+ const problem = profileContentProblem(v)
130
+ if (problem) throw new ProfileContentError("invalid", problem)
131
+ const skills = (v.skills as ProfileSkill[]).map(({ name, description, body }) => ({ name, description, body }))
132
+ const commands = (v.commands as ProfileCommand[]).map(({ name, description, template }) => ({ name, description, template }))
133
+ const content: ProfileVersionContent = {
134
+ ...(v.instructions !== undefined ? { instructions: v.instructions as string } : {}),
135
+ ...(v.model !== undefined ? { model: v.model as string } : {}),
136
+ skills,
137
+ commands,
138
+ }
139
+ for (const command of commands) {
140
+ const parsed = parseTemplate(command.template)
141
+ if (!parsed.ok) throw new ProfileContentError("invalid", `commands.${command.name}.template: ${parsed.problem}`)
142
+ }
143
+ const texts: [string, string][] = [
144
+ ...(content.instructions !== undefined ? [["instructions", content.instructions] as [string, string]] : []),
145
+ ...skills.flatMap((s): [string, string][] => [[`skills.${s.name}.description`, s.description], [`skills.${s.name}.body`, s.body]]),
146
+ ...commands.flatMap((c): [string, string][] => [[`commands.${c.name}.description`, c.description], [`commands.${c.name}.template`, c.template]]),
147
+ ]
148
+ for (const [where, text] of texts) {
149
+ const kind = credentialFinding(text)
150
+ if (kind) throw new ProfileContentError("invalid", `${where} contains what looks like a ${kind}; keep credentials out of profiles and use a secret reference {{secret:NAME}}`)
151
+ }
152
+ return content
153
+ }
154
+
155
+ /** JSON with object keys sorted at every level (arrays keep their order); shared with spokes through the protocol. */
156
+ export const canonicalJSON = canonicalProfileJSON
157
+
158
+ /** sha256 hex of the canonical JSON of a version's content (what adapters echo in their acknowledgment). */
159
+ export const contentDigest = (content: unknown): string => createHash("sha256").update(canonicalJSON(content)).digest("hex")
160
+
161
+ /** Serialized size of a version's content in bytes (the storage quota counts it). */
162
+ export const contentBytes = (content: ProfileVersionContent): number => new TextEncoder().encode(JSON.stringify(content)).length
163
+
164
+ /** Secret names a version references (all command templates); for views, never values. */
165
+ export function secretReferences(content: ProfileVersionContent): string[] {
166
+ const out = new Set<string>()
167
+ for (const command of content.commands) {
168
+ const parsed = parseTemplate(command.template)
169
+ if (parsed.ok) for (const name of parsed.secrets) out.add(name)
170
+ }
171
+ return [...out]
172
+ }
173
+
174
+ export { PROFILE_LIMITS }
@@ -0,0 +1,40 @@
1
+ // Compact journal events of the profile registry (contract: PROFILE_EVENT_TYPES, family `manage`). Allowlisted
2
+ // payloads only: IDs, revisions, version numbers and flags; never names, titles, descriptions, instructions,
3
+ // skills, command templates or parameters.
4
+ import type { EventInput } from "../agent/events"
5
+
6
+ export const profileCreatedEvent = (profileID: string, revision: number): EventInput => ({
7
+ type: "profile.created",
8
+ family: "manage",
9
+ subject: { profileID },
10
+ data: { version: 1, revision },
11
+ })
12
+
13
+ export const profileVersionEvent = (profileID: string, version: number, revision: number): EventInput => ({
14
+ type: "profile.version.created",
15
+ family: "manage",
16
+ subject: { profileID },
17
+ data: { version, revision },
18
+ })
19
+
20
+ export const profileArchivedEvent = (profileID: string, archived: boolean, revision: number): EventInput => ({
21
+ type: "profile.archived",
22
+ family: "manage",
23
+ subject: { profileID },
24
+ data: { archived, revision },
25
+ })
26
+
27
+ export const profileAssignedEvent = (sessionID: string, profileID: string, version: number, revision: number): EventInput => ({
28
+ type: "profile.assigned",
29
+ family: "manage",
30
+ subject: { sessionID, profileID },
31
+ data: { version, revision },
32
+ })
33
+
34
+ /** A spoke's (or the hub's) application status of a pin (HWA-15): never its notes, which may quote the harness. */
35
+ export const profileAckEvent = (sessionID: string, profileID: string, version: number, revision: number, status: string): EventInput => ({
36
+ type: "profile.ack",
37
+ family: "manage",
38
+ subject: { sessionID, profileID },
39
+ data: { version, revision, status },
40
+ })
@@ -0,0 +1,14 @@
1
+ // Idempotent profile creates: the shared route-level helper (../agent/idempotency.ts, extracted by HWA-06) over
2
+ // the StateStore collection "profile-idempotency", so records written before the extraction keep replaying.
3
+ import type { Clock, StateStore } from "../backend/types"
4
+ import { RouteIdempotency } from "../agent/idempotency"
5
+
6
+ export { IDEMPOTENCY_LIMITS, type Answer } from "../agent/idempotency"
7
+
8
+ export const PROFILE_IDEMPOTENCY = "profile-idempotency"
9
+
10
+ export class Idempotency extends RouteIdempotency {
11
+ constructor(options: { readonly state: StateStore; readonly now?: Clock }) {
12
+ super({ ...options, collection: PROFILE_IDEMPOTENCY })
13
+ }
14
+ }