@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,133 @@
1
+ // Route-level Idempotency-Key handling shared by the /api/agent create routes (contract: IDEMPOTENCY_KEY,
2
+ // IDEMPOTENCY_TTL_MS; per user and per route). Two compatible modes:
3
+ //
4
+ // - Answer replay (RouteIdempotency.run, extracted from the profile routes of HWA-14): the first request with a
5
+ // key reserves it (ifVersion 0), runs, and stores its answer; a repeat with the same body within the TTL gets
6
+ // that answer again; another body under the key is 409 conflict, and so is a repeat while the first is still
7
+ // running. A failed request frees its key. Records live in a StateStore collection of the caller's choosing,
8
+ // keyed by sha256(route, key); expired ones are pruned as keys are used.
9
+ // - Store-level (the workspace, assignment and launch stores keep the key next to the record they create, so the
10
+ // claim and the record cannot drift apart): the route only reads and validates the header (idempotencyKeyOf)
11
+ // and answers with storeAnswer.
12
+ //
13
+ // Both modes look the same to clients: the header is `Idempotency-Key`, a malformed key is 400 invalid with the
14
+ // same wording, another body under a live key is 409 conflict, and a replay carries `idempotent-replayed: true`.
15
+ import { createHash } from "node:crypto"
16
+ import { IDEMPOTENCY_KEY, IDEMPOTENCY_TTL_MS } from "../../protocol"
17
+ import type { Clock, StateStore } from "../backend/types"
18
+ import { AgentError } from "./errors"
19
+
20
+ export const IDEMPOTENCY_HEADER = "idempotency-key"
21
+ export const REPLAYED_HEADER = "idempotent-replayed"
22
+ const BAD_KEY = "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -"
23
+
24
+ export const IDEMPOTENCY_LIMITS = {
25
+ ttlMs: IDEMPOTENCY_TTL_MS,
26
+ /** A reservation whose request never finished is taken over after this long. */
27
+ pendingMs: 60_000,
28
+ /** Live keys per user (per collection). */
29
+ keysPerUser: 1_000,
30
+ } as const
31
+
32
+ interface Record_ {
33
+ readonly v: 1
34
+ readonly hash: string
35
+ readonly at: number
36
+ readonly state: "pending" | "done"
37
+ readonly status?: number
38
+ readonly body?: unknown
39
+ }
40
+
41
+ export interface Answer {
42
+ readonly status: number
43
+ readonly body: unknown
44
+ }
45
+
46
+ const NO_STORE = { "cache-control": "no-store" }
47
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex")
48
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
49
+
50
+ /** JSON with sorted keys and undefined members dropped (the same body in any key order compares equal). */
51
+ export function canonicalBody(value: unknown): string {
52
+ if (Array.isArray(value)) return `[${value.map(canonicalBody).join(",")}]`
53
+ if (isRecord(value)) {
54
+ const keys = Object.keys(value)
55
+ .filter((k) => value[k] !== undefined)
56
+ .sort()
57
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalBody(value[k])}`).join(",")}}`
58
+ }
59
+ return JSON.stringify(value)
60
+ }
61
+
62
+ /** The request's Idempotency-Key (undefined when absent; 400 invalid when malformed). */
63
+ export function idempotencyKeyOf(request: Request): string | undefined {
64
+ const key = request.headers.get(IDEMPOTENCY_HEADER)
65
+ if (key === null) return undefined
66
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", BAD_KEY)
67
+ return key
68
+ }
69
+
70
+ /** The answer of a store-level idempotent create: `status` when it created, 200 + the replay header when not. */
71
+ export function storeAnswer(created: boolean, body: unknown, status = 201): Response {
72
+ return respond({ status: created ? status : 200, body }, !created)
73
+ }
74
+
75
+ export class RouteIdempotency {
76
+ private readonly state: StateStore
77
+ private readonly collection: string
78
+ private readonly now: Clock
79
+
80
+ constructor(options: { readonly state: StateStore; readonly collection: string; readonly now?: Clock }) {
81
+ this.state = options.state
82
+ this.collection = options.collection
83
+ this.now = options.now ?? Date.now
84
+ }
85
+
86
+ /** Runs `perform` once per (user, route, key, body); without a key it simply runs. */
87
+ async run(userID: string, route: string, key: string | null, body: unknown, perform: () => Promise<Answer>): Promise<Response> {
88
+ if (key === null) return respond(await perform(), false)
89
+ if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", BAD_KEY)
90
+ const id = sha256(`${route}\n${key}`)
91
+ const hash = sha256(canonicalBody(body))
92
+ let reserved: number | undefined
93
+ for (let attempt = 0; attempt < 4 && reserved === undefined; attempt++) {
94
+ const existing = await this.state.get<Record_>(userID, this.collection, id)
95
+ const now = this.now()
96
+ const live = existing && now - existing.value.at < (existing.value.state === "pending" ? IDEMPOTENCY_LIMITS.pendingMs : IDEMPOTENCY_LIMITS.ttlMs)
97
+ if (existing && live) {
98
+ if (existing.value.hash !== hash) throw new AgentError("conflict", "This Idempotency-Key was used with another body", { hint: "Use a new key" })
99
+ if (existing.value.state === "done") return respond({ status: existing.value.status!, body: existing.value.body }, true)
100
+ throw new AgentError("conflict", "A request with this Idempotency-Key is still running", { hint: "Retry shortly" })
101
+ }
102
+ if (!existing) await this.prune(userID)
103
+ const put = await this.state.put<Record_>(userID, this.collection, id, { v: 1, hash, at: now, state: "pending" }, { ifVersion: existing?.version ?? 0 })
104
+ if (put.ok) reserved = put.version
105
+ }
106
+ if (reserved === undefined) throw new AgentError("conflict", "A request with this Idempotency-Key is still running", { hint: "Retry shortly" })
107
+ let answer: Answer
108
+ try {
109
+ answer = await perform()
110
+ } catch (cause) {
111
+ await this.state.delete(userID, this.collection, id, { ifVersion: reserved }).catch(() => {})
112
+ throw cause
113
+ }
114
+ await this.state.put<Record_>(userID, this.collection, id, { v: 1, hash, at: this.now(), state: "done", status: answer.status, body: answer.body }, { ifVersion: reserved })
115
+ return respond(answer, false)
116
+ }
117
+
118
+ /** Drops expired keys; refuses a new one past keysPerUser live keys. */
119
+ private async prune(userID: string): Promise<void> {
120
+ const now = this.now()
121
+ let live = 0
122
+ for (const entry of await this.state.list<Record_>(userID, this.collection)) {
123
+ const ttl = entry.value.state === "pending" ? IDEMPOTENCY_LIMITS.pendingMs : IDEMPOTENCY_LIMITS.ttlMs
124
+ if (now - entry.value.at >= ttl) await this.state.delete(userID, this.collection, entry.key, { ifVersion: entry.version })
125
+ else live++
126
+ }
127
+ if (live >= IDEMPOTENCY_LIMITS.keysPerUser) throw new AgentError("rate_limited", `At most ${IDEMPOTENCY_LIMITS.keysPerUser} live Idempotency-Keys per user`, { extra: { retryAfter: 3600 } })
128
+ }
129
+ }
130
+
131
+ function respond(answer: Answer, replayed: boolean): Response {
132
+ return Response.json(answer.body, { status: answer.status, headers: { ...NO_STORE, ...(replayed ? { [REPLAYED_HEADER]: "true" } : {}) } })
133
+ }
@@ -0,0 +1,116 @@
1
+ // Session intent routes (CON-02; contract: "Session intent (CON-02)" in docs/plans/2026-11-01-concierge-contracts.md).
2
+ //
3
+ // GET /api/agent/sessions/:addr/intent → {intent: SessionIntent | null}
4
+ // users; app tokens holding sessions:read; session tokens (the session read rules: any of the user's
5
+ // sessions, scoped by team discovery when the hub has it)
6
+ // PUT /api/agent/sessions/:addr/intent {text: string | null, expectedRevision} → {intent: SessionIntent | null}
7
+ // users (source user); app tokens holding sessions:manage (source user, `by` the app); a session token for
8
+ // ITS OWN session only (source self; another session is 403 forbidden). null clears the writer's layer, down
9
+ // to the next source. A stale expectedRevision is 409 conflict {currentRevision}. (CON-10) Users only:
10
+ // {source: "prompt", text: null, expectedRevision} clears the first-prompt intent (a wrong one), down to the
11
+ // next source; the first prompt stays used up, so a later prompt never refills it.
12
+ //
13
+ // Answers carry the effective intent: the stored one, else the session's non-placeholder title (source title, the
14
+ // record's revision, 0 when nothing was ever stored), else null. Another user's session is 404 not_found (address
15
+ // resolution is per caller). The text is untrusted data: bounded, sanitised and never interpreted. Each change is
16
+ // in the admin audit (no text) and the journal (`session.intent {source, revision}`, from SessionIntents).
17
+ import type { SessionIntent } from "../../protocol"
18
+ import type { Registry } from "../registry"
19
+ import type { Store } from "../store"
20
+ import type { AgentActor } from "./actor"
21
+ import { boundedJson } from "./body"
22
+ import type { Directory, DirectoryEntry } from "./directory"
23
+ import { AgentError } from "./errors"
24
+ import { type IntentWrite, type SessionIntents, titleIntent } from "./intents"
25
+ import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
26
+ import type { TeamDiscovery } from "../teams/discovery"
27
+
28
+ /** A PUT body is one short line of text and a revision. */
29
+ const BODY_MAX = 16 * 1024
30
+ const FIELDS = new Set(["text", "expectedRevision", "source"])
31
+
32
+ export interface IntentRoutesDeps {
33
+ readonly intents: SessionIntents
34
+ readonly directory: Directory
35
+ /** The live title (the read fallback); the directory's last known title otherwise. */
36
+ readonly registry?: Pick<Registry, "sessionTenant" | "session">
37
+ readonly store: Pick<Store, "audit">
38
+ /** Team-aware discovery (HWA-40): team-scoped callers read only sessions they may see. */
39
+ readonly discovery?: TeamDiscovery
40
+ }
41
+
42
+ const NO_STORE = { "cache-control": "no-store" }
43
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
44
+
45
+ /** Who wrote: user, app:<tokenID>, or the session's ID (as launches record `by`). */
46
+ function writer(actor: AgentActor): string {
47
+ switch (actor.kind) {
48
+ case "user":
49
+ return "user"
50
+ case "app":
51
+ return `app:${actor.tokenID}`
52
+ case "session":
53
+ return actor.sessionID
54
+ }
55
+ }
56
+
57
+ async function readWrite(request: Request): Promise<Pick<IntentWrite, "text" | "expectedRevision"> & { readonly clearPrompt?: true }> {
58
+ const body = await boundedJson(request, BODY_MAX)
59
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object {text, expectedRevision}")
60
+ const fields = body as Record<string, unknown>
61
+ for (const key of Object.keys(fields)) if (!FIELDS.has(key)) throw new AgentError("invalid", `Unknown field ${key}`)
62
+ const { text, expectedRevision } = fields
63
+ if (text !== null && typeof text !== "string") throw new AgentError("invalid", "text is a string, or null to clear")
64
+ if (!Number.isSafeInteger(expectedRevision) || (expectedRevision as number) < 0) {
65
+ throw new AgentError("invalid", "expectedRevision is the intent's revision you read (0 when none was stored)")
66
+ }
67
+ // (CON-10) {source: "prompt", text: null}: the user clears the first-prompt intent (down to the title).
68
+ if (fields.source !== undefined && (fields.source !== "prompt" || text !== null)) throw new AgentError("invalid", 'source is only "prompt", with text null (clears the first-prompt intent)')
69
+ return { text, expectedRevision: expectedRevision as number, ...(fields.source === "prompt" ? { clearPrompt: true as const } : {}) }
70
+ }
71
+
72
+ export function intentRoutes(deps: IntentRoutesDeps): AgentRoute[] {
73
+ const { intents, directory } = deps
74
+
75
+ /** The session an address names for this caller (team-scoped when discovery is given); not_found otherwise. */
76
+ async function resolve(userID: string, actor: AgentActor, addr: string): Promise<DirectoryEntry> {
77
+ if (deps.discovery) return deps.discovery.resolveOne(await deps.discovery.census(userID, actor), addr)
78
+ return directory.resolveOne(userID, addr)
79
+ }
80
+
81
+ /** The stored intent, else the title fallback, else null. */
82
+ async function effective(userID: string, entry: DirectoryEntry): Promise<SessionIntent | null> {
83
+ const stored = await intents.read(userID, entry.sessionID)
84
+ if (stored) return stored
85
+ const live = deps.registry && deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
86
+ const title = live?.title ?? entry.title
87
+ return titleIntent(entry.sessionID, title, await intents.revision(userID, entry.sessionID), live?.updated ?? entry.lastSeen ?? 0) ?? null
88
+ }
89
+
90
+ return [
91
+ {
92
+ method: "GET",
93
+ path: `${AGENT_PREFIX}/sessions/:addr/intent`,
94
+ allow: { session: true, scope: "sessions:read" },
95
+ handle: async ({ user, actor, params }) => ok({ intent: await effective(user.id, await resolve(user.id, actor, params.addr!)) }),
96
+ },
97
+ {
98
+ method: "PUT",
99
+ path: `${AGENT_PREFIX}/sessions/:addr/intent`,
100
+ allow: { session: true, scope: "sessions:manage" },
101
+ async handle(context: AgentContext) {
102
+ const { user, actor, params } = context
103
+ const { clearPrompt, ...write } = await readWrite(context.request)
104
+ const entry = await resolve(user.id, actor, params.addr!)
105
+ if (actor.kind === "session" && entry.sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only set its own session's intent")
106
+ if (clearPrompt && actor.kind !== "user") throw new AgentError("forbidden", "Only the user clears a session's first-prompt intent")
107
+ const before = await intents.revision(user.id, entry.sessionID)
108
+ await intents.set(user.id, entry.sessionID, { ...write, source: clearPrompt ? "prompt" : actor.kind === "session" ? "self" : "user", by: writer(actor) })
109
+ if ((await intents.revision(user.id, entry.sessionID)) !== before) {
110
+ await deps.store.audit({ actor: user.id, action: write.text === null ? "session.intent.clear" : "session.intent", target: entry.sessionID, detail: actor.kind })
111
+ }
112
+ return ok({ intent: await effective(user.id, entry) })
113
+ },
114
+ },
115
+ ]
116
+ }