@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
@@ -3,12 +3,14 @@
3
3
  //
4
4
  // Routes (user actors always; session tokens and app tokens holding sessions:read):
5
5
  // GET /api/agent/status
6
- // GET /api/agent/sessions?project=&state=&machine=&label=k=v
6
+ // GET /api/agent/sessions?project=&state=&machine=&label=k=v&q= (CON-11) q: every word in the title or @name
7
7
  // GET /api/agent/sessions/:addr show: the catalog view plus the spoke's summary (when it serves one)
8
8
  // GET /api/agent/sessions/:addr/log?n=20 the newest timeline rows (spoke feature "timeline")
9
9
  // GET /api/agent/watch?blocked=1&session=<addr> SSE of compact events (watch.ts)
10
10
  // show and log call spokes; a spoke without the feature answers unsupported {feature, harness}.
11
11
  // A session a launch started carries that launch's labels (the latest launch's, when several did); others have none.
12
+ // (CON-02) /sessions and /sessions/:addr entries carry `intent {text, source, revision}` when one is stored (untrusted
13
+ // text; the title fallback is only on GET /sessions/:addr/intent, intent-routes.ts).
12
14
  import path from "node:path"
13
15
  import { featureForOp, type ServerRequest, type SpokeFeature } from "../../protocol"
14
16
  import type { CatalogMachine, CatalogSession, LiveStatus, NowActivity, RegistryMessage, SessionView } from "../registry"
@@ -21,9 +23,14 @@ import { AgentError } from "./errors"
21
23
  import { AGENT_PREFIX, type AgentRoute } from "./routes"
22
24
  import { watchStream } from "./watch"
23
25
  import type { EventsRouteDeps } from "./events-routes"
26
+ import { type Census, type TeamDiscovery, visible } from "../teams/discovery"
27
+ import type { SessionIntent } from "../../protocol"
28
+ import { intentField } from "./intents"
24
29
 
25
30
  /** Labels per session ID. */
26
31
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
32
+ /** (CON-02) Stored intents per session ID. */
33
+ export type SessionIntentMap = ReadonlyMap<string, SessionIntent>
27
34
 
28
35
  /** What the read routes need from the Registry. */
29
36
  export interface ReadRegistry {
@@ -32,6 +39,8 @@ export interface ReadRegistry {
32
39
  session(sessionID: string): SessionView | undefined
33
40
  owner(sessionID: string): string | undefined
34
41
  supports(connID: string, feature: SpokeFeature): boolean
42
+ /** (RP-08) A connection's harness (Registry implements it). */
43
+ harnessOf?(connID: string): string | undefined
35
44
  subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
36
45
  }
37
46
 
@@ -47,6 +56,8 @@ export interface ReadDeps {
47
56
  readonly spokes?: SpokeRequester
48
57
  /** The labels of the user's launched sessions (none without it). */
49
58
  readonly labels?: (userID: string) => Promise<SessionLabels>
59
+ /** (CON-02) The stored intents of these sessions: GET /sessions and /sessions/:addr carry `intent` when present. */
60
+ readonly intents?: (userID: string, sessionIDs: readonly string[]) => Promise<SessionIntentMap>
50
61
  /** The journal GET /api/agent/watch streams (no watch route without it). */
51
62
  readonly events?: EventsRouteDeps
52
63
  /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
@@ -54,6 +65,10 @@ export interface ReadDeps {
54
65
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
55
66
  readonly draining?: AbortSignal
56
67
  readonly now?: () => number
68
+ /** Team-aware discovery (HWA-40): team members see their teams; invisible sessions read as unknown. */
69
+ readonly discovery?: TeamDiscovery
70
+ /** (CON-04) The user's Concierge sessions (current and hidden): their entries carry `concierge: true`. */
71
+ readonly concierge?: (userID: string) => Promise<readonly string[]>
57
72
  }
58
73
 
59
74
  /** One session in agent answers (status, sessions). */
@@ -168,8 +183,9 @@ function you(actor: AgentActor, entries: Map<string, DirectoryEntry>) {
168
183
  }
169
184
 
170
185
  /** The status snapshot of one user (design §4): machines → projects → sessions, and what needs attention. */
171
- export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS) {
172
- const list = rows(deps.registry, userID)
186
+ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS, census?: Census, exclude: ReadonlySet<string> = NONE) {
187
+ const list = rows(deps.registry, userID).filter((r) => (!census || visible(census, r.session.id)) && !exclude.has(r.session.id))
188
+ const teams = census && deps.discovery ? deps.discovery.groups(census, list.map((r) => r.session.id)) : []
173
189
  const entries = directoryOf(deps.directory, userID)
174
190
  const online = new Map(deps.registry.catalog().map((m) => [m.id, m]))
175
191
  const machines = deps.store
@@ -193,12 +209,12 @@ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labe
193
209
  projects: [...projects.values()],
194
210
  }
195
211
  })
196
- return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries) }
212
+ return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries), ...(teams.length ? { teams } : {}) }
197
213
  }
198
214
 
199
215
  /** The session an address names for the user, if the user's spokes report it now (else offline). */
200
- function located(deps: ReadDeps, userID: string, addr: string) {
201
- const entry = deps.directory.resolveOne(userID, addr)
216
+ function located(deps: ReadDeps, userID: string, addr: string, census?: Census) {
217
+ const entry = census && deps.discovery ? deps.discovery.resolveOne(census, addr) : deps.directory.resolveOne(userID, addr)
202
218
  const live = deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
203
219
  const list = rows(deps.registry, userID)
204
220
  const row = list.find((r) => r.session.id === entry.sessionID) ?? (live && list.find((r) => r.session.id === live.status[0]?.id))
@@ -226,9 +242,13 @@ async function ask(deps: ReadDeps, connID: string, req: ServerRequest): Promise<
226
242
  }
227
243
 
228
244
  /** GET /sessions/:addr: the session's catalog view with full blockers, and its summary from the spoke. */
229
- async function show(deps: ReadDeps, userID: string, addr: string) {
230
- const { entry, live, row } = located(deps, userID, addr)
245
+ async function show(deps: ReadDeps, userID: string, addr: string, census?: Census) {
246
+ const { entry, live, row } = located(deps, userID, addr, census)
247
+ const memberships = census && deps.discovery ? deps.discovery.memberships(census, entry.sessionID) : []
248
+ const teams = memberships.length ? { teams: memberships } : {}
231
249
  const labels = { ...(await labelsOf(deps, userID)).get(entry.sessionID) }
250
+ const intent = intentField((await intentsOf(deps, userID, [entry.sessionID])).get(entry.sessionID))
251
+ const marker = (await conciergeOf(deps, userID)).has(entry.sessionID) ? { concierge: true } : {}
232
252
  const name = entry.name ? { name: `@${entry.name}` } : {}
233
253
  const roles = entry.roles.map(roleLabel)
234
254
  if (!live) {
@@ -245,6 +265,9 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
245
265
  ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}),
246
266
  blockers: [],
247
267
  labels,
268
+ ...intent,
269
+ ...marker,
270
+ ...teams,
248
271
  }
249
272
  }
250
273
  const harness = row?.harness ?? "unknown"
@@ -266,6 +289,9 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
266
289
  blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
267
290
  children: live.status.filter((e) => e.parentID === live.id).length,
268
291
  labels,
292
+ ...intent,
293
+ ...marker,
294
+ ...teams,
269
295
  }
270
296
  try {
271
297
  const connID = spokeFor(deps, live.id, "summary", harness)
@@ -279,23 +305,55 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
279
305
  }
280
306
 
281
307
  /** GET /sessions/:addr/log: the newest `n` timeline rows, newest first. */
282
- async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null) {
308
+ async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null, census?: Census) {
283
309
  const n = raw === null ? 20 : Number(raw)
284
310
  if (!Number.isInteger(n) || n < 1 || n > 200) throw new AgentError("invalid", "n must be 1–200")
285
- const { entry, live, row } = located(deps, userID, addr)
311
+ const { entry, live, row } = located(deps, userID, addr, census)
286
312
  if (!live) throw new AgentError("offline", "No spoke reports this session now")
287
313
  const connID = spokeFor(deps, entry.sessionID, "timeline", row?.harness ?? "unknown")
288
314
  const page = (await ask(deps, connID, { op: "timeline", sessionID: entry.sessionID, limit: n, order: "desc" })) as { items?: unknown[]; older?: string }
289
315
  return { sessionID: entry.sessionID, rows: page.items ?? [], ...(page.older ? { older: page.older } : {}) }
290
316
  }
291
317
 
318
+ const censusOf = (deps: ReadDeps, userID: string, actor: AgentActor): Promise<Census | undefined> => deps.discovery?.census(userID, actor) ?? Promise.resolve(undefined)
319
+
292
320
  const labelsOf = (deps: ReadDeps, userID: string): Promise<SessionLabels> => deps.labels?.(userID) ?? Promise.resolve(NO_LABELS)
293
321
 
322
+ const NO_INTENTS: SessionIntentMap = new Map()
323
+ /** (CON-04) The user's Concierge sessions (none without the dep). */
324
+ async function conciergeOf(deps: ReadDeps, userID: string): Promise<ReadonlySet<string>> {
325
+ return new Set(deps.concierge ? await deps.concierge(userID) : [])
326
+ }
327
+
328
+ const NONE: ReadonlySet<string> = new Set()
329
+ /**
330
+ * (CON-09a) What a Concierge session's own reads leave out: every Concierge session (current and previous), so it
331
+ * never reports itself as one of the user's sessions ("Greeting is working"). Anyone else: nothing.
332
+ */
333
+ export async function conciergeSelf(concierge: ((userID: string) => Promise<readonly string[]>) | undefined, userID: string, actor: AgentActor): Promise<ReadonlySet<string>> {
334
+ if (actor.kind !== "session" || !concierge) return NONE
335
+ const all = new Set(await concierge(userID))
336
+ return all.has(actor.sessionID) ? all : NONE
337
+ }
338
+
339
+ const intentsOf = (deps: ReadDeps, userID: string, sessionIDs: readonly string[]): Promise<SessionIntentMap> =>
340
+ deps.intents && sessionIDs.length ? deps.intents(userID, sessionIDs) : Promise.resolve(NO_INTENTS)
341
+
294
342
  const STATES = new Set(["idle", "working", "blocked", "error"])
295
343
 
296
344
  /** The /sessions filters: project (name or root), state (comma list), machine (ID or name), label k=v. */
345
+ /** (CON-11) The longest `q` (characters). */
346
+ const QUERY_MAX = 200
347
+ /** Search words: lower case, letters and digits only (an @, dashes and punctuation separate words). */
348
+ const words = (text: string) => text.toLowerCase().normalize("NFKD").replace(/\p{M}/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean)
349
+
297
350
  function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams): (row: Row, view: AgentSessionView) => boolean {
298
351
  const project = params.get("project") || undefined
352
+ const q = params.get("q") ?? ""
353
+ if (q.length > QUERY_MAX) throw new AgentError("invalid", `q is at most ${QUERY_MAX} characters`)
354
+ const wanted = words(q)
355
+ // (FX-07) A q with no letters or digits ("@", "«»", "?") names no word: it matches nothing (never every session).
356
+ const nothing = q.trim() !== "" && wanted.length === 0
299
357
  const states = params.get("state")?.split(",").filter(Boolean)
300
358
  if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
301
359
  const machine = params.get("machine") || undefined
@@ -306,10 +364,15 @@ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams):
306
364
  return [label.slice(0, at), label.slice(at + 1)] as const
307
365
  })
308
366
  return (row, view) => {
367
+ if (nothing) return false
309
368
  const { root, name } = projectOf(row.session)
310
369
  if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
311
370
  if (states?.length && !states.includes(view.state)) return false
312
371
  if (machineIDs && !machineIDs.has(row.machineID)) return false
372
+ if (wanted.length) {
373
+ const have = [...words(view.title ?? ""), ...words(view.name ?? "")]
374
+ if (!wanted.every((w) => have.some((h) => h.includes(w)))) return false
375
+ }
313
376
  return labels.every(([key, value]) => view.labels[key] === value)
314
377
  }
315
378
  }
@@ -324,20 +387,25 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
324
387
  method: "GET",
325
388
  path: `${AGENT_PREFIX}/status`,
326
389
  allow: READ,
327
- handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id))),
390
+ handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id), await censusOf(deps, user.id, actor), await conciergeSelf(deps.concierge, user.id, actor))),
328
391
  },
329
392
  {
330
393
  method: "GET",
331
394
  path: `${AGENT_PREFIX}/sessions`,
332
395
  allow: READ,
333
- handle: async ({ user, url }) => {
396
+ handle: async ({ user, url, actor }) => {
334
397
  const keep = sessionFilter(deps, user.id, url.searchParams)
398
+ const census = await censusOf(deps, user.id, actor)
335
399
  const entries = directoryOf(deps.directory, user.id)
336
400
  const labels = await labelsOf(deps, user.id)
337
- const sessions = rows(deps.registry, user.id)
401
+ const self = await conciergeSelf(deps.concierge, user.id, actor)
402
+ const listed = rows(deps.registry, user.id)
338
403
  .map((row) => ({ row, view: view(row, entries.get(row.session.id), labels) }))
339
- .filter(({ row, view }) => keep(row, view))
340
- .map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name }))
404
+ .filter(({ row, view }) => keep(row, view) && (!census || visible(census, row.session.id)) && !self.has(row.session.id))
405
+ // Only the listed sessions' intents are read (never the whole collection).
406
+ const intents = await intentsOf(deps, user.id, listed.map(({ row }) => row.session.id))
407
+ const concierge = await conciergeOf(deps, user.id)
408
+ const sessions = listed.map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name, ...intentField(intents.get(row.session.id)), ...(concierge.has(row.session.id) ? { concierge: true } : {}) }))
341
409
  return ok({ sessions })
342
410
  },
343
411
  },
@@ -345,13 +413,13 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
345
413
  method: "GET",
346
414
  path: `${AGENT_PREFIX}/sessions/:addr`,
347
415
  allow: READ,
348
- handle: async ({ user, params }) => ok(await show(deps, user.id, params.addr!)),
416
+ handle: async ({ user, params, actor }) => ok(await show(deps, user.id, params.addr!, await censusOf(deps, user.id, actor))),
349
417
  },
350
418
  {
351
419
  method: "GET",
352
420
  path: `${AGENT_PREFIX}/sessions/:addr/log`,
353
421
  allow: READ,
354
- handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
422
+ handle: async ({ user, params, url, actor }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"), await censusOf(deps, user.id, actor))),
355
423
  },
356
424
  ...(deps.events ? [watchRoute(deps, deps.events)] : []),
357
425
  ]
@@ -363,9 +431,11 @@ function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
363
431
  method: "GET",
364
432
  path: `${AGENT_PREFIX}/watch`,
365
433
  allow: READ,
366
- handle: ({ user, url, request, actor }) => {
434
+ handle: async ({ user, url, request, actor }) => {
367
435
  const addr = url.searchParams.get("session") || undefined
368
- const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
436
+ const census = addr ? await censusOf(deps, user.id, actor) : undefined
437
+ const resolved = addr ? (census && deps.discovery ? deps.discovery.resolve(census, addr) : deps.directory.resolve(user.id, addr)) : undefined
438
+ const sessions = resolved ? new Set(resolved.sessions.map((s) => s.sessionID)) : undefined
369
439
  const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
370
440
  const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
371
441
  return watchStream({
@@ -8,7 +8,7 @@
8
8
  // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
9
  import type { AgentEvents } from "./events"
10
10
  import type { CursorKeys } from "./event-cursor"
11
- import { eventsRoutes } from "./events-routes"
11
+ import { eventsRoutes, type EventsRouteDeps } from "./events-routes"
12
12
  import type { DeviceRecord, Store, UserRecord } from "../store"
13
13
  import type { Directory } from "./directory"
14
14
  import { directoryRoutes } from "./directory-routes"
@@ -30,6 +30,36 @@ import { actingRoutes } from "./acting-routes"
30
30
  import type { Actions } from "../actions"
31
31
  import { launchRoutes, type RouteLaunches } from "./launch-routes"
32
32
  import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
33
+ import { pinAuthorizer } from "../profiles/apply"
34
+ import { profileRoutes } from "../profiles/routes"
35
+ import { ProfileUsage } from "../profiles/usage"
36
+ import { usagePorts } from "../profiles/usage-ports"
37
+ import type { ProfileStore } from "../profiles/store"
38
+ import { workspaceRoutes, type WorkspaceRoutesDeps } from "../workspaces/routes"
39
+ import { boardRoutes, registryBoardSessions } from "../workspaces/board"
40
+ import { type CheckpointStore, checkpointRoutes } from "../workspaces/checkpoints"
41
+ import { masterRoutes } from "../workspaces/master"
42
+ import { routePorts, sideChatRoutes } from "../workspaces/sidechats-routes"
43
+ import { type ArtifactRouteDeps, artifactRoutes } from "../artifacts/routes"
44
+ import { artifactJobRoutes, jobPorts } from "../artifacts/jobs-routes"
45
+ import { type ArtifactHostRouteDeps, artifactHostRoutes } from "../artifacts/host"
46
+ import { type GraphRouteDeps, graphRoutes } from "./interaction-graph-routes"
47
+ import type { TeamIntakeService } from "../teams/intake"
48
+ import { intakeRoutes } from "../teams/intake-routes"
49
+ import { eventVisibility, type TeamDiscovery } from "../teams/discovery"
50
+ import { teamRoutes, type TeamRoutesDeps } from "../teams/routes"
51
+ import type { StateStore } from "../backend/types"
52
+ import type { ActivityTracker } from "../concierge/activity"
53
+ import { boardRoutes as conciergeBoardRoutes } from "../concierge/board-routes"
54
+ import type { ResultTracker } from "../concierge/board-results"
55
+ import { type ConciergeIdentity, watchlistRoutes } from "../concierge/watchlist-routes"
56
+ import { conciergeRoutes, type MachineModel } from "../concierge/routes"
57
+ import { guardConciergeRoutes } from "../concierge/guard"
58
+ import { isConciergeLaunch } from "../concierge/supervisor"
59
+ import type { ConciergeService } from "../concierge/service"
60
+ import type { SessionIntent } from "../../protocol"
61
+ import type { SessionIntents } from "./intents"
62
+ import { intentRoutes } from "./intent-routes"
33
63
 
34
64
  export const AGENT_PREFIX = "/api/agent"
35
65
 
@@ -90,6 +120,22 @@ export interface AgentDeps {
90
120
  readonly launches?: RouteLaunches
91
121
  /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
92
122
  readonly webhooks?: RouteWebhooks
123
+ /** Profiles (HWA-14): the /api/agent/profiles and /sessions/:addr/profile routes when given with the directory. */
124
+ readonly profiles?: ProfileStore
125
+ /** Workspaces (HWA-06): the /api/agent/workspaces routes when given. */
126
+ readonly workspaces?: Pick<WorkspaceRoutesDeps, "workspaces" | "members" | "supervision" | "lifecycle">
127
+ /** Supervisor checkpoints (HWA-11): POST/GET /api/agent/workspaces/:id/checkpoints when given. */
128
+ readonly workspaceCheckpoints?: CheckpointStore
129
+ /** Team intake (HWA-36): /api/agent/teams/:id/intake when given. */
130
+ readonly teamIntake?: TeamIntakeService
131
+ /** Teams (HWA-37): /api/agent/teams definitions, staffing and status when given (the hub's single instances). */
132
+ readonly teams?: Pick<TeamRoutesDeps, "teams" | "seats" | "staffing">
133
+ /** Persistent artifacts (HWA-21): /api/agent/artifacts; without a store the routes answer 422 unsupported. */
134
+ readonly artifacts?: ArtifactRouteDeps
135
+ /** Isolated artifact host (HWA-26): mint/revoke capability URLs for html/app revisions when given. */
136
+ readonly artifactHost?: ArtifactHostRouteDeps
137
+ /** Interaction graph (HWA-31) team overlay; the graph routes are added with the directory, registry and delivery. */
138
+ readonly graph?: Pick<GraphRouteDeps, "teams">
93
139
  /** The canonical agent event journal: archive changes are appended to it. */
94
140
  readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
95
141
  /** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
@@ -105,6 +151,34 @@ export interface AgentDeps {
105
151
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
106
152
  readonly draining?: AbortSignal
107
153
  readonly now?: () => number
154
+ /** Team-aware discovery (HWA-40): who and the read routes scope team members to their teams. */
155
+ readonly teamDiscovery?: TeamDiscovery
156
+ /**
157
+ * Concierge (CON-01): the /api/agent/concierge/watchlist routes when given with the directory and registry.
158
+ * `identity` is the user's current Concierge session (CON-04); without it no session token is admitted.
159
+ */
160
+ readonly concierge?: {
161
+ readonly state: StateStore
162
+ readonly activity: Pick<ActivityTracker, "list">
163
+ readonly identity?: ConciergeIdentity
164
+ readonly results?: Pick<ResultTracker, "many">
165
+ /** (CON-04) Sessions only the user acts on (the Concierge's): the act verbs refuse agents on them. */
166
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
167
+ /** (CON-04) The Concierge service: /api/agent/concierge settings, enable/disable/restart and notes. */
168
+ readonly service?: ConciergeService
169
+ /** (FX-04) A machine's OpenCode model list (routes.ts machineModels): GET …/concierge/machines/:id/models. */
170
+ readonly models?: (machineID: string) => Promise<MachineModel[]>
171
+ /** (CON-06 and later) More Concierge routes (the tiers slot). */
172
+ readonly routes?: () => AgentRoute[]
173
+ /** (CON-06) The Decision armed now by the current Concierge (the board's `armed` flag). */
174
+ readonly armed?: (userID: string) => Promise<string | undefined>
175
+ /** (CON-10) The user's time zone (Concierge settings): the rendered board's time is local. */
176
+ readonly timeZone?: (userID: string) => Promise<string | undefined>
177
+ /** (CON-13) The board's inGrant (board-routes.ts BoardRouteDeps.coverage). */
178
+ readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
179
+ }
180
+ /** (CON-02) Session intents: GET/PUT /api/agent/sessions/:addr/intent with the directory; reads carry `intent`. */
181
+ readonly intents?: SessionIntents
108
182
  }
109
183
 
110
184
  /**
@@ -125,11 +199,12 @@ const userView = (user: UserRecord) => ({
125
199
  /** The built-in /api/agent routes. */
126
200
  export function agentRoutes(deps: AgentDeps): AgentRoute[] {
127
201
  const feed = deps.feed ?? new MessageFeed()
128
- return [
129
- ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
130
- ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
202
+ const routes: AgentRoute[] = [
203
+ ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
204
+ ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
205
+ ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
131
206
  ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
132
- ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
207
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
133
208
  {
134
209
  method: "GET",
135
210
  path: `${AGENT_PREFIX}/whoami`,
@@ -145,7 +220,83 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
145
220
  ? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
146
221
  : []),
147
222
  ...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
223
+ ...(deps.profiles && deps.directory
224
+ ? profileRoutes({
225
+ profiles: deps.profiles,
226
+ directory: deps.directory,
227
+ audit: deps.store,
228
+ ...(deps.agentEvents ? { events: deps.agentEvents } : {}),
229
+ // HWA-15: an app's pin also needs a usable prompt grant on the session.
230
+ ...(deps.grants && deps.registry ? { authorizePin: pinAuthorizer({ registry: deps.registry, grants: deps.grants, ...(deps.acting ? { acting: deps.acting } : {}) }) } : {}),
231
+ // RP-08: where a profile is used (pins, team roles, launches, workspaces).
232
+ usage: new ProfileUsage({
233
+ profiles: deps.profiles,
234
+ ports: usagePorts({
235
+ directory: deps.directory,
236
+ ...(deps.registry ? { registry: deps.registry } : {}),
237
+ ...(deps.teams ? { teams: { teams: deps.teams.teams, seats: deps.teams.seats } } : {}),
238
+ ...(deps.launches ? { launches: deps.launches } : {}),
239
+ ...(deps.workspaces ? { workspaces: { workspaces: deps.workspaces.workspaces, members: deps.workspaces.members } } : {}),
240
+ }),
241
+ }),
242
+ })
243
+ : []),
244
+ ...(deps.workspaces ? workspaceRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
245
+ ...(deps.workspaces ? boardRoutes({ ...deps.workspaces, ...(deps.directory && deps.registry ? { sessions: registryBoardSessions(deps.directory, deps.registry) } : {}), ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.artifacts?.artifacts ? { artifacts: deps.artifacts.artifacts } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
246
+ ...(deps.workspaceCheckpoints ? checkpointRoutes({ checkpoints: deps.workspaceCheckpoints, audit: deps.store }) : []),
247
+ ...(deps.workspaces ? masterRoutes({ ...deps.workspaces, audit: deps.store, ...(deps.workspaceCheckpoints ? { checkpoints: deps.workspaceCheckpoints } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
248
+ ...artifactRoutes(deps.artifacts ?? {}),
249
+ ...(deps.artifactHost ? artifactHostRoutes(deps.artifactHost) : []),
250
+ ...(deps.directory && deps.registry && deps.delivery ? graphRoutes({ store: deps.store, registry: deps.registry, directory: deps.directory, messages: deps.delivery.messages, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.launches ? { launches: deps.launches } : {}), ...(deps.workspaces ? { workspaces: deps.workspaces } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...deps.graph, ...(deps.now ? { now: deps.now } : {}), ...conciergeReads(deps), ...conciergeCurrent(deps) }) : []),
251
+ ...(deps.teamIntake ? intakeRoutes({ intake: deps.teamIntake, audit: deps.store }) : []),
252
+ ...(deps.teams ? teamRoutes({ ...deps.teams, ...(deps.teamIntake ? { intake: deps.teamIntake } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}) }) : []),
253
+ ...(deps.concierge && deps.directory && deps.registry
254
+ ? watchlistRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...launchReads(deps), ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })
255
+ : []),
256
+ // CON-03: the situation board and the Concierge's Decision reads (users and the current Concierge only).
257
+ ...(deps.concierge && deps.directory && deps.registry
258
+ ? conciergeBoardRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.intents ? { intents: deps.intents } : {}), ...launchReads(deps), ...(deps.now ? { now: deps.now } : {}) })
259
+ : []),
260
+ // CON-04: settings, enable/disable/restart and notes; CON-06's routes through the tiers slot.
261
+ ...(deps.concierge?.service && deps.concierge.identity ? conciergeRoutes({ service: deps.concierge.service, identity: deps.concierge.identity, ...(deps.concierge.models ? { models: deps.concierge.models } : {}) }) : []),
262
+ ...(deps.concierge?.routes ? deps.concierge.routes() : []),
148
263
  ]
264
+ // HWA-13: side chats read, launch and message through the routes above, as the caller.
265
+ const { registry } = deps
266
+ const sourceUpdatedAt = registry ? (userID: string, id: string) => (registry.sessionTenant(id) === userID ? registry.session(id)?.updated : undefined) : undefined
267
+ // HWA-24: artifact AI jobs start their handler through the launch and message routes above, as the requester.
268
+ const a = deps.artifacts
269
+ routes.push(...artifactJobRoutes({ ...(a?.artifacts ? { artifacts: a.artifacts } : {}), ...(a?.state ? { state: a.state } : {}), ports: jobPorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) }))
270
+ const all = deps.workspaces ? [...routes, ...sideChatRoutes({ workspaces: deps.workspaces.workspaces, ports: routePorts(routes), ...(deps.launches ? { launches: deps.launches } : {}), ...(sourceUpdatedAt ? { sourceUpdatedAt } : {}), audit: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.now ? { now: deps.now } : {}) })] : routes
271
+ // CON-04: manage paths never touch a Concierge session (concierge/guard.ts).
272
+ const identity = deps.concierge?.identity
273
+ const directory = deps.directory
274
+ const launches = deps.launches
275
+ return identity && directory
276
+ ? guardConciergeRoutes(all, {
277
+ identity,
278
+ resolve: (userID, addr) => directory.resolve(userID, addr).sessions.map((s) => s.sessionID),
279
+ ...(launches
280
+ ? {
281
+ launch: async (userID: string, id: string) => {
282
+ const l = await launches.view(userID, id)
283
+ return l ? { ...(l.sessionID !== undefined ? { sessionID: l.sessionID } : {}), concierge: isConciergeLaunch(l) } : undefined
284
+ },
285
+ }
286
+ : {}),
287
+ })
288
+ : all
289
+ }
290
+
291
+ /** HWA-28/40: unfiltered events and watch read by a team-scoped session leave out what discovery hides from it. */
292
+ function eventScope(deps: AgentDeps): Pick<EventsRouteDeps, "scope" | "resolve"> {
293
+ const discovery = deps.teamDiscovery
294
+ return discovery
295
+ ? {
296
+ scope: async (userID, actor) => eventVisibility(await discovery.census(userID, actor)),
297
+ resolve: async (userID, actor, addr) => discovery.resolve(await discovery.census(userID, actor), addr).sessions.map((s) => s.sessionID),
298
+ }
299
+ : {}
149
300
  }
150
301
 
151
302
  /** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
@@ -165,6 +316,24 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
165
316
  }
166
317
  }
167
318
 
319
+ /** (CON-04) Which sessions reads mark `concierge: true` (the user's current and hidden Concierge sessions). */
320
+ function conciergeReads(deps: AgentDeps): { concierge?: (userID: string) => Promise<readonly string[]> } {
321
+ const identity = deps.concierge?.identity
322
+ return identity ? { concierge: async (userID: string) => (await identity.current(userID)).hiddenSessionIDs } : {}
323
+ }
324
+
325
+ /** (CON-09a) The user's current Concierge session (Watchtower labels it @@concierge). */
326
+ function conciergeCurrent(deps: AgentDeps): { conciergeCurrent?: (userID: string) => Promise<string | undefined> } {
327
+ const identity = deps.concierge?.identity
328
+ return identity ? { conciergeCurrent: async (userID: string) => (await identity.current(userID)).sessionID } : {}
329
+ }
330
+
331
+ /** (CON-02) What reads show of intents: the stored ones of the listed sessions, by session. */
332
+ function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs: readonly string[]) => Promise<Map<string, SessionIntent>> } {
333
+ const { intents } = deps
334
+ return intents ? { intents: (userID: string, sessionIDs: readonly string[]) => intents.many(userID, sessionIDs) } : {}
335
+ }
336
+
168
337
  /** The act verbs, when every part they need is given. */
169
338
  function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
170
339
  const { actions, acting, grants, audit, registry, directory, spokes } = deps
@@ -180,6 +349,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
180
349
  audit,
181
350
  ...(deps.escalate ? { escalate: deps.escalate } : {}),
182
351
  ...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
352
+ ...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
183
353
  }),
184
354
  )
185
355
  }
@@ -192,6 +362,8 @@ export interface AgentApiOptions {
192
362
  /** Records activity of a resolved caller (device and user lastSeen). */
193
363
  touch?(user: UserRecord, device?: DeviceRecord): void
194
364
  readonly routes: readonly AgentRoute[]
365
+ /** Runs for every resolved caller before the route class check (CON-04: the Concierge's route allow-list). */
366
+ gate?(actor: AgentActor, route: AgentRoute): Promise<void>
195
367
  }
196
368
 
197
369
  export interface AgentApi {
@@ -250,6 +422,7 @@ export function agentApi(options: AgentApiOptions): AgentApi {
250
422
  options.touch?.(resolved.user, resolved.device)
251
423
  const found = find(method, url.pathname)
252
424
  if (!found) throw new AgentError("not_found", "Not found")
425
+ if (options.gate) await options.gate(resolved.actor, found.route)
253
426
  if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
254
427
  const { actor, credential, user, device } = resolved
255
428
  return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
@@ -9,6 +9,7 @@
9
9
  // scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
10
10
  // the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
11
11
  // one the token did not make, is 404 not_found.
12
+ import { boundedJson } from "./body"
12
13
  import type { Store } from "../store"
13
14
  import type { AgentActor } from "./actor"
14
15
  import { AgentError } from "./errors"
@@ -37,7 +38,7 @@ const requireEventScopes = (actor: AgentActor, events: unknown) => {
37
38
  }
38
39
 
39
40
  async function bodyOf(request: Request): Promise<Record<string, unknown>> {
40
- const raw = JSON.parse(await request.text()) as unknown
41
+ const raw = (await boundedJson(request)) as unknown
41
42
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
42
43
  return raw as Record<string, unknown>
43
44
  }
@@ -39,6 +39,18 @@ export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
39
39
  "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
40
  "message.changed", "launch.changed",
41
41
  ]
42
+ /** A launch.changed event for a queued launch's new wait kind (not a state step). */
43
+ export const isWaitChange = (event: Pick<AgentEvent, "type" | "data">): boolean => event.type === "launch.changed" && event.data.wait !== undefined
44
+
45
+ /**
46
+ * The legacy `launch.<state>` type a canonical launch.changed event also matches; none for a queued launch's
47
+ * change of wait kind (FX-02), so launch.queued still fires once per launch, as before.
48
+ */
49
+ export function legacyLaunchType(event: Pick<AgentEvent, "type" | "data">): string | undefined {
50
+ if (event.type !== "launch.changed" || typeof event.data.state !== "string" || isWaitChange(event)) return undefined
51
+ return `launch.${event.data.state}`
52
+ }
53
+
42
54
  /** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
43
55
  export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
44
56
  export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
@@ -336,11 +348,14 @@ export class Webhooks {
336
348
  }
337
349
 
338
350
  publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
339
- const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
351
+ // (FX-02) A queued launch's new wait kind is journaled (agent events, watch) but sent to no webhook: webhooks keep
352
+ // one delivery per launch state, as before.
353
+ if (isWaitChange(event)) return Promise.resolve(0)
354
+ const legacy = legacyLaunchType(event)
340
355
  return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
341
356
  ...(audience?.by ? { by: audience.by } : {}),
342
357
  ...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
343
- ...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
358
+ ...(legacy ? { matchTypes: [event.type, legacy] } : {}),
344
359
  })
345
360
  }
346
361
 
@@ -38,6 +38,8 @@ export interface AgentSession {
38
38
  /** Project name (lowercased) used by `.project` scopes. */
39
39
  project?: string
40
40
  title?: string
41
+ /** The last harness kind a spoke reported it under (offline sessions keep their glyph, RF-02). */
42
+ harness?: string
41
43
  firstSeen?: number
42
44
  /** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
43
45
  lastSeen?: number
@@ -70,6 +72,8 @@ export interface AgentData {
70
72
  generations?: Record<string, number>
71
73
  /** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
72
74
  actingPaused?: true
75
+ /** (CON-09a) Sessions unnamed before the directory knew them: their record is never derived a name (≤ 500). */
76
+ unnamed?: string[]
73
77
  [field: string]: unknown
74
78
  }
75
79
 
@@ -0,0 +1,44 @@
1
+ // Who may do what with an artifact (HWA-20; contract: Cross-cutting rules, Artifacts). Checked on every access by
2
+ // the artifact store. Tenancy comes first: the store only ever reads the caller's own user's records, so another
3
+ // user's artifact is simply missing. Within a user:
4
+ // - The user (console, app token) owns every artifact: read, revise, manage.
5
+ // - A session reads an artifact only when its visibility is "workspace" and the session is a *current* member of
6
+ // that workspace (re-checked on every read, so leaving a workspace ends access). It never reads a private one,
7
+ // even one it created: createdBySessionID is provenance and grants nothing.
8
+ // - A session may create artifacts (they belong to its user). Membership grants no write: revising, metadata,
9
+ // visibility, archive and delete stay with the user unless an explicit grant-aware policy is passed in (the
10
+ // routes, HWA-21, may wire the existing grants through `authorize`).
11
+ // Role labels and workspace roles never confer authority here.
12
+ import type { Artifact } from "../../protocol"
13
+
14
+ export type ArtifactPrincipal = { readonly kind: "user" } | { readonly kind: "session"; readonly sessionID: string }
15
+
16
+ /** "manage": metadata, visibility, archive/unarchive and delete. */
17
+ export type ArtifactAction = "read" | "create" | "revise" | "manage"
18
+
19
+ /** The workspace facts access needs (HWA-03/04 provide them; tests use fixtures). */
20
+ export interface ArtifactWorkspaces {
21
+ exists(userID: string, workspaceID: string): Promise<boolean>
22
+ isCurrentMember(userID: string, workspaceID: string, sessionID: string): Promise<boolean>
23
+ }
24
+
25
+ export interface AccessRequest {
26
+ readonly userID: string
27
+ readonly principal: ArtifactPrincipal
28
+ readonly action: ArtifactAction
29
+ /** Absent for create. */
30
+ readonly artifact?: Artifact
31
+ /** Absent when this hub has no workspaces: then no session reads through a workspace. */
32
+ readonly workspaces?: ArtifactWorkspaces
33
+ }
34
+
35
+ export type ArtifactAuthorizer = (request: AccessRequest) => Promise<boolean>
36
+
37
+ /** The contract's default policy (see the header). */
38
+ export const defaultArtifactPolicy: ArtifactAuthorizer = async ({ userID, principal, action, artifact, workspaces }) => {
39
+ if (principal.kind === "user") return true
40
+ if (action === "create") return true
41
+ if (action !== "read" || !artifact) return false
42
+ if (artifact.visibility !== "workspace" || artifact.workspaceID === undefined || !workspaces) return false
43
+ return workspaces.isCurrentMember(userID, artifact.workspaceID, principal.sessionID)
44
+ }