@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
@@ -0,0 +1,139 @@
1
+ // Importing a skills/commands bundle into profile content (HWA-14). A bundle is a bounded list of text files
2
+ // {path, text}, in the layout harnesses already use:
3
+ //
4
+ // AGENTS.md or CLAUDE.md → instructions (at most one)
5
+ // skills/<name>/SKILL.md → a skill (front matter `description`; `name` must match the folder)
6
+ // commands/<name>.md → a command (front matter `description`; `$ARGUMENTS` → `{{arguments}}`)
7
+ //
8
+ // Anything else (scripts, hook folders, package/requirements manifests, harness settings) is `unsupported`:
9
+ // profiles never carry code to run or dependencies to install. Front matter that would grant tools
10
+ // (`allowed-tools`) or pick a model is dropped with a note: a profile confers no authority. Text only; nothing is
11
+ // fetched, executed or written to disk. The result goes through the same content rules as any version.
12
+ import { isSlug, PROFILE_LIMITS, type ProfileCommand, type ProfileSkill, type ProfileVersionContent } from "../../protocol"
13
+ import { checkContent, ProfileContentError } from "./content"
14
+
15
+ export const IMPORT_LIMITS = {
16
+ /** One instructions file, every skill and every command. */
17
+ files: 1 + PROFILE_LIMITS.skillsPerProfile + PROFILE_LIMITS.commandsPerProfile,
18
+ pathChars: 200,
19
+ /** Total bytes of the files' text. */
20
+ bytes: PROFILE_LIMITS.versionBytes,
21
+ notes: 64,
22
+ } as const
23
+
24
+ export interface BundleFile {
25
+ readonly path: string
26
+ readonly text: string
27
+ }
28
+
29
+ export interface ImportResult {
30
+ readonly content: ProfileVersionContent
31
+ /** What was dropped or defaulted, per file; bounded, never file text. */
32
+ readonly notes: string[]
33
+ }
34
+
35
+ const INSTRUCTION_FILES = new Set(["AGENTS.md", "CLAUDE.md"])
36
+ const SKILL_PATH = /^skills\/([^/]+)\/SKILL\.md$/
37
+ const COMMAND_PATH = /^commands\/([^/]+)\.md$/
38
+ const SAFE_PATH = /^[A-Za-z0-9._/-]+$/
39
+
40
+ const invalid = (message: string) => new ProfileContentError("invalid", message)
41
+ const shown = (path: string) => (SAFE_PATH.test(path) && path.length <= IMPORT_LIMITS.pathChars ? path : "(unprintable path)")
42
+
43
+ function checkPath(path: string): void {
44
+ if (!path || path.length > IMPORT_LIMITS.pathChars) throw invalid(`file paths must be 1–${IMPORT_LIMITS.pathChars} characters`)
45
+ if (path.includes("\\") || path.startsWith("/") || path.split("/").some((s) => s === "" || s === "." || s === "..")) {
46
+ throw invalid(`${shown(path)}: paths must be relative, with / separators and no . or .. segments`)
47
+ }
48
+ }
49
+
50
+ interface FrontMatter {
51
+ readonly fields: Record<string, string>
52
+ readonly body: string
53
+ }
54
+
55
+ /** `---\nkey: value\n---\nbody`; text without front matter is all body. */
56
+ function frontMatter(text: string): FrontMatter {
57
+ if (!text.startsWith("---\n")) return { fields: {}, body: text }
58
+ const end = text.indexOf("\n---", 3)
59
+ if (end < 0) return { fields: {}, body: text }
60
+ const after = text.indexOf("\n", end + 4)
61
+ const body = after < 0 ? "" : text.slice(after + 1)
62
+ const fields: Record<string, string> = {}
63
+ for (const line of text.slice(4, end).split("\n")) {
64
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line)
65
+ if (!match) continue
66
+ fields[match[1]!.toLowerCase()] = match[2]!.trim().replace(/^(["'])(.*)\1$/, "$2")
67
+ }
68
+ return { fields, body }
69
+ }
70
+
71
+ function ignored(path: string, fields: Record<string, string>, keep: readonly string[], notes: string[]): void {
72
+ for (const key of Object.keys(fields)) {
73
+ if (keep.includes(key)) continue
74
+ if (key === "allowed-tools") notes.push(`${path}: allowed-tools ignored (profiles grant no tool permissions)`)
75
+ else notes.push(`${path}: ${key} ignored`)
76
+ }
77
+ }
78
+
79
+ function describe(path: string, fields: Record<string, string>, fallback: string, notes: string[]): string {
80
+ const description = fields.description?.trim()
81
+ if (description) return description
82
+ notes.push(`${path}: no description; using a default`)
83
+ return fallback
84
+ }
85
+
86
+ /** Converts a bundle to normalized version content; throws ProfileContentError (invalid / unsupported). */
87
+ export function importBundle(files: unknown): ImportResult {
88
+ if (!Array.isArray(files)) throw invalid("files must be a list of {path, text}")
89
+ if (files.length > IMPORT_LIMITS.files) throw invalid(`at most ${IMPORT_LIMITS.files} files`)
90
+ let bytes = 0
91
+ const seen = new Set<string>()
92
+ for (const file of files) {
93
+ if (typeof file !== "object" || file === null || Array.isArray(file)) throw invalid("each file must be {path, text}")
94
+ const { path, text, ...rest } = file as Record<string, unknown>
95
+ if (typeof path !== "string" || typeof text !== "string" || Object.keys(rest).length) throw invalid("each file must be {path, text} with text content")
96
+ checkPath(path)
97
+ if (seen.has(path)) throw invalid(`${shown(path)} appears twice`)
98
+ seen.add(path)
99
+ bytes += new TextEncoder().encode(text).length
100
+ }
101
+ if (bytes > IMPORT_LIMITS.bytes) throw invalid(`bundle is too large (more than ${IMPORT_LIMITS.bytes} bytes)`)
102
+
103
+ let instructions: string | undefined
104
+ const skills: ProfileSkill[] = []
105
+ const commands: ProfileCommand[] = []
106
+ const notes: string[] = []
107
+ for (const file of files as BundleFile[]) {
108
+ const { path } = file
109
+ const text = file.text.replace(/\r\n?/g, "\n")
110
+ if (INSTRUCTION_FILES.has(path)) {
111
+ if (instructions !== undefined) throw invalid("only one of AGENTS.md and CLAUDE.md")
112
+ instructions = text
113
+ continue
114
+ }
115
+ const skill = SKILL_PATH.exec(path)
116
+ const command = COMMAND_PATH.exec(path)
117
+ if (skill || command) {
118
+ const name = (skill ?? command)![1]!
119
+ if (!isSlug(name)) throw invalid(`${shown(path)}: names must be 2–40 lowercase letters, digits or dashes`)
120
+ const { fields, body } = frontMatter(text)
121
+ if (skill) {
122
+ if (fields.name !== undefined && fields.name !== name) notes.push(`${path}: name ${isSlug(fields.name) ? fields.name : "(invalid)"} differs from its folder; using ${name}`)
123
+ ignored(path, fields, ["name", "description"], notes)
124
+ skills.push({ name, description: describe(path, fields, `Skill ${name}`, notes), body })
125
+ } else {
126
+ ignored(path, fields, ["description"], notes)
127
+ if (/\$[1-9]/.test(body)) notes.push(`${path}: positional $1…$9 are kept as text; use {{name}} parameters`)
128
+ commands.push({ name, description: describe(path, fields, `Command ${name}`, notes), template: body.replaceAll("$ARGUMENTS", "{{arguments}}") })
129
+ }
130
+ continue
131
+ }
132
+ throw new ProfileContentError(
133
+ "unsupported",
134
+ `${shown(path)}: only AGENTS.md or CLAUDE.md, skills/<name>/SKILL.md and commands/<name>.md are imported; profiles carry no scripts, hooks, settings or dependency manifests`,
135
+ )
136
+ }
137
+ const content = checkContent({ ...(instructions !== undefined ? { instructions } : {}), skills, commands })
138
+ return { content, notes: notes.slice(0, IMPORT_LIMITS.notes) }
139
+ }
@@ -0,0 +1,356 @@
1
+ // The profile registry on /api/agent (HWA-14; contract: Profiles in docs/plans/2026-10-30-hub-workspaces-artifacts-
2
+ // contracts.md):
3
+ //
4
+ // POST /profiles {name, title, description?, tags?, content} manage
5
+ // POST /profiles/import {name, title, description?, tags?, files} manage
6
+ // GET /profiles?state=active|archived|all&limit&cursor read
7
+ // GET /profiles?include=usage,summary + {usage: {id → counts}, hidden?, summaries: {id → latest counts}} (RP-08)
8
+ // GET /profiles/:id → {profile, latest} (latest: version summary) read
9
+ // GET /profiles/:id/usage?limit&cursor where it is used (usage.ts; per-category scopes) read
10
+ // PATCH /profiles/:id {expectedRevision, title?, description?, tags?, state?, metadata?, projects?} manage
11
+ // GET /profiles/:id/versions?limit&cursor the retained history, newest first read
12
+ // POST /profiles/:id/versions {expectedRevision, content | fromVersion | files} manage
13
+ // GET /profiles/:id/versions/:version the immutable version in full read
14
+ // GET /profiles/:id/versions/:version/skills/:name one skill body (on-demand loading) read
15
+ // POST /profiles/:id/versions/:version/commands/:name/render {params} → {prompt, secretRefs} read
16
+ // GET /sessions/:addr/profile {assignment, profile, version summary} | {assignment: null}
17
+ // PUT /sessions/:addr/profile {profileID, version, expectedRevision} assign
18
+ // DELETE /sessions/:addr/profile?expectedRevision=n assign
19
+ // GET /sessions/:addr/profile/skills/:name the pinned version's skill
20
+ // POST /sessions/:addr/profile/commands/:name/render the pinned version's command, rendered
21
+ //
22
+ // Who: users, always (their own records; another user's are not_found). App tokens by scope: profiles:read,
23
+ // profiles:manage, profiles:assign; session-addressed routes also need sessions:read (resolving an address
24
+ // reveals a session). Session tokens only read their OWN pinned profile (summary, skills, command rendering);
25
+ // they never list, create, version, archive or assign. Rendering returns text and confers nothing: sending it
26
+ // to a session goes through the existing prompt verb and its grants. Assigning pins a version; it grants no
27
+ // authority and does not by itself change a running session (HWA-15 applies pins through the spoke). An app's pin
28
+ // also needs a usable prompt grant on the session (authorizePin, HWA-15); the answer's ack is `pending` until the
29
+ // spoke acknowledges, `unsupported` or `pending` + `offline` (see apply.ts). Unpinning stops re-applying the pin;
30
+ // what a running session already applied stays until it restarts (no clear op in v1).
31
+ // Creates accept Idempotency-Key. Every change is in the admin audit (IDs only) and the journal (compact events).
32
+ import { type AgentProfileVersion, PROFILE_SCOPES } from "../../protocol"
33
+ import type { AgentContext, AgentRoute } from "../agent/routes"
34
+ import type { AgentEvents, EventInput } from "../agent/events"
35
+ import { AgentError } from "../agent/errors"
36
+ import type { Store } from "../store"
37
+ import { ProfileContentError, parseTemplate, renderTemplate, secretReferences, contentBytes } from "./content"
38
+ import { profileArchivedEvent, profileAssignedEvent, profileCreatedEvent, profileVersionEvent } from "./events"
39
+ import { Idempotency } from "./idempotency"
40
+ import type { PinAuthorizer } from "./apply"
41
+ import { importBundle } from "./import"
42
+ import { contentError, type ProfileStore } from "./store"
43
+ import { boundedText, PROFILE_JSON_BODY_MAX } from "../agent/body"
44
+ import { ProfileUsage, usageView } from "./usage"
45
+
46
+ const PREFIX = "/api/agent"
47
+ const NO_STORE = { "cache-control": "no-store" }
48
+ const [READ_SCOPE, MANAGE_SCOPE, ASSIGN_SCOPE] = PROFILE_SCOPES
49
+ const READ = { scope: READ_SCOPE } as const
50
+ const MANAGE = { scope: MANAGE_SCOPE } as const
51
+ const ASSIGN = { scope: ASSIGN_SCOPE } as const
52
+ /** Session-addressed reads: sessions (their own only) and apps with profiles:read (+ sessions:read). */
53
+ const SESSION_READ = { session: true, scope: READ_SCOPE } as const
54
+
55
+ export interface ProfileRoutesDeps {
56
+ readonly profiles: ProfileStore
57
+ readonly directory: { resolveOne(userID: string, addr: string): { readonly sessionID: string } }
58
+ readonly events?: Pick<AgentEvents, "append">
59
+ readonly audit?: Pick<Store, "audit">
60
+ /** Idempotent creates; one over the profiles' StateStore when absent. */
61
+ readonly idempotency?: Idempotency
62
+ /**
63
+ * (HWA-15) Checks an app's pin beyond its scopes (pinAuthorizer: a usable prompt grant on the session). Without
64
+ * it apps cannot pin at all (fail closed); users always can.
65
+ */
66
+ readonly authorizePin?: PinAuthorizer
67
+ /** (RP-08) Where profiles are used; without it every category reads as unused. */
68
+ readonly usage?: ProfileUsage
69
+ }
70
+
71
+ /** A version without its bodies: what clients list before loading a skill on demand. */
72
+ export function versionSummary(version: AgentProfileVersion) {
73
+ const { content } = version
74
+ return {
75
+ profileID: version.profileID,
76
+ version: version.version,
77
+ digest: version.digest,
78
+ createdAt: version.createdAt,
79
+ bytes: contentBytes(content),
80
+ instructions: content.instructions !== undefined,
81
+ ...(content.model !== undefined ? { model: content.model } : {}),
82
+ skills: content.skills.map(({ name, description }) => ({ name, description })),
83
+ commands: content.commands.map(({ name, description, template }) => {
84
+ const parsed = parseTemplate(template)
85
+ return { name, description, params: parsed.ok ? parsed.params : [] }
86
+ }),
87
+ secretRefs: secretReferences(content),
88
+ }
89
+ }
90
+
91
+ const ok = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE })
92
+
93
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
94
+ const raw = JSON.parse(await boundedText(request, PROFILE_JSON_BODY_MAX)) as unknown
95
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
96
+ return raw as Record<string, unknown>
97
+ }
98
+
99
+ function only(body: Record<string, unknown>, allowed: readonly string[]): void {
100
+ for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
101
+ }
102
+
103
+ function versionParam(raw: string | undefined): number {
104
+ if (!raw || !/^[1-9][0-9]{0,8}$/.test(raw)) throw new AgentError("invalid", "version must be a version number")
105
+ return Number(raw)
106
+ }
107
+
108
+ function intParam(url: URL, name: string): number | undefined {
109
+ const raw = url.searchParams.get(name)
110
+ return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
111
+ }
112
+
113
+ function importContent(files: unknown) {
114
+ try {
115
+ return importBundle(files)
116
+ } catch (cause) {
117
+ throw cause instanceof ProfileContentError ? contentError(cause) : cause
118
+ }
119
+ }
120
+
121
+ function render(version: AgentProfileVersion, name: string, body: Record<string, unknown>) {
122
+ only(body, ["params"])
123
+ const params = body.params ?? {}
124
+ if (typeof params !== "object" || params === null || Array.isArray(params)) throw new AgentError("invalid", "params must be an object of text values")
125
+ const command = version.content.commands.find((c) => c.name === name)
126
+ if (!command) throw new AgentError("not_found", `No command ${name} in version ${version.version}`)
127
+ let prompt: string
128
+ try {
129
+ prompt = renderTemplate(command.template, params as Record<string, unknown>)
130
+ } catch (cause) {
131
+ throw contentError(cause)
132
+ }
133
+ const parsed = parseTemplate(command.template)
134
+ return { prompt, secretRefs: parsed.ok ? parsed.secrets : [], profileID: version.profileID, version: version.version, command: name }
135
+ }
136
+
137
+ function skillOf(version: AgentProfileVersion, name: string) {
138
+ const skill = version.content.skills.find((s) => s.name === name)
139
+ if (!skill) throw new AgentError("not_found", `No skill ${name} in version ${version.version}`)
140
+ return { skill, profileID: version.profileID, version: version.version }
141
+ }
142
+
143
+ export function profileRoutes(deps: ProfileRoutesDeps): AgentRoute[] {
144
+ const { profiles, directory } = deps
145
+ const idempotency = deps.idempotency ?? new Idempotency({ state: profiles.state })
146
+ const usage = deps.usage ?? new ProfileUsage({ profiles })
147
+
148
+ async function changed(context: AgentContext, action: string, target: string, event?: EventInput): Promise<void> {
149
+ await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
150
+ if (event) await deps.events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
151
+ }
152
+
153
+ /** The session a session-addressed route names, for this caller. */
154
+ function sessionTarget(context: AgentContext): string {
155
+ const { actor, user } = context
156
+ if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) throw new AgentError("forbidden", "Session profile routes also need scope sessions:read")
157
+ const sessionID = directory.resolveOne(user.id, context.params.addr!).sessionID
158
+ if (actor.kind === "session" && sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only read its own profile")
159
+ return sessionID
160
+ }
161
+
162
+ async function version(userID: string, id: string, number: number): Promise<AgentProfileVersion> {
163
+ const found = await profiles.version(userID, id, number)
164
+ if (!found) throw new AgentError("not_found", `No version ${number} of profile ${id}`)
165
+ return found
166
+ }
167
+
168
+ /** The pinned version of the addressed session. */
169
+ async function pinned(context: AgentContext): Promise<AgentProfileVersion> {
170
+ const sessionID = sessionTarget(context)
171
+ const assignment = await profiles.assignment(context.user.id, sessionID)
172
+ if (!assignment) throw new AgentError("not_found", `Session ${sessionID} has no profile`)
173
+ return version(context.user.id, assignment.profileID, assignment.version)
174
+ }
175
+
176
+ const create = (path: string, input: (body: Record<string, unknown>) => { body: Record<string, unknown>; notes?: string[] }): AgentRoute => ({
177
+ method: "POST",
178
+ path,
179
+ allow: MANAGE,
180
+ async handle(context) {
181
+ const raw = await bodyOf(context.request)
182
+ return idempotency.run(context.user.id, `POST ${context.url.pathname}`, context.request.headers.get("idempotency-key"), raw, async () => {
183
+ const { body, notes } = input(raw)
184
+ const { profile, version } = await profiles.create(context.user.id, body)
185
+ await changed(context, "profile.create", profile.id, profileCreatedEvent(profile.id, profile.revision))
186
+ return { status: 201, body: { profile, version: versionSummary(version), ...(notes ? { notes } : {}) } }
187
+ })
188
+ },
189
+ })
190
+
191
+ return [
192
+ create(`${PREFIX}/profiles`, (body) => ({ body })),
193
+ create(`${PREFIX}/profiles/import`, (body) => {
194
+ only(body, ["name", "title", "description", "tags", "metadata", "projects", "files"])
195
+ const { files, ...rest } = body
196
+ const { content, notes } = importContent(files)
197
+ return { body: { ...rest, content }, notes }
198
+ }),
199
+ {
200
+ method: "GET",
201
+ path: `${PREFIX}/profiles`,
202
+ allow: READ,
203
+ async handle({ url, user, actor }) {
204
+ const limit = intParam(url, "limit")
205
+ const state = url.searchParams.get("state") ?? undefined
206
+ const cursor = url.searchParams.get("cursor") ?? undefined
207
+ const include = url.searchParams.getAll("include").flatMap((i) => i.split(","))
208
+ if (include.some((i) => i !== "usage" && i !== "summary")) throw new AgentError("invalid", "include must be usage and/or summary")
209
+ const page = await profiles.list(user.id, { ...(limit !== undefined ? { limit } : {}), ...(state ? { state } : {}), ...(cursor ? { cursor } : {}) })
210
+ return ok({
211
+ ...page,
212
+ ...(include.includes("summary") ? { summaries: await profiles.summaries(user.id, page.items.map((p) => p.id)) } : {}),
213
+ ...(include.includes("usage") ? await usage.counts(user.id, page.items, usageView(actor)) : {}),
214
+ })
215
+ },
216
+ },
217
+ {
218
+ method: "GET",
219
+ path: `${PREFIX}/profiles/:id/usage`,
220
+ allow: READ,
221
+ async handle({ params, url, user, actor }) {
222
+ const limit = intParam(url, "limit")
223
+ const cursor = url.searchParams.get("cursor") ?? undefined
224
+ return ok(await usage.usage(user.id, params.id!, usageView(actor), { ...(limit !== undefined ? { limit } : {}), ...(cursor !== undefined ? { cursor } : {}) }))
225
+ },
226
+ },
227
+ {
228
+ method: "GET",
229
+ path: `${PREFIX}/profiles/:id`,
230
+ allow: READ,
231
+ async handle({ params, user }) {
232
+ const profile = await profiles.get(user.id, params.id!)
233
+ if (!profile) throw new AgentError("not_found", `No profile ${params.id}`)
234
+ const latest = await profiles.version(user.id, profile.id, profile.latestVersion)
235
+ return ok({ profile, ...(latest ? { latest: versionSummary(latest) } : {}) })
236
+ },
237
+ },
238
+ {
239
+ method: "PATCH",
240
+ path: `${PREFIX}/profiles/:id`,
241
+ allow: MANAGE,
242
+ async handle(context) {
243
+ const { before, after } = await profiles.updateWithPrior(context.user.id, context.params.id!, await bodyOf(context.request))
244
+ const event = before.state !== after.state ? profileArchivedEvent(after.id, after.state === "archived", after.revision) : undefined
245
+ await changed(context, "profile.update", after.id, event)
246
+ return ok({ profile: after })
247
+ },
248
+ },
249
+ {
250
+ method: "GET",
251
+ path: `${PREFIX}/profiles/:id/versions`,
252
+ allow: READ,
253
+ async handle({ params, url, user }) {
254
+ const limit = intParam(url, "limit")
255
+ const cursor = url.searchParams.get("cursor") ?? undefined
256
+ return ok(await profiles.history(user.id, params.id!, { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) }))
257
+ },
258
+ },
259
+ {
260
+ method: "POST",
261
+ path: `${PREFIX}/profiles/:id/versions`,
262
+ allow: MANAGE,
263
+ async handle(context) {
264
+ const raw = await bodyOf(context.request)
265
+ return idempotency.run(context.user.id, `POST ${context.url.pathname}`, context.request.headers.get("idempotency-key"), raw, async () => {
266
+ only(raw, ["expectedRevision", "content", "fromVersion", "files"])
267
+ const sources = ["content", "fromVersion", "files"].filter((k) => raw[k] !== undefined)
268
+ if (sources.length !== 1) throw new AgentError("invalid", "Send exactly one of content, fromVersion or files")
269
+ const imported = raw.files !== undefined ? importContent(raw.files) : undefined
270
+ const { profile, version } = await profiles.addVersion(context.user.id, context.params.id!, {
271
+ expectedRevision: raw.expectedRevision,
272
+ ...(imported ? { content: imported.content } : raw.content !== undefined ? { content: raw.content } : { fromVersion: raw.fromVersion }),
273
+ })
274
+ await changed(context, "profile.version", profile.id, profileVersionEvent(profile.id, version.version, profile.revision))
275
+ return { status: 201, body: { profile, version: versionSummary(version), ...(imported ? { notes: imported.notes } : {}) } }
276
+ })
277
+ },
278
+ },
279
+ {
280
+ method: "GET",
281
+ path: `${PREFIX}/profiles/:id/versions/:version`,
282
+ allow: READ,
283
+ handle: async ({ params, user }) => ok({ version: await version(user.id, params.id!, versionParam(params.version)) }),
284
+ },
285
+ {
286
+ method: "GET",
287
+ path: `${PREFIX}/profiles/:id/versions/:version/skills/:name`,
288
+ allow: READ,
289
+ handle: async ({ params, user }) => ok(skillOf(await version(user.id, params.id!, versionParam(params.version)), params.name!)),
290
+ },
291
+ {
292
+ method: "POST",
293
+ path: `${PREFIX}/profiles/:id/versions/:version/commands/:name/render`,
294
+ allow: READ,
295
+ async handle({ params, user, request }) {
296
+ const body = await bodyOf(request)
297
+ return ok(render(await version(user.id, params.id!, versionParam(params.version)), params.name!, body))
298
+ },
299
+ },
300
+ {
301
+ method: "GET",
302
+ path: `${PREFIX}/sessions/:addr/profile`,
303
+ allow: SESSION_READ,
304
+ async handle(context) {
305
+ const sessionID = sessionTarget(context)
306
+ const assignment = await profiles.assignment(context.user.id, sessionID)
307
+ if (!assignment) return ok({ assignment: null })
308
+ const [profile, pinnedVersion] = await Promise.all([profiles.get(context.user.id, assignment.profileID), profiles.version(context.user.id, assignment.profileID, assignment.version)])
309
+ return ok({ assignment, ...(profile ? { profile } : {}), ...(pinnedVersion ? { version: versionSummary(pinnedVersion) } : {}) })
310
+ },
311
+ },
312
+ {
313
+ method: "PUT",
314
+ path: `${PREFIX}/sessions/:addr/profile`,
315
+ allow: ASSIGN,
316
+ async handle(context) {
317
+ const sessionID = sessionTarget(context)
318
+ const body = await bodyOf(context.request)
319
+ only(body, ["profileID", "version", "expectedRevision"])
320
+ if (context.actor.kind !== "user") {
321
+ if (!deps.authorizePin) throw new AgentError("forbidden", "This hub does not let apps pin profiles")
322
+ await deps.authorizePin(context.actor, context.user.id, sessionID)
323
+ }
324
+ const assignment = await profiles.assign(context.user.id, sessionID, body)
325
+ await changed(context, "profile.assign", sessionID, profileAssignedEvent(sessionID, assignment.profileID, assignment.version, assignment.revision))
326
+ return ok({ assignment })
327
+ },
328
+ },
329
+ {
330
+ method: "DELETE",
331
+ path: `${PREFIX}/sessions/:addr/profile`,
332
+ allow: ASSIGN,
333
+ async handle(context) {
334
+ const sessionID = sessionTarget(context)
335
+ await profiles.unassign(context.user.id, sessionID, intParam(context.url, "expectedRevision"))
336
+ await changed(context, "profile.unassign", sessionID)
337
+ return new Response(null, { status: 204, headers: NO_STORE })
338
+ },
339
+ },
340
+ {
341
+ method: "GET",
342
+ path: `${PREFIX}/sessions/:addr/profile/skills/:name`,
343
+ allow: SESSION_READ,
344
+ handle: async (context) => ok(skillOf(await pinned(context), context.params.name!)),
345
+ },
346
+ {
347
+ method: "POST",
348
+ path: `${PREFIX}/sessions/:addr/profile/commands/:name/render`,
349
+ allow: SESSION_READ,
350
+ async handle(context) {
351
+ const body = await bodyOf(context.request)
352
+ return ok(render(await pinned(context), context.params.name!, body))
353
+ },
354
+ },
355
+ ]
356
+ }