@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
@@ -8,7 +8,7 @@
8
8
  // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
9
  import type { AgentEvents } from "./events"
10
10
  import type { CursorKeys } from "./event-cursor"
11
- import { eventsRoutes } from "./events-routes"
11
+ import { eventsRoutes, type EventsRouteDeps } from "./events-routes"
12
12
  import type { DeviceRecord, Store, UserRecord } from "../store"
13
13
  import type { Directory } from "./directory"
14
14
  import { directoryRoutes } from "./directory-routes"
@@ -30,6 +30,36 @@ import { actingRoutes } from "./acting-routes"
30
30
  import type { Actions } from "../actions"
31
31
  import { launchRoutes, type RouteLaunches } from "./launch-routes"
32
32
  import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
33
+ import { pinAuthorizer } from "../profiles/apply"
34
+ import { profileRoutes } from "../profiles/routes"
35
+ import { ProfileUsage } from "../profiles/usage"
36
+ import { usagePorts } from "../profiles/usage-ports"
37
+ import type { ProfileStore } from "../profiles/store"
38
+ import { workspaceRoutes, type WorkspaceRoutesDeps } from "../workspaces/routes"
39
+ import { boardRoutes, registryBoardSessions } from "../workspaces/board"
40
+ import { type CheckpointStore, checkpointRoutes } from "../workspaces/checkpoints"
41
+ import { masterRoutes } from "../workspaces/master"
42
+ import { routePorts, sideChatRoutes } from "../workspaces/sidechats-routes"
43
+ import { type ArtifactRouteDeps, artifactRoutes } from "../artifacts/routes"
44
+ import { artifactJobRoutes, jobPorts } from "../artifacts/jobs-routes"
45
+ import { type ArtifactHostRouteDeps, artifactHostRoutes } from "../artifacts/host"
46
+ import { type GraphRouteDeps, graphRoutes } from "./interaction-graph-routes"
47
+ import type { TeamIntakeService } from "../teams/intake"
48
+ import { intakeRoutes } from "../teams/intake-routes"
49
+ import { eventVisibility, type TeamDiscovery } from "../teams/discovery"
50
+ import { teamRoutes, type TeamRoutesDeps } from "../teams/routes"
51
+ import type { StateStore } from "../backend/types"
52
+ import type { ActivityTracker } from "../concierge/activity"
53
+ import { boardRoutes as conciergeBoardRoutes } from "../concierge/board-routes"
54
+ import type { ResultTracker } from "../concierge/board-results"
55
+ import { type ConciergeIdentity, watchlistRoutes } from "../concierge/watchlist-routes"
56
+ import { conciergeRoutes } from "../concierge/routes"
57
+ import { guardConciergeRoutes } from "../concierge/guard"
58
+ import { isConciergeLaunch } from "../concierge/supervisor"
59
+ import type { ConciergeService } from "../concierge/service"
60
+ import type { SessionIntent } from "../../protocol"
61
+ import type { SessionIntents } from "./intents"
62
+ import { intentRoutes } from "./intent-routes"
33
63
 
34
64
  export const AGENT_PREFIX = "/api/agent"
35
65
 
@@ -90,6 +120,22 @@ export interface AgentDeps {
90
120
  readonly launches?: RouteLaunches
91
121
  /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
92
122
  readonly webhooks?: RouteWebhooks
123
+ /** Profiles (HWA-14): the /api/agent/profiles and /sessions/:addr/profile routes when given with the directory. */
124
+ readonly profiles?: ProfileStore
125
+ /** Workspaces (HWA-06): the /api/agent/workspaces routes when given. */
126
+ readonly workspaces?: Pick<WorkspaceRoutesDeps, "workspaces" | "members" | "supervision" | "lifecycle">
127
+ /** Supervisor checkpoints (HWA-11): POST/GET /api/agent/workspaces/:id/checkpoints when given. */
128
+ readonly workspaceCheckpoints?: CheckpointStore
129
+ /** Team intake (HWA-36): /api/agent/teams/:id/intake when given. */
130
+ readonly teamIntake?: TeamIntakeService
131
+ /** Teams (HWA-37): /api/agent/teams definitions, staffing and status when given (the hub's single instances). */
132
+ readonly teams?: Pick<TeamRoutesDeps, "teams" | "seats" | "staffing">
133
+ /** Persistent artifacts (HWA-21): /api/agent/artifacts; without a store the routes answer 422 unsupported. */
134
+ readonly artifacts?: ArtifactRouteDeps
135
+ /** Isolated artifact host (HWA-26): mint/revoke capability URLs for html/app revisions when given. */
136
+ readonly artifactHost?: ArtifactHostRouteDeps
137
+ /** Interaction graph (HWA-31) team overlay; the graph routes are added with the directory, registry and delivery. */
138
+ readonly graph?: Pick<GraphRouteDeps, "teams">
93
139
  /** The canonical agent event journal: archive changes are appended to it. */
94
140
  readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
95
141
  /** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
@@ -105,6 +151,32 @@ export interface AgentDeps {
105
151
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
106
152
  readonly draining?: AbortSignal
107
153
  readonly now?: () => number
154
+ /** Team-aware discovery (HWA-40): who and the read routes scope team members to their teams. */
155
+ readonly teamDiscovery?: TeamDiscovery
156
+ /**
157
+ * Concierge (CON-01): the /api/agent/concierge/watchlist routes when given with the directory and registry.
158
+ * `identity` is the user's current Concierge session (CON-04); without it no session token is admitted.
159
+ */
160
+ readonly concierge?: {
161
+ readonly state: StateStore
162
+ readonly activity: Pick<ActivityTracker, "list">
163
+ readonly identity?: ConciergeIdentity
164
+ readonly results?: Pick<ResultTracker, "many">
165
+ /** (CON-04) Sessions only the user acts on (the Concierge's): the act verbs refuse agents on them. */
166
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
167
+ /** (CON-04) The Concierge service: /api/agent/concierge settings, enable/disable/restart and notes. */
168
+ readonly service?: ConciergeService
169
+ /** (CON-06 and later) More Concierge routes (the tiers slot). */
170
+ readonly routes?: () => AgentRoute[]
171
+ /** (CON-06) The Decision armed now by the current Concierge (the board's `armed` flag). */
172
+ readonly armed?: (userID: string) => Promise<string | undefined>
173
+ /** (CON-10) The user's time zone (Concierge settings): the rendered board's time is local. */
174
+ readonly timeZone?: (userID: string) => Promise<string | undefined>
175
+ /** (CON-13) The board's inGrant (board-routes.ts BoardRouteDeps.coverage). */
176
+ readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
177
+ }
178
+ /** (CON-02) Session intents: GET/PUT /api/agent/sessions/:addr/intent with the directory; reads carry `intent`. */
179
+ readonly intents?: SessionIntents
108
180
  }
109
181
 
110
182
  /**
@@ -125,11 +197,12 @@ const userView = (user: UserRecord) => ({
125
197
  /** The built-in /api/agent routes. */
126
198
  export function agentRoutes(deps: AgentDeps): AgentRoute[] {
127
199
  const feed = deps.feed ?? new MessageFeed()
128
- return [
129
- ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
130
- ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
200
+ const routes: AgentRoute[] = [
201
+ ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
202
+ ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
203
+ ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
131
204
  ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
132
- ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
205
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
133
206
  {
134
207
  method: "GET",
135
208
  path: `${AGENT_PREFIX}/whoami`,
@@ -145,7 +218,83 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
145
218
  ? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
146
219
  : []),
147
220
  ...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
221
+ ...(deps.profiles && deps.directory
222
+ ? profileRoutes({
223
+ profiles: deps.profiles,
224
+ directory: deps.directory,
225
+ audit: deps.store,
226
+ ...(deps.agentEvents ? { events: deps.agentEvents } : {}),
227
+ // HWA-15: an app's pin also needs a usable prompt grant on the session.
228
+ ...(deps.grants && deps.registry ? { authorizePin: pinAuthorizer({ registry: deps.registry, grants: deps.grants, ...(deps.acting ? { acting: deps.acting } : {}) }) } : {}),
229
+ // RP-08: where a profile is used (pins, team roles, launches, workspaces).
230
+ usage: new ProfileUsage({
231
+ profiles: deps.profiles,
232
+ ports: usagePorts({
233
+ directory: deps.directory,
234
+ ...(deps.registry ? { registry: deps.registry } : {}),
235
+ ...(deps.teams ? { teams: { teams: deps.teams.teams, seats: deps.teams.seats } } : {}),
236
+ ...(deps.launches ? { launches: deps.launches } : {}),
237
+ ...(deps.workspaces ? { workspaces: { workspaces: deps.workspaces.workspaces, members: deps.workspaces.members } } : {}),
238
+ }),
239
+ }),
240
+ })
241
+ : []),
242
+ ...(deps.workspaces ? workspaceRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
243
+ ...(deps.workspaces ? boardRoutes({ ...deps.workspaces, ...(deps.directory && deps.registry ? { sessions: registryBoardSessions(deps.directory, deps.registry) } : {}), ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.artifacts?.artifacts ? { artifacts: deps.artifacts.artifacts } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
244
+ ...(deps.workspaceCheckpoints ? checkpointRoutes({ checkpoints: deps.workspaceCheckpoints, audit: deps.store }) : []),
245
+ ...(deps.workspaces ? masterRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.workspaceCheckpoints ? { checkpoints: deps.workspaceCheckpoints } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
246
+ ...artifactRoutes(deps.artifacts ?? {}),
247
+ ...(deps.artifactHost ? artifactHostRoutes(deps.artifactHost) : []),
248
+ ...(deps.directory && deps.registry && deps.delivery ? graphRoutes({ store: deps.store, registry: deps.registry, directory: deps.directory, messages: deps.delivery.messages, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.workspaces ? { workspaces: deps.workspaces } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...deps.graph, ...(deps.now ? { now: deps.now } : {}), ...conciergeReads(deps), ...conciergeCurrent(deps) }) : []),
249
+ ...(deps.teamIntake ? intakeRoutes({ intake: deps.teamIntake, audit: deps.store }) : []),
250
+ ...(deps.teams ? teamRoutes({ ...deps.teams, ...(deps.teamIntake ? { intake: deps.teamIntake } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
251
+ ...(deps.concierge && deps.directory && deps.registry
252
+ ? watchlistRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...launchReads(deps), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })
253
+ : []),
254
+ // CON-03: the situation board and the Concierge's Decision reads (users and the current Concierge only).
255
+ ...(deps.concierge && deps.directory && deps.registry
256
+ ? conciergeBoardRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.intents ? { intents: deps.intents } : {}), ...launchReads(deps), ...(deps.now ? { now: deps.now } : {}) })
257
+ : []),
258
+ // CON-04: settings, enable/disable/restart and notes; CON-06's routes through the tiers slot.
259
+ ...(deps.concierge?.service && deps.concierge.identity ? conciergeRoutes({ service: deps.concierge.service, identity: deps.concierge.identity }) : []),
260
+ ...(deps.concierge?.routes ? deps.concierge.routes() : []),
148
261
  ]
262
+ // HWA-13: side chats read, launch and message through the routes above, as the caller.
263
+ const { registry } = deps
264
+ const sourceUpdatedAt = registry ? (userID: string, id: string) => (registry.sessionTenant(id) === userID ? registry.session(id)?.updated : undefined) : undefined
265
+ // HWA-24: artifact AI jobs start their handler through the launch and message routes above, as the requester.
266
+ const a = deps.artifacts
267
+ routes.push(...artifactJobRoutes({ ...(a?.artifacts ? { artifacts: a.artifacts } : {}), ...(a?.state ? { state: a.state } : {}), ports: jobPorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }))
268
+ const all = deps.workspaces ? [...routes, ...sideChatRoutes({ workspaces: deps.workspaces.workspaces, ports: routePorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })] : routes
269
+ // CON-04: manage paths never touch a Concierge session (concierge/guard.ts).
270
+ const identity = deps.concierge?.identity
271
+ const directory = deps.directory
272
+ const launches = deps.launches
273
+ return identity && directory
274
+ ? guardConciergeRoutes(all, {
275
+ identity,
276
+ resolve: (userID, addr) => directory.resolve(userID, addr).sessions.map((s) => s.sessionID),
277
+ ...(launches
278
+ ? {
279
+ launch: async (userID: string, id: string) => {
280
+ const l = await launches.view(userID, id)
281
+ return l ? { ...(l.sessionID !== undefined ? { sessionID: l.sessionID } : {}), concierge: isConciergeLaunch(l) } : undefined
282
+ },
283
+ }
284
+ : {}),
285
+ })
286
+ : all
287
+ }
288
+
289
+ /** HWA-28/40: unfiltered events and watch read by a team-scoped session leave out what discovery hides from it. */
290
+ function eventScope(deps: AgentDeps): Pick<EventsRouteDeps, "scope" | "resolve"> {
291
+ const discovery = deps.teamDiscovery
292
+ return discovery
293
+ ? {
294
+ scope: async (userID, actor) => eventVisibility(await discovery.census(userID, actor)),
295
+ resolve: async (userID, actor, addr) => discovery.resolve(await discovery.census(userID, actor), addr).sessions.map((s) => s.sessionID),
296
+ }
297
+ : {}
149
298
  }
150
299
 
151
300
  /** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
@@ -165,6 +314,24 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
165
314
  }
166
315
  }
167
316
 
317
+ /** (CON-04) Which sessions reads mark `concierge: true` (the user's current and hidden Concierge sessions). */
318
+ function conciergeReads(deps: AgentDeps): { concierge?: (userID: string) => Promise<readonly string[]> } {
319
+ const identity = deps.concierge?.identity
320
+ return identity ? { concierge: async (userID: string) => (await identity.current(userID)).hiddenSessionIDs } : {}
321
+ }
322
+
323
+ /** (CON-09a) The user's current Concierge session (Watchtower labels it @@concierge). */
324
+ function conciergeCurrent(deps: AgentDeps): { conciergeCurrent?: (userID: string) => Promise<string | undefined> } {
325
+ const identity = deps.concierge?.identity
326
+ return identity ? { conciergeCurrent: async (userID: string) => (await identity.current(userID)).sessionID } : {}
327
+ }
328
+
329
+ /** (CON-02) What reads show of intents: the stored ones of the listed sessions, by session. */
330
+ function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs: readonly string[]) => Promise<Map<string, SessionIntent>> } {
331
+ const { intents } = deps
332
+ return intents ? { intents: (userID: string, sessionIDs: readonly string[]) => intents.many(userID, sessionIDs) } : {}
333
+ }
334
+
168
335
  /** The act verbs, when every part they need is given. */
169
336
  function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
170
337
  const { actions, acting, grants, audit, registry, directory, spokes } = deps
@@ -180,6 +347,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
180
347
  audit,
181
348
  ...(deps.escalate ? { escalate: deps.escalate } : {}),
182
349
  ...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
350
+ ...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
183
351
  }),
184
352
  )
185
353
  }
@@ -192,6 +360,8 @@ export interface AgentApiOptions {
192
360
  /** Records activity of a resolved caller (device and user lastSeen). */
193
361
  touch?(user: UserRecord, device?: DeviceRecord): void
194
362
  readonly routes: readonly AgentRoute[]
363
+ /** Runs for every resolved caller before the route class check (CON-04: the Concierge's route allow-list). */
364
+ gate?(actor: AgentActor, route: AgentRoute): Promise<void>
195
365
  }
196
366
 
197
367
  export interface AgentApi {
@@ -250,6 +420,7 @@ export function agentApi(options: AgentApiOptions): AgentApi {
250
420
  options.touch?.(resolved.user, resolved.device)
251
421
  const found = find(method, url.pathname)
252
422
  if (!found) throw new AgentError("not_found", "Not found")
423
+ if (options.gate) await options.gate(resolved.actor, found.route)
253
424
  if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
254
425
  const { actor, credential, user, device } = resolved
255
426
  return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
@@ -9,6 +9,7 @@
9
9
  // scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
10
10
  // the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
11
11
  // one the token did not make, is 404 not_found.
12
+ import { boundedJson } from "./body"
12
13
  import type { Store } from "../store"
13
14
  import type { AgentActor } from "./actor"
14
15
  import { AgentError } from "./errors"
@@ -37,7 +38,7 @@ const requireEventScopes = (actor: AgentActor, events: unknown) => {
37
38
  }
38
39
 
39
40
  async function bodyOf(request: Request): Promise<Record<string, unknown>> {
40
- const raw = JSON.parse(await request.text()) as unknown
41
+ const raw = (await boundedJson(request)) as unknown
41
42
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
42
43
  return raw as Record<string, unknown>
43
44
  }
@@ -38,6 +38,8 @@ export interface AgentSession {
38
38
  /** Project name (lowercased) used by `.project` scopes. */
39
39
  project?: string
40
40
  title?: string
41
+ /** The last harness kind a spoke reported it under (offline sessions keep their glyph, RF-02). */
42
+ harness?: string
41
43
  firstSeen?: number
42
44
  /** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
43
45
  lastSeen?: number
@@ -70,6 +72,8 @@ export interface AgentData {
70
72
  generations?: Record<string, number>
71
73
  /** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
72
74
  actingPaused?: true
75
+ /** (CON-09a) Sessions unnamed before the directory knew them: their record is never derived a name (≤ 500). */
76
+ unnamed?: string[]
73
77
  [field: string]: unknown
74
78
  }
75
79
 
@@ -0,0 +1,44 @@
1
+ // Who may do what with an artifact (HWA-20; contract: Cross-cutting rules, Artifacts). Checked on every access by
2
+ // the artifact store. Tenancy comes first: the store only ever reads the caller's own user's records, so another
3
+ // user's artifact is simply missing. Within a user:
4
+ // - The user (console, app token) owns every artifact: read, revise, manage.
5
+ // - A session reads an artifact only when its visibility is "workspace" and the session is a *current* member of
6
+ // that workspace (re-checked on every read, so leaving a workspace ends access). It never reads a private one,
7
+ // even one it created: createdBySessionID is provenance and grants nothing.
8
+ // - A session may create artifacts (they belong to its user). Membership grants no write: revising, metadata,
9
+ // visibility, archive and delete stay with the user unless an explicit grant-aware policy is passed in (the
10
+ // routes, HWA-21, may wire the existing grants through `authorize`).
11
+ // Role labels and workspace roles never confer authority here.
12
+ import type { Artifact } from "../../protocol"
13
+
14
+ export type ArtifactPrincipal = { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string }
15
+
16
+ /** "manage": metadata, visibility, archive/unarchive and delete. */
17
+ export type ArtifactAction = "read" | "create" | "revise" | "manage"
18
+
19
+ /** The workspace facts access needs (HWA-03/04 provide them; tests use fixtures). */
20
+ export interface ArtifactWorkspaces {
21
+ exists(userID: string, workspaceID: string): Promise<boolean>
22
+ isCurrentMember(userID: string, workspaceID: string, sessionID: string): Promise<boolean>
23
+ }
24
+
25
+ export interface AccessRequest {
26
+ readonly userID: string
27
+ readonly principal: ArtifactPrincipal
28
+ readonly action: ArtifactAction
29
+ /** Absent for create. */
30
+ readonly artifact?: Artifact
31
+ /** Absent when this hub has no workspaces: then no session reads through a workspace. */
32
+ readonly workspaces?: ArtifactWorkspaces
33
+ }
34
+
35
+ export type ArtifactAuthorizer = (request: AccessRequest) => Promise<boolean>
36
+
37
+ /** The contract's default policy (see the header). */
38
+ export const defaultArtifactPolicy: ArtifactAuthorizer = async ({ userID, principal, action, artifact, workspaces }) => {
39
+ if (principal.kind === "user") return true
40
+ if (action === "create") return true
41
+ if (action !== "read" || !artifact) return false
42
+ if (artifact.visibility !== "workspace" || artifact.workspaceID === undefined || !workspaces) return false
43
+ return workspaces.isCurrentMember(userID, artifact.workspaceID, principal.sessionID)
44
+ }
@@ -0,0 +1,113 @@
1
+ // The BlobStore contract (HWA-20; docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Artifacts): artifact
2
+ // bytes, content-addressed by sha256 and immutable, stored per user (the tenant key). Implementations: memory
3
+ // (src/central/backend/memory-blobs.ts, tests and the single-process default), files under the hub data directory
4
+ // (HWA-21) and a chunked Postgres table (HWA-22, decision tctkrik8). Every implementation passes the shared suite in
5
+ // test/blob-store-conformance.ts.
6
+ //
7
+ // Rules every implementation keeps:
8
+ // - Tenancy: a blob belongs to one user. Another user's hash answers like a missing one (no existence oracle), and
9
+ // usage is per user. The same bytes stored by two users are two blobs.
10
+ // - put streams the body: it hashes as it reads, stops reading (and cancels the source) as soon as the body passes
11
+ // `maxBytes` (default and ceiling ARTIFACT_LIMITS.blobBytes), and stores nothing when the body is too large, its
12
+ // hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (never sniffed), or a new blob would
13
+ // take the user past `quotaBytes` (default ARTIFACT_LIMITS.userBlobBytes). Storing bytes that are already there
14
+ // stores nothing new, is never refused for quota, and refreshes `touchedAt`.
15
+ // - A blob becomes visible to get/has/stat/list only once all its bytes are durably stored and verified
16
+ // (write-temp-then-rename, a committed transaction): readers never see a partial blob.
17
+ // - Reference counting lives with the revisions (src/central/artifacts/store.ts), not here: the artifact store
18
+ // decides a blob is unreferenced and then calls delete with `touchedBefore`. delete refuses (false) a blob touched
19
+ // at or after that time, atomically with the removal, so a writer that touched a blob after the deleter read the
20
+ // references keeps it (see the publish protocol in store.ts).
21
+ import type { BlobRef, Page } from "../../protocol"
22
+
23
+ /** A blob body: whole bytes, a web stream, or any async iterable of chunks (read once). */
24
+ export type BlobBody = Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
25
+
26
+ export interface BlobPutOptions {
27
+ /** `sha256:<hex>` the body must hash to; a mismatch stores nothing (BlobHashMismatchError). */
28
+ readonly expectedHash?: string
29
+ /** Largest body accepted (default and ceiling ARTIFACT_LIMITS.blobBytes). */
30
+ readonly maxBytes?: number
31
+ /** The user's total stored bytes a new blob may not exceed (default ARTIFACT_LIMITS.userBlobBytes). */
32
+ readonly quotaBytes?: number
33
+ }
34
+
35
+ /** A stored blob's facts (no media type: that belongs to the revision's BlobRef). */
36
+ export interface BlobInfo {
37
+ readonly hash: string
38
+ readonly bytes: number
39
+ /** When the bytes were first stored. */
40
+ readonly storedAt: number
41
+ /** The latest put or touch of these bytes. */
42
+ readonly touchedAt: number
43
+ }
44
+
45
+ /** A blob being read: its facts and its bytes as a stream (read once). */
46
+ export interface BlobRead extends BlobInfo {
47
+ readonly body: ReadableStream<Uint8Array>
48
+ }
49
+
50
+ export interface BlobUsage {
51
+ readonly blobs: number
52
+ readonly bytes: number
53
+ }
54
+
55
+ export interface BlobDeleteOptions {
56
+ /** Delete only when the blob's touchedAt is before this time (checked atomically with the delete). */
57
+ readonly touchedBefore?: number
58
+ }
59
+
60
+ export interface BlobListOptions {
61
+ /** 1–LIST_LIMIT_MAX (default LIST_LIMIT_DEFAULT). */
62
+ readonly limit?: number
63
+ /** The cursor of the previous page. */
64
+ readonly cursor?: string
65
+ }
66
+
67
+ export interface BlobStore {
68
+ /** Stores the body (see the rules above) and answers its reference with the given media type. */
69
+ put(userID: string, body: BlobBody, mediaType: string, options?: BlobPutOptions): Promise<BlobRef>
70
+ /** The blob's bytes; undefined when missing (or another user's, or a malformed hash). */
71
+ get(userID: string, hash: string): Promise<BlobRead | undefined>
72
+ stat(userID: string, hash: string): Promise<BlobInfo | undefined>
73
+ has(userID: string, hash: string): Promise<boolean>
74
+ /** Refreshes touchedAt; false when the blob is missing. */
75
+ touch(userID: string, hash: string): Promise<boolean>
76
+ /** Removes the blob; false when missing or (touchedBefore) touched at or after that time. */
77
+ delete(userID: string, hash: string, options?: BlobDeleteOptions): Promise<boolean>
78
+ usage(userID: string): Promise<BlobUsage>
79
+ /** The user's blobs by hash, paged (orphan recovery walks it). */
80
+ list(userID: string, options?: BlobListOptions): Promise<Page<BlobInfo>>
81
+ }
82
+
83
+ /** A request the store refuses as malformed: a media type, maxBytes, limit or cursor. Maps to 400 invalid. */
84
+ export class BlobInputError extends Error {
85
+ constructor(message: string) {
86
+ super(message)
87
+ this.name = "BlobInputError"
88
+ }
89
+ }
90
+
91
+ /** The body passed maxBytes; nothing was stored. Maps to 400 invalid. */
92
+ export class BlobTooLargeError extends Error {
93
+ constructor(readonly maxBytes: number) {
94
+ super(`blob is larger than ${maxBytes} bytes`)
95
+ this.name = "BlobTooLargeError"
96
+ }
97
+ }
98
+
99
+ /** The body did not hash to expectedHash; nothing was stored. Maps to 400 invalid. */
100
+ export class BlobHashMismatchError extends Error {
101
+ constructor(readonly expected: string, readonly actual: string) {
102
+ super(`blob hashes to ${actual}, not ${expected}`)
103
+ this.name = "BlobHashMismatchError"
104
+ }
105
+ }
106
+
107
+ /** A new blob would take the user past their quota; nothing was stored. Maps to 429 rate_limited (a user cap). */
108
+ export class BlobQuotaError extends Error {
109
+ constructor(readonly quotaBytes: number, readonly usedBytes: number) {
110
+ super(`blob storage quota of ${quotaBytes} bytes reached`)
111
+ this.name = "BlobQuotaError"
112
+ }
113
+ }
@@ -0,0 +1,98 @@
1
+ // Helpers every BlobStore implementation shares (memory, files HWA-21, Postgres HWA-22): option checks, the
2
+ // bounded hashing body reader, and the list cursor. Keeping them here keeps the implementations alike.
3
+ import { ARTIFACT_LIMITS, ARTIFACT_MEDIA_TYPES, isBlobHash, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX } from "../../protocol"
4
+ import { type BlobBody, BlobHashMismatchError, BlobInputError, type BlobListOptions, type BlobPutOptions, BlobTooLargeError } from "./blob-store"
5
+
6
+ export interface PutLimits {
7
+ readonly maxBytes: number
8
+ readonly quotaBytes: number
9
+ readonly expectedHash?: string
10
+ }
11
+
12
+ /** Checks put's media type and options (BlobInputError) and answers the limits in force. */
13
+ export function putLimits(mediaType: string, options: BlobPutOptions = {}): PutLimits {
14
+ if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
15
+ const maxBytes = options.maxBytes ?? ARTIFACT_LIMITS.blobBytes
16
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > ARTIFACT_LIMITS.blobBytes) {
17
+ throw new BlobInputError(`maxBytes must be 0–${ARTIFACT_LIMITS.blobBytes}`)
18
+ }
19
+ const quotaBytes = options.quotaBytes ?? ARTIFACT_LIMITS.userBlobBytes
20
+ if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 0) throw new BlobInputError("quotaBytes must be a non-negative integer")
21
+ if (options.expectedHash !== undefined && !isBlobHash(options.expectedHash)) throw new BlobInputError("expectedHash must be sha256:<64 hex>")
22
+ return { maxBytes, quotaBytes, ...(options.expectedHash !== undefined ? { expectedHash: options.expectedHash } : {}) }
23
+ }
24
+
25
+ /**
26
+ * Reads the body once, handing each chunk to `sink` as it comes, hashing and counting it. Stops reading and
27
+ * cancels the source as soon as the body passes maxBytes (BlobTooLargeError), and checks expectedHash at the end
28
+ * (BlobHashMismatchError). The caller discards what the sink got when this throws.
29
+ */
30
+ export async function readBlobBody(
31
+ body: BlobBody,
32
+ limits: PutLimits,
33
+ sink: (chunk: Uint8Array) => void | Promise<void>,
34
+ ): Promise<{ hash: string; bytes: number }> {
35
+ const hasher = new Bun.CryptoHasher("sha256")
36
+ let bytes = 0
37
+ const take = async (chunk: Uint8Array): Promise<boolean> => {
38
+ if (!(chunk instanceof Uint8Array)) throw new BlobInputError("blob chunks must be bytes")
39
+ bytes += chunk.byteLength
40
+ if (bytes > limits.maxBytes) return false
41
+ hasher.update(chunk)
42
+ await sink(chunk)
43
+ return true
44
+ }
45
+ if (body instanceof Uint8Array) {
46
+ if (!(await take(body))) throw new BlobTooLargeError(limits.maxBytes)
47
+ } else if (body instanceof ReadableStream) {
48
+ const reader = body.getReader()
49
+ try {
50
+ for (;;) {
51
+ const { done, value } = await reader.read()
52
+ if (done) break
53
+ if (!(await take(value))) {
54
+ await reader.cancel().catch(() => {})
55
+ throw new BlobTooLargeError(limits.maxBytes)
56
+ }
57
+ }
58
+ } finally {
59
+ // Bun 1.3 can throw from releaseLock() on a finished body reader after other I/O (quirk tfxrxuta).
60
+ try {
61
+ reader.releaseLock()
62
+ } catch {
63
+ // best effort
64
+ }
65
+ }
66
+ } else {
67
+ const iterator = body[Symbol.asyncIterator]()
68
+ for (;;) {
69
+ const { done, value } = await iterator.next()
70
+ if (done) break
71
+ if (!(await take(value))) {
72
+ await iterator.return?.()
73
+ throw new BlobTooLargeError(limits.maxBytes)
74
+ }
75
+ }
76
+ }
77
+ const hash = `sha256:${hasher.digest("hex")}`
78
+ if (limits.expectedHash !== undefined && limits.expectedHash !== hash) throw new BlobHashMismatchError(limits.expectedHash, hash)
79
+ return { hash, bytes }
80
+ }
81
+
82
+ /** A list request's limit and the hash after which the page starts (BlobInputError on bad ones). */
83
+ export function listWindow(options: BlobListOptions = {}): { limit: number; after?: string } {
84
+ const limit = options.limit ?? LIST_LIMIT_DEFAULT
85
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new BlobInputError(`limit must be 1–${LIST_LIMIT_MAX}`)
86
+ if (options.cursor === undefined) return { limit }
87
+ let after: string
88
+ try {
89
+ after = Buffer.from(options.cursor, "base64url").toString("utf8")
90
+ } catch {
91
+ throw new BlobInputError("cursor is not valid")
92
+ }
93
+ if (!isBlobHash(after)) throw new BlobInputError("cursor is not valid")
94
+ return { limit, after }
95
+ }
96
+
97
+ /** The cursor that continues a list after `hash`. */
98
+ export const listCursor = (hash: string): string => Buffer.from(hash, "utf8").toString("base64url")
@@ -0,0 +1,27 @@
1
+ // Artifact events for the canonical agent journal (HWA-21; contract: Cross-cutting rules, Events). The store's
2
+ // onEvent hook gives compact events; this maps them to journal input in the `artifact` family (read with
3
+ // artifacts:read). Payloads are IDs, kinds, revisions and counts only: never titles, descriptions or content.
4
+ import type { ArtifactEvent } from "./store"
5
+
6
+ /** Journal input of an artifact event (an EventInput of family "artifact"). */
7
+ export interface ArtifactJournalInput {
8
+ readonly type: ArtifactEvent["type"]
9
+ readonly family: "artifact"
10
+ readonly subject: { readonly artifactID: string }
11
+ readonly data: Readonly<Record<string, string | number>>
12
+ }
13
+
14
+ export function artifactJournalEvent(event: ArtifactEvent): ArtifactJournalInput {
15
+ return {
16
+ type: event.type,
17
+ family: "artifact",
18
+ subject: { artifactID: event.artifactID },
19
+ data: {
20
+ kind: event.kind,
21
+ revision: event.revision,
22
+ ...(event.revisionID !== undefined ? { revisionID: event.revisionID } : {}),
23
+ ...(event.number !== undefined ? { number: event.number } : {}),
24
+ ...(event.state !== undefined ? { state: event.state } : {}),
25
+ },
26
+ }
27
+ }