@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -7,14 +7,22 @@
7
7
  // {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
8
8
  // - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
9
9
  // first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
10
- // unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget.
10
+ // unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget; Sent
11
+ // says whether it woke (woke) and, when not, why (wakeSkipped: wake_false | subagent | hop_cap | budget).
11
12
  // - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
12
13
  // one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
13
14
  // never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
14
- // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed → kept for
15
- // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
15
+ // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
16
+ // OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
17
+ // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
18
+ // still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
19
+ // sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). In a
16
20
  // cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
17
- // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
21
+ // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
22
+ // session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
23
+ // or unreachable session per frame), and a waking message first flushes only its own session's pending ones.
24
+ // Ordering limit: a waking message to a session that is still busy is admitted (queued after the turn) before the
25
+ // quiet ones pending for it, which follow when the turn ends.
18
26
  // OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
19
27
  // "retained" retry loop on the hub.
20
28
  // - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
@@ -32,11 +40,16 @@
32
40
  // for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
33
41
  // Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
34
42
  // - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
35
- // (console, voice, typed in the harness) reset a session's chain (humanTurn).
43
+ // (console, voice, typed in the harness) reset a session's chain (humanTurn). Apps keep no depth (S1): a new
44
+ // msg/ask from an app is hop 1 plus its relay depth (what OTHER sessions sent it in the last 5 minutes, so a
45
+ // relay loop through an app still reaches the hop cap), an app's reply or decline counts up from the message it
46
+ // answers, and nothing sent to an app raises a persistent depth.
36
47
  import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
48
+ import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
37
49
  import { isDeliverResult } from "../../protocol"
38
50
  import { randomBytes } from "node:crypto"
39
51
  import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
52
+ import type { TeamPolicy } from "../teams/policy"
40
53
  import type { AgentActor } from "./actor"
41
54
  import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
42
55
  import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
@@ -74,14 +87,27 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
74
87
  * ask stayed unanswered by an idle recipient after every re-wake.
75
88
  */
76
89
  export interface DeliveryAlert {
77
- /** user: a session or app sent the user (@me) a message. */
78
- readonly kind: "hopCap" | "unanswered" | "user"
90
+ /** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
91
+ readonly kind: "hopCap" | "unanswered" | "user" | "expired"
79
92
  /** The recipient session (user: the sending session, "" for an app). */
80
93
  readonly sessionID: string
81
94
  readonly messageID: string
82
95
  readonly text: string
83
96
  }
84
97
 
98
+ /** What a message to a session is, for the inbound policy. */
99
+ export type InboundKind = "msg" | "ask" | "reply" | "decline"
100
+
101
+ /**
102
+ * (CON-04) Who may message a session, beyond the existing rules: `admit` throws (forbidden) to refuse before anything
103
+ * is stored. `original`: the message a reply or decline answers. The hub passes the Concierge inbound rule.
104
+ */
105
+ export interface InboundPolicy {
106
+ admit(userID: string, from: Party, sessionID: string, kind: InboundKind, original?: Message): Promise<void>
107
+ /** Who a sender may reach at all (the Concierge: only @me, and replies to asks addressed to it); throws to refuse. */
108
+ outbound?(userID: string, from: Party, kind: InboundKind, original?: Message): Promise<void>
109
+ }
110
+
85
111
  export interface DeliveryDeps {
86
112
  readonly state: StateStore
87
113
  readonly counters: Counters
@@ -94,8 +120,12 @@ export interface DeliveryDeps {
94
120
  /** Labels of a session sender (default: its @name from the directory). */
95
121
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
96
122
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
123
+ /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
124
+ readonly expired?: (userID: string, message: Message) => void
97
125
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
98
126
  readonly limits?: Partial<MessageLimits>
127
+ /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
128
+ readonly relayWindowMs?: number
99
129
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
100
130
  readonly users?: () => Iterable<string> | Promise<Iterable<string>>
101
131
  /** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
@@ -104,6 +134,10 @@ export interface DeliveryDeps {
104
134
  readonly owner?: string
105
135
  /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
106
136
  readonly tickMs?: number
137
+ /** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
138
+ readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
139
+ /** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
140
+ readonly inbound?: InboundPolicy
107
141
  }
108
142
 
109
143
  export interface SendOptions {
@@ -113,12 +147,21 @@ export interface SendOptions {
113
147
  readonly urgent?: boolean
114
148
  /** Asks only. */
115
149
  readonly expiresInMs?: number
150
+ /**
151
+ * Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
152
+ * larger of its own and this). Team intake passes the submitting session's depth + 1.
153
+ */
154
+ readonly chainHop?: number
116
155
  }
117
156
 
118
157
  export interface Sent {
119
158
  readonly message: Message
120
159
  /** How it reached the recipient; undefined when the recipient is not a session (stored only). */
121
160
  readonly delivery?: DeliveryState
161
+ /** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
162
+ readonly woke?: boolean
163
+ /** Why it was not (only when woke is false). */
164
+ readonly wakeSkipped?: WakeSkip
122
165
  }
123
166
 
124
167
  /** A reply or decline as sent, with the message it answered. */
@@ -202,6 +245,11 @@ export class Delivery {
202
245
  private readonly newID: () => string
203
246
  /** Per user: the flush in progress (flushes run one at a time). */
204
247
  private readonly flushing = new Map<string, Promise<void>>()
248
+ /**
249
+ * Pending flush counters since start (no message text): attempts that failed (offline spokes, a busy OpenCode
250
+ * session refusing a routine quiet msg) and messages skipped behind such a failure until the next frame.
251
+ */
252
+ readonly flushStats = { failed: 0, skipped: 0 }
205
253
  /** Per user and session: the primer in flight (one at a time); true once it was admitted. */
206
254
  private readonly priming = new Map<string, Promise<boolean>>()
207
255
  /** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
@@ -217,7 +265,7 @@ export class Delivery {
217
265
  this.newID = deps.newID ?? newMessageID
218
266
  this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
219
267
  this.messages = new Messages({ state: deps.state, now, newID: this.newID })
220
- this.hops = new HopTracker({ state: deps.state, now })
268
+ this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
221
269
  this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
222
270
  // An explicit (re)name primes again, so the session learns its new address.
223
271
  deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
@@ -250,7 +298,7 @@ export class Delivery {
250
298
  async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
251
299
  if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
252
300
  const from = this.party(userID, actor)
253
- const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
301
+ const resolved = await this.resolveFor(userID, actor, from, addr, kind)
254
302
  return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
255
303
  }
256
304
 
@@ -258,7 +306,7 @@ export class Delivery {
258
306
  async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
259
307
  if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
260
308
  const from = this.party(userID, actor)
261
- const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
309
+ const resolved = await this.resolveFor(userID, actor, from, addr, kind)
262
310
  const sent: Sent[] = []
263
311
  for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
264
312
  return sent
@@ -266,12 +314,12 @@ export class Delivery {
266
314
 
267
315
  /** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
268
316
  reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
269
- return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
317
+ return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
270
318
  }
271
319
 
272
320
  /** Declines an ask, with a reason, and delivers the decline to the asker. */
273
321
  decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
274
- return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
322
+ return this.answer(userID, actor, replyTo, options, "decline", (input) => this.messages.decline(userID, { ...input, why }))
275
323
  }
276
324
 
277
325
  /** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
@@ -280,9 +328,10 @@ export class Delivery {
280
328
  }
281
329
 
282
330
  /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
283
- flush(userID: string): Promise<void> {
331
+ /** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
332
+ flush(userID: string, sessionID?: string): Promise<void> {
284
333
  const previous = this.flushing.get(userID) ?? Promise.resolve()
285
- const run = previous.then(() => this.flushNow(userID))
334
+ const run = previous.then(() => this.flushNow(userID, sessionID))
286
335
  const tracked = run.finally(() => {
287
336
  if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
288
337
  })
@@ -290,6 +339,21 @@ export class Delivery {
290
339
  return run
291
340
  }
292
341
 
342
+ /**
343
+ * (CON-10) Drops the messages still pending for a session that ended for good (a retired Concierge session): they
344
+ * are never delivered later, and their delivery reads "dropped" (never waiting). Answers how many were dropped.
345
+ */
346
+ async dropPending(userID: string, sessionID: string): Promise<number> {
347
+ let dropped = 0
348
+ for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
349
+ if (value.sessionID !== sessionID) continue
350
+ if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) continue
351
+ await this.messages.setDelivery(userID, value.messageID, "dropped")
352
+ dropped++
353
+ }
354
+ return dropped
355
+ }
356
+
293
357
  /** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
294
358
  async onSessions(connID: string): Promise<void> {
295
359
  const userID = this.deps.registry.connTenant(connID)
@@ -321,6 +385,11 @@ export class Delivery {
321
385
  return
322
386
  }
323
387
  for (const userID of users) {
388
+ try {
389
+ await this.expirePending(userID)
390
+ } catch (cause) {
391
+ console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
392
+ }
324
393
  try {
325
394
  await this.rewake(userID)
326
395
  } catch (cause) {
@@ -358,7 +427,7 @@ export class Delivery {
358
427
  const from = this.party(userID, actor)
359
428
  const recipient: Party = { kind: "user" }
360
429
  await this.limiter.admit(userID, from, recipient)
361
- const hop = await this.hops.next(userID, from)
430
+ const hop = await this.hops.next(userID, from, { to: recipient })
362
431
  const message = await this.messages.send(userID, {
363
432
  from,
364
433
  to: USER_ADDRESS,
@@ -378,10 +447,12 @@ export class Delivery {
378
447
  private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
379
448
  const sessionID = entry.sessionID
380
449
  const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
450
+ await this.deps.inbound?.outbound?.(userID, from, kind)
451
+ await this.deps.inbound?.admit(userID, from, sessionID, kind)
381
452
  this.gate(userID, sessionID)
382
453
  await this.limiter.admit(userID, from, recipient)
383
- const hop = await this.hops.next(userID, from)
384
- const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
454
+ const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
455
+ const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
385
456
  const message = await this.messages.send(userID, {
386
457
  from,
387
458
  to: addr,
@@ -392,15 +463,15 @@ export class Delivery {
392
463
  ...(generation === undefined ? {} : { generation }),
393
464
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
394
465
  })
395
- await this.hops.received(userID, recipient, hop)
466
+ await this.hops.received(userID, recipient, hop, from)
396
467
  this.users.add(userID)
397
- const wanted = options.wake !== false && entry.parentID === undefined
398
- const wake = await this.wakeFor(userID, message, wanted)
399
- if (kind === "ask" && wanted && !overHopCap(hop)) {
468
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
469
+ const skipped = await this.wakeFor(userID, message, unwanted)
470
+ if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
400
471
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
401
472
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
402
473
  }
403
- return this.deliver(userID, message, sessionID, wake, options.urgent === true)
474
+ return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
404
475
  }
405
476
 
406
477
  private schedule(): void {
@@ -463,6 +534,13 @@ export class Delivery {
463
534
  return (await this.request(userID, sessionID, req)) !== undefined
464
535
  }
465
536
 
537
+ /** The address's sessions (team-aware, HWA-34) without the sender, narrowed to those team policy permits. */
538
+ private async resolveFor(userID: string, actor: AgentActor, from: Party, addr: string, kind: "msg" | "ask"): Promise<Resolved> {
539
+ const team = await this.deps.teams?.prepare(userID, from, addr)
540
+ const resolved = this.others(actor, team?.resolved ?? this.deps.directory.resolve(userID, addr))
541
+ return team ? { ...resolved, sessions: await team.admit(resolved.sessions, kind, this.limiter) } : resolved
542
+ }
543
+
466
544
  /** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
467
545
  private others(actor: AgentActor, resolved: Resolved): Resolved {
468
546
  if (actor.kind !== "session") return resolved
@@ -487,40 +565,51 @@ export class Delivery {
487
565
  actor: AgentActor,
488
566
  replyTo: string,
489
567
  options: SendOptions,
568
+ kind: "reply" | "decline",
490
569
  store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
491
570
  ): Promise<Replied> {
492
571
  const from = this.party(userID, actor)
493
572
  const original = await this.messages.get(userID, replyTo)
494
573
  const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
495
- if (to?.kind === "session") this.gate(userID, to.sessionID)
574
+ if (original) await this.deps.inbound?.outbound?.(userID, from, kind, original)
575
+ if (to?.kind === "session") {
576
+ await this.deps.inbound?.admit(userID, from, to.sessionID, kind, original)
577
+ this.gate(userID, to.sessionID)
578
+ }
579
+ if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
496
580
  if (to) await this.limiter.admit(userID, from, to)
497
- const hop = await this.hops.next(userID, from)
581
+ const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
498
582
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
499
583
  const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
500
584
  const { message } = answered
501
- await this.hops.received(userID, message.recipient, hop)
585
+ await this.hops.received(userID, message.recipient, hop, from)
502
586
  this.users.add(userID)
503
587
  if (message.recipient.kind !== "session") return answered
504
588
  const sessionID = message.recipient.sessionID
505
- const wake = await this.wakeFor(userID, message, options.wake !== false && !this.isSubagent(userID, sessionID))
506
- return { ...(await this.deliver(userID, message, sessionID, wake, true)), original: answered.original }
589
+ const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
590
+ const skipped = await this.wakeFor(userID, message, unwanted)
591
+ return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
507
592
  }
508
593
 
509
- /** Whether the message may start a turn: wanted, under the hop cap (alerting once at the cap), wake budget left. */
510
- private async wakeFor(userID: string, message: Message, wanted: boolean): Promise<boolean> {
511
- if (!wanted || message.recipient.kind !== "session") return false
594
+ /**
595
+ * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
596
+ * the hop cap (alerting once at the cap), or no wake budget left.
597
+ */
598
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
599
+ if (unwanted) return unwanted
600
+ if (message.recipient.kind !== "session") return "wake_false"
512
601
  if (overHopCap(message.hop)) {
513
602
  if (message.hop === HOP_CAP + 1) {
514
603
  this.deps.alert?.(userID, {
515
604
  kind: "hopCap",
516
605
  sessionID: message.recipient.sessionID,
517
606
  messageID: message.id,
518
- text: `Sessions messaged each other ${HOP_CAP} times without you; further messages no longer wake them`,
607
+ text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
519
608
  })
520
609
  }
521
- return false
610
+ return "hop_cap"
522
611
  }
523
- return this.limiter.wake(userID, message.recipient)
612
+ return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
524
613
  }
525
614
 
526
615
  /** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
@@ -558,13 +647,18 @@ export class Delivery {
558
647
  }
559
648
 
560
649
  /** Delivers now, or queues the message as pending when no spoke takes it. */
561
- private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
650
+ private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
651
+ const wake = skipped === undefined
652
+ const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
653
+ // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
654
+ // one starts then carries them, instead of them arriving inside it.
655
+ if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
562
656
  const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
563
- if (delivered) return delivered
657
+ if (delivered) return { ...delivered, ...woke }
564
658
  const at = this.now()
565
659
  const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
566
660
  await this.deps.state.put(userID, PENDING, message.id, pending)
567
- return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
661
+ return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
568
662
  }
569
663
 
570
664
  /** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
@@ -593,13 +687,25 @@ export class Delivery {
593
687
  return undefined
594
688
  }
595
689
 
596
- private async flushNow(userID: string): Promise<void> {
597
- const entries = (await this.deps.state.list<Pending>(userID, PENDING)).sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
690
+ private async flushNow(userID: string, only?: string): Promise<void> {
691
+ const entries = (await this.deps.state.list<Pending>(userID, PENDING))
692
+ .filter((e) => only === undefined || e.value.sessionID === only)
693
+ .sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
598
694
  /** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
599
695
  const leases = new Map<string, boolean>()
696
+ /** Sessions whose delivery failed in this flush: their later messages wait for the next frame (in order). */
697
+ const failed = new Set<string>()
600
698
  try {
601
699
  for (const { key, value, version } of entries) {
602
- const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
700
+ if (failed.has(value.sessionID)) {
701
+ this.flushStats.skipped++
702
+ continue
703
+ }
704
+ if (value.expiresAt <= this.now()) {
705
+ await this.expireEntry(userID, key, value, version)
706
+ continue
707
+ }
708
+ const message = await this.messages.get(userID, value.messageID)
603
709
  // Gone, or an ask that closed (expired) while it waited: nothing to deliver.
604
710
  if (!message || (message.kind === "ask" && message.state !== "open")) {
605
711
  await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
@@ -610,6 +716,10 @@ export class Delivery {
610
716
  // Another process may have delivered it before this one took the lease.
611
717
  if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
612
718
  if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
719
+ else {
720
+ failed.add(value.sessionID)
721
+ this.flushStats.failed++
722
+ }
613
723
  }
614
724
  } finally {
615
725
  const { locks } = this.deps
@@ -619,6 +729,46 @@ export class Delivery {
619
729
  }
620
730
  }
621
731
 
732
+ /** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
733
+ private async expirePending(userID: string): Promise<void> {
734
+ const t = this.now()
735
+ for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
736
+ if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
737
+ }
738
+ }
739
+
740
+ /**
741
+ * A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
742
+ * told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
743
+ * journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
744
+ */
745
+ private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
746
+ if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
747
+ const current = await this.messages.get(userID, value.messageID)
748
+ if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
749
+ const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
750
+ const what = `${message.kind} ${message.id} to ${message.to}`
751
+ try {
752
+ this.deps.expired?.(userID, message)
753
+ } catch (cause) {
754
+ console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
755
+ }
756
+ const { from } = message
757
+ if (from.kind === "user") {
758
+ this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: its session stayed offline or busy for 24 h` })
759
+ } else if (from.kind === "session") {
760
+ const id = this.newID()
761
+ await this.request(userID, from.sessionID, {
762
+ op: "deliver",
763
+ sessionID: from.sessionID,
764
+ messageID: id,
765
+ text: `[hub] notice · ${what} was not delivered: its session stayed offline or busy for 24 h · ${id}`,
766
+ wake: false,
767
+ meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
768
+ })
769
+ }
770
+ }
771
+
622
772
  /** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
623
773
  private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
624
774
  const { locks } = this.deps
@@ -635,7 +785,11 @@ export class Delivery {
635
785
  return held
636
786
  }
637
787
 
638
- /** `steer`: urgent, or the first msg after a primer to an idle session (see attempt). */
788
+ /**
789
+ * `steer`: urgent, or the first msg after a primer to an idle session (see attempt). A wake:false msg keeps
790
+ * "queue" unless urgent: that marks it routine, and the OpenCode spoke (RF-05) admits it steered without resume,
791
+ * refusing it while a turn runs so it stays pending here until the turn ends (never a model call of its own).
792
+ */
639
793
  private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
640
794
  const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
641
795
  return {
@@ -3,7 +3,7 @@
3
3
  // and every catalog change syncs the affected users (records, lastSeen, derived names).
4
4
  //
5
5
  // Routes (user actors always; session tokens may read, and write for their OWN session only):
6
- // GET /api/agent/who?project=&role=
6
+ // GET /api/agent/who?project=&role= (entries carry a stored `intent`, CON-02)
7
7
  // POST /api/agent/name {name, session?}
8
8
  // POST /api/agent/roles/claim {role, exclusive?, project?, session?}
9
9
  // POST /api/agent/roles/release {role, project?, session?}
@@ -16,6 +16,7 @@
16
16
  // order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
17
17
  // roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
18
18
  // journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
19
+ import { boundedJson } from "./body"
19
20
  import type { ActingSwitch } from "./acting-switch"
20
21
  import { auditOf } from "./acting"
21
22
  import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
@@ -31,6 +32,10 @@ import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
31
32
  import { slugify } from "./names"
32
33
  import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
33
34
  import type { CursorKeys } from "./event-cursor"
35
+ import type { TeamDiscovery } from "../teams/discovery"
36
+ import type { SessionIntent } from "../../protocol"
37
+ import { intentField } from "./intents"
38
+ import { conciergeSelf } from "./read-routes"
34
39
 
35
40
  /** What the directory reads from the Registry. */
36
41
  export interface DirectoryRegistry {
@@ -58,10 +63,11 @@ export function registrySource(registry: Pick<DirectoryRegistry, "catalog" | "se
58
63
  if (out.has(s.id) || registry.sessionTenant(s.id) !== userID) continue
59
64
  const project = projectOf(s)
60
65
  const scope = project ? { project } : {}
61
- out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state })
66
+ const harness = process.harness ? { harness: process.harness } : {}
67
+ out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state, ...harness })
62
68
  for (const child of s.status) {
63
69
  if (child.id === s.id || out.has(child.id)) continue
64
- out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state })
70
+ out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state, ...harness })
65
71
  }
66
72
  }
67
73
  }
@@ -104,7 +110,7 @@ export function hubDirectory(options: { registry: DirectoryRegistry; store: Pick
104
110
  type Body = Record<string, unknown>
105
111
 
106
112
  async function readBody(request: Request): Promise<Body> {
107
- const body = await request.json()
113
+ const body = await boundedJson(request)
108
114
  if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object")
109
115
  return body as Body
110
116
  }
@@ -152,6 +158,12 @@ export interface DirectoryRoutesDeps {
152
158
  readonly acting?: Pick<ActingSwitch, "ensureActing">
153
159
  readonly audit?: Pick<AgentAudit, "record">
154
160
  readonly previewKeys?: CursorKeys
161
+ /** Team-aware who (HWA-40); without it, the owner-wide directory. */
162
+ readonly discovery?: TeamDiscovery
163
+ /** (CON-02) The stored intents of these sessions: who entries carry `intent` when present. */
164
+ readonly intents?: (userID: string, sessionIDs: readonly string[]) => Promise<ReadonlyMap<string, SessionIntent>>
165
+ /** (CON-04) The user's Concierge sessions: who marks them `concierge: true`; archive-inactive never archives them. */
166
+ readonly concierge?: (userID: string) => Promise<readonly string[]>
155
167
  }
156
168
 
157
169
  const MANAGE = { session: true, scope: "sessions:manage" } as const
@@ -202,7 +214,10 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
202
214
  const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
203
215
  const body = await readBody(context.request)
204
216
  const prefs = store.viewPrefs(context.user.id)
205
- const entries = (store.agentData(context.user.id).sessions ?? []).map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
217
+ const concierge = new Set(deps.concierge ? await deps.concierge(context.user.id) : [])
218
+ const entries = (store.agentData(context.user.id).sessions ?? [])
219
+ .filter((s) => !concierge.has(s.sessionID))
220
+ .map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
206
221
  if (body.preview === true) {
207
222
  const limit = body.limit
208
223
  if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
@@ -261,10 +276,20 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
261
276
  path: `${AGENT_PREFIX}/who`,
262
277
  // Reading the directory: sessions, and app tokens holding sessions:read; writes stay users and sessions.
263
278
  allow: { session: true, scope: "sessions:read" },
264
- handle: ({ url, user }) => {
279
+ handle: async ({ url, user, actor }) => {
265
280
  const project = url.searchParams.get("project") || undefined
266
281
  const role = url.searchParams.get("role") || undefined
267
- return ok({ sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) })
282
+ const team = url.searchParams.get("team") || undefined
283
+ const answer = deps.discovery
284
+ ? deps.discovery.who(await deps.discovery.census(user.id, actor), { ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}) })
285
+ : { sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) }
286
+ if (!answer.sessions.length || (!deps.intents && !deps.concierge)) return ok(answer)
287
+ // (CON-09a) The Concierge's own who leaves out its sessions (current and previous).
288
+ const self = await conciergeSelf(deps.concierge, user.id, actor)
289
+ const shown = self.size ? answer.sessions.filter((entry) => !self.has(entry.sessionID)) : answer.sessions
290
+ const intents = deps.intents ? await deps.intents(user.id, shown.map((entry) => entry.sessionID)) : new Map<string, SessionIntent>()
291
+ const concierge = new Set(deps.concierge ? await deps.concierge(user.id) : [])
292
+ return ok({ ...answer, sessions: shown.map((entry) => ({ ...entry, ...intentField(intents.get(entry.sessionID)), ...(concierge.has(entry.sessionID) ? { concierge: true } : {}) })) })
268
293
  },
269
294
  },
270
295
  {
@@ -291,6 +316,11 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
291
316
  const exclusive = flag(body, "exclusive")
292
317
  const project = text(body, "project")
293
318
  const sessionID = target(directory, context, text(body, "session"))
319
+ // (CON-04) A Concierge session holds @@concierge only (claimed by the hub): no other role, for anyone, so it
320
+ // never inherits another role's grants.
321
+ if (deps.concierge && (await deps.concierge(context.user.id)).includes(sessionID)) {
322
+ throw new AgentError("forbidden", "The Concierge holds no role but @@concierge", { extra: { policy: "concierge" } })
323
+ }
294
324
  const claim = await roleWrite(context, "roles", sessionID, async () => {
295
325
  const before = directory.resolveOne(context.user.id, sessionID).roles
296
326
  const claim = await directory.claimRole(context.user.id, sessionID, role, {