@huynle/hub 0.7.4 → 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 +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  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 +632 -49
  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,231 @@
1
+ // Team communication policy in message delivery (HWA-34; contract docs/plans/2026-10-30-hub-workspaces-artifacts-
2
+ // contracts.md, Teams + HWA-33 clarification; decisions zltiwb4s, 5etngvgm, r3vv4eon, qxzrdc6c).
3
+ //
4
+ // The delivery engine (../agent/delivery.ts) calls this module before it stores anything:
5
+ // - prepare(userID, from, addr) once per send: resolves team addresses and returns admit(), which filters the
6
+ // resolved recipients through the normative rule `teamDeliveryVerdict` (packages/protocol/src/teams.ts; never
7
+ // re-derived here) and throws when none is left.
8
+ // - answer(userID, from, original, to, kind) before a reply or decline is stored.
9
+ //
10
+ // Addressing (qxzrdc6c):
11
+ // - `@@role#team` / `@@role.project#team` (parseTeamRoleAddress) name the sessions currently seated in that role of
12
+ // that active team (paused teams included), filtered by the session's project; never global role holders. The
13
+ // team becomes the explicit team context: only its policy decides, and a team sender must hold a seat in it
14
+ // (not_member). An unknown or archived team, or a role nobody holds, is not_found (like any address).
15
+ // - A team-less `@@role[.project]` from a session with active seats: when the role is seated (by another session)
16
+ // in two or more of the sender's active teams, 409 ambiguous with `candidates: [{team, address}]` (only the
17
+ // sender's own teams); in exactly one, it resolves to that team's seats (implicit: every shared team counts);
18
+ // in none, it stays a global role address. Sessions in no team, the user and apps: global, unchanged (r3vv4eon).
19
+ // - The SDK parseAddress never accepts `#`, so no other caller resolves a team address as a global role.
20
+ //
21
+ // Recipients: every candidate of a role (one or sendAll) is checked; send picks among the permitted ones, sendAll
22
+ // reaches only those. When none is permitted the send is denied with the first candidate's reason.
23
+ //
24
+ // Denials: AgentError forbidden, extra `{policy: "policy" | "external" | "not_member"}` and a fixed message that
25
+ // names no team, role or recipient. Before a denial is recorded the sender's message rate limit is charged (the
26
+ // limiter passed in), so repeated denied attempts end in rate_limited and the audit stays bounded. The `denied`
27
+ // hook gets IDs only (sender, recipient, team when known, kind, reason); the hub records it in the agent audit and
28
+ // appends teamDeniedEvent (journal family `manage`) for the Watchtower's denied counts. Nothing is stored,
29
+ // no hop counted, nothing sent.
30
+ //
31
+ // Replies and declines: always allowed when they answer an ask that was delivered to the replier (its recipient is
32
+ // the replying party; Messages.answerable enforces the same on store, so a forged or foreign ID is not_found and
33
+ // reaches nobody). Denied asks are never stored, so they cannot be answered. A reply to a msg is a msg under policy.
34
+ //
35
+ // Revisions and in-flight messages (decided for HWA-34): the policy is read fresh for each send (no cache across
36
+ // sends), so a published revision, a pause/archive or a staffing change applies to the very next message. Messages
37
+ // admitted earlier are not re-checked: an ask stays answerable after its edge, its team or the replier's seat goes
38
+ // away, its re-wake reminders continue, and pending (offline) messages are still delivered on flush.
39
+ //
40
+ // Cost: one deliveryContext read (seats of the sender and all candidates, plus their teams) per send, plus, for
41
+ // role addresses, one byName or one current-seat list per involved team (≤ TEAM_LIMITS.teamsPerUser). Lookups are
42
+ // memoised within the send only.
43
+ import { parseTeamRoleAddress, type TeamSeat, teamDeliveryVerdict, type DeliveryVerdict } from "../../protocol"
44
+ import type { DirectoryEntry, Resolved } from "../agent/directory"
45
+ import { type Party, partyKey } from "../agent/envelope"
46
+ import { AgentError } from "../agent/errors"
47
+ import type { EventInput } from "../agent/events"
48
+ import type { Message } from "../agent/messages"
49
+ import { parseAddress } from "../agent/names"
50
+ import type { DeliveryContext, TeamSeatStore } from "./seats"
51
+ import type { TeamStore } from "./store"
52
+
53
+ export type TeamDenialReason = "policy" | "external" | "not_member"
54
+
55
+ /** What the denied hook and the journal get: IDs and enums only. */
56
+ export interface TeamDenial {
57
+ readonly senderID: string
58
+ readonly recipientID?: string
59
+ readonly kind: "msg" | "ask" | "reply" | "decline"
60
+ readonly reason: TeamDenialReason
61
+ readonly teamID?: string
62
+ }
63
+
64
+ /** Recipients named through a team: `explicit` for `#team` (the verdict's team context). */
65
+ export interface TeamResolved extends Resolved {
66
+ readonly team: { readonly teamID: string; readonly explicit: boolean }
67
+ }
68
+
69
+ /** The message limiter (MessageLimiter.admit): charged before a denial is recorded. */
70
+ export interface PolicyLimiter {
71
+ admit(userID: string, from: Party, to: Party): Promise<void>
72
+ }
73
+
74
+ /** One send under policy (see prepare). */
75
+ export interface TeamSend {
76
+ /** Set when the address resolved through a team; otherwise the caller resolves it as before. */
77
+ readonly resolved?: TeamResolved
78
+ /** The recipients the sender may reach, in order; throws forbidden when none is left. */
79
+ admit(recipients: readonly DirectoryEntry[], kind: "msg" | "ask", limiter?: PolicyLimiter): Promise<DirectoryEntry[]>
80
+ }
81
+
82
+ export interface TeamPolicyDeps {
83
+ readonly teams: Pick<TeamStore, "byName" | "get">
84
+ readonly seats: Pick<TeamSeatStore, "deliveryContext" | "list">
85
+ /** Session lookups by ID (Directory.resolve). */
86
+ readonly directory: { resolve(userID: string, addr: string): Resolved }
87
+ /** A denial, after it was charged to the limiter: audit it and append teamDeniedEvent. Errors are logged. */
88
+ readonly denied?: (userID: string, denial: TeamDenial) => void | Promise<void>
89
+ }
90
+
91
+ /** The compact journal event of a denial (TEAM_EVENT_TYPES `team.denied`): IDs only, never text or role names. */
92
+ export const teamDeniedEvent = (d: TeamDenial): EventInput => ({
93
+ type: "team.denied",
94
+ family: "manage",
95
+ subject: { senderID: d.senderID, ...(d.recipientID !== undefined ? { sessionID: d.recipientID } : {}), ...(d.teamID !== undefined ? { teamID: d.teamID } : {}) },
96
+ data: { kind: d.kind, reason: d.reason },
97
+ })
98
+
99
+ const DENIED: Record<TeamDenialReason, { message: string; hint: string }> = {
100
+ policy: { message: "Your team's communication paths do not allow this message", hint: "Message a role your team lets you reach, or escalate to @me" },
101
+ external: { message: "Team members can only contact sessions in their teams", hint: "Escalate to @me, or ask the user to allow external contact for your role" },
102
+ not_member: { message: "You hold no seat in that team", hint: "Address the role without #team, or through a team you are in" },
103
+ }
104
+
105
+ type Denied = Extract<DeliveryVerdict, { allow: false }>
106
+
107
+ const sessionParty = (entry: DirectoryEntry): Party => ({ kind: "session", sessionID: entry.sessionID, ...(entry.name ? { name: entry.name } : {}) })
108
+
109
+ export class TeamPolicy {
110
+ constructor(private readonly deps: TeamPolicyDeps) {}
111
+
112
+ /** Starts a send from `from` to `addr` (see the header). */
113
+ async prepare(userID: string, from: Party, addr: string): Promise<TeamSend> {
114
+ const resolved = await this.resolve(userID, from, addr)
115
+ const teamID = resolved?.team.explicit ? resolved.team.teamID : undefined
116
+ return {
117
+ ...(resolved ? { resolved } : {}),
118
+ admit: async (recipients, kind, limiter) => {
119
+ if (from.kind !== "session" || recipients.length === 0) return [...recipients]
120
+ const ctx = await this.context(userID, [from.sessionID, ...recipients.map((r) => r.sessionID)])
121
+ const allowed: DirectoryEntry[] = []
122
+ let first: { entry: DirectoryEntry; verdict: Denied } | undefined
123
+ for (const entry of recipients) {
124
+ const verdict = this.verdict(ctx, from.sessionID, entry.sessionID, kind, teamID)
125
+ if (verdict.allow) allowed.push(entry)
126
+ else first ??= { entry, verdict }
127
+ }
128
+ if (allowed.length || !first) return allowed
129
+ return this.deny(userID, from, sessionParty(first.entry), { senderID: from.sessionID, recipientID: first.entry.sessionID, kind, reason: first.verdict.reason, ...(teamID ? { teamID } : {}) }, limiter)
130
+ },
131
+ }
132
+ }
133
+
134
+ /** Checks a reply or decline from `from` (the original's recipient) to `to` (its sender) before it is stored. */
135
+ async answer(userID: string, from: Party, original: Message, to: Party, kind: "reply" | "decline", limiter?: PolicyLimiter): Promise<void> {
136
+ if (from.kind !== "session") return
137
+ // A decline of a msg is invalid (Messages.decline says so); policy does not mask that answer.
138
+ if (kind === "decline" && original.kind !== "ask") return
139
+ const replyToPermittedAsk = original.kind === "ask" && partyKey(original.recipient) === partyKey(from)
140
+ const ids = [from.sessionID, ...(to.kind === "session" ? [to.sessionID] : [])]
141
+ const base = { kind: "reply" as const, toUser: to.kind === "user", replyToPermittedAsk }
142
+ // @me and permitted-ask answers need no lookup (the verdict's first rules).
143
+ let verdict = teamDeliveryVerdict({ ...base, senderSeats: [], recipientSeats: [], teams: new Map() })
144
+ if (!verdict.allow || (verdict.via !== "user" && verdict.via !== "reply")) {
145
+ const ctx = await this.context(userID, ids)
146
+ verdict = teamDeliveryVerdict({ ...base, senderSeats: seatsIn(ctx, from.sessionID), recipientSeats: to.kind === "session" ? seatsIn(ctx, to.sessionID) : [], teams: ctx.teams })
147
+ }
148
+ if (verdict.allow) return
149
+ await this.deny(userID, from, to, { senderID: from.sessionID, ...(to.kind === "session" ? { recipientID: to.sessionID } : {}), kind, reason: verdict.reason }, limiter)
150
+ }
151
+
152
+ // ---------- internals ----------
153
+
154
+ private verdict(ctx: DeliveryContext, senderID: string, recipientID: string, kind: "msg" | "ask", teamID: string | undefined): DeliveryVerdict {
155
+ return teamDeliveryVerdict({
156
+ kind,
157
+ toUser: false,
158
+ replyToPermittedAsk: false,
159
+ senderSeats: seatsIn(ctx, senderID),
160
+ recipientSeats: seatsIn(ctx, recipientID),
161
+ ...(teamID !== undefined ? { teamID } : {}),
162
+ teams: ctx.teams,
163
+ })
164
+ }
165
+
166
+ private context(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
167
+ return this.deps.seats.deliveryContext(userID, [...new Set(sessionIDs)])
168
+ }
169
+
170
+ /** Charges the limiter, records the denial (IDs only) and throws forbidden. */
171
+ private async deny(userID: string, from: Party, to: Party, denial: TeamDenial, limiter?: PolicyLimiter): Promise<never> {
172
+ await limiter?.admit(userID, from, to)
173
+ try {
174
+ await this.deps.denied?.(userID, denial)
175
+ } catch (cause) {
176
+ console.error(`[hub] team denial record failed: ${cause instanceof Error ? cause.message : String(cause)}`)
177
+ }
178
+ const { message, hint } = DENIED[denial.reason]
179
+ throw new AgentError("forbidden", message, { hint, extra: { policy: denial.reason } })
180
+ }
181
+
182
+ /** Team resolution of `addr` (see the header); undefined: resolve it as a plain address. */
183
+ private async resolve(userID: string, from: Party, addr: string): Promise<TeamResolved | undefined> {
184
+ const team = parseTeamRoleAddress(addr)
185
+ if (team) {
186
+ const record = await this.deps.teams.byName(userID, team.team)
187
+ if (!record || record.state !== "active") throw new AgentError("not_found", `No session ${addr}`)
188
+ const sessions = await this.seated(userID, record.id, team.role, team.project)
189
+ if (!sessions.length) throw new AgentError("not_found", `No session ${addr}`)
190
+ return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions, team: { teamID: record.id, explicit: true } }
191
+ }
192
+ const address = parseAddress(addr)
193
+ if (address?.kind !== "role" || from.kind !== "session") return undefined
194
+ const ctx = await this.context(userID, [from.sessionID])
195
+ const mine = [...new Set(seatsIn(ctx, from.sessionID).filter((s) => ctx.teams.get(s.teamID)?.state === "active").map((s) => s.teamID))]
196
+ const matches: Array<{ teamID: string; sessions: DirectoryEntry[] }> = []
197
+ for (const teamID of mine) {
198
+ const sessions = (await this.seated(userID, teamID, address.role, address.project)).filter((e) => e.sessionID !== from.sessionID)
199
+ if (sessions.length) matches.push({ teamID, sessions })
200
+ }
201
+ if (matches.length > 1) {
202
+ const candidates: Array<{ team: string; address: string }> = []
203
+ for (const m of matches) {
204
+ const name = (await this.deps.teams.get(userID, m.teamID))?.name
205
+ if (name) candidates.push({ team: name, address: `@@${address.role}${address.project ? `.${address.project}` : ""}#${name}` })
206
+ }
207
+ candidates.sort((a, b) => (a.team < b.team ? -1 : 1))
208
+ throw new AgentError("ambiguous", `${addr} is a role in ${matches.length} of your teams`, { hint: "Name the team: @@role#team (quote # in a shell)", extra: { candidates } })
209
+ }
210
+ const only = matches[0]
211
+ return only ? { address, sessions: only.sessions, team: { teamID: only.teamID, explicit: false } } : undefined
212
+ }
213
+
214
+ /** Directory entries of the sessions currently seated in the role (and the project, when given). */
215
+ private async seated(userID: string, teamID: string, role: string, project: string | undefined): Promise<DirectoryEntry[]> {
216
+ const ids = [...new Set((await this.deps.seats.list(userID, teamID, { current: true })).filter((s) => s.role === role).map((s) => s.sessionID))]
217
+ const out: DirectoryEntry[] = []
218
+ for (const id of ids) {
219
+ let entry: DirectoryEntry | undefined
220
+ try {
221
+ entry = this.deps.directory.resolve(userID, id).sessions.find((e) => e.sessionID === id)
222
+ } catch {
223
+ entry = undefined // a seat of a session the directory no longer knows: nobody to deliver to
224
+ }
225
+ if (entry && (project === undefined || entry.project === project)) out.push(entry)
226
+ }
227
+ return out
228
+ }
229
+ }
230
+
231
+ const seatsIn = (ctx: DeliveryContext, sessionID: string): readonly TeamSeat[] => ctx.seats.get(sessionID) ?? []