@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
@@ -0,0 +1,120 @@
1
+ // The hub's sources for profile usage (RP-08; usage.ts): one bounded read per source, never one per session.
2
+ // - sessions: one directory read (Directory.entries) for every pinned session; the harness only for a session a
3
+ // connection of the same tenant owns now (registry.owner + harnessOf, tenant-checked).
4
+ // - team roles: the user's active teams (one paged list, ≤ teamsPerUser) and, when a role names a profile, one
5
+ // scan of the seats (TeamSeatStore.currentCounts).
6
+ // - launches: the user's retained launches (one list; the launch store prunes ended ones).
7
+ // - workspaces: one scan of the memberships (MembershipStore.membershipsOfMany) and one paged workspace list for
8
+ // the titles, only when a pinned session is a member somewhere.
9
+ import { LIST_LIMIT_MAX } from "../../protocol"
10
+ import type { Directory } from "../agent/directory"
11
+ import { AgentError } from "../agent/errors"
12
+ import type { RouteLaunches } from "../agent/launch-routes"
13
+ import type { ReadRegistry } from "../agent/read-routes"
14
+ import type { TeamSeatStore } from "../teams/seats"
15
+ import type { Team, TeamStore } from "../teams/store"
16
+ import type { MembershipStore } from "../workspaces/members"
17
+ import type { WorkspaceStore } from "../workspaces/store"
18
+ import type { UsagePorts, UsageSessionInfo, UsageTeamRoleRef } from "./usage"
19
+
20
+ export interface UsagePortDeps {
21
+ readonly directory?: Pick<Directory, "entries">
22
+ readonly registry?: Pick<ReadRegistry, "owner" | "sessionTenant" | "harnessOf">
23
+ readonly teams?: { readonly teams: Pick<TeamStore, "list">; readonly seats: Pick<TeamSeatStore, "currentCounts"> }
24
+ readonly launches?: Pick<RouteLaunches, "list">
25
+ readonly workspaces?: { readonly workspaces: Pick<WorkspaceStore, "list">; readonly members: Pick<MembershipStore, "membershipsOfMany"> }
26
+ }
27
+
28
+ export function usagePorts(deps: UsagePortDeps): UsagePorts {
29
+ const { directory, registry, teams, launches, workspaces } = deps
30
+ return {
31
+ ...(directory
32
+ ? {
33
+ sessions(userID: string, sessionIDs: readonly string[]) {
34
+ let entries
35
+ try {
36
+ entries = directory.entries(userID, sessionIDs)
37
+ } catch (cause) {
38
+ if (cause instanceof AgentError) return new Map()
39
+ throw cause
40
+ }
41
+ const out = new Map<string, UsageSessionInfo>()
42
+ for (const [id, e] of entries) {
43
+ const conn = e.online && registry && registry.sessionTenant(id) === userID ? registry.owner(id) : undefined
44
+ const harness = conn ? registry?.harnessOf?.(conn) : undefined
45
+ out.set(id, {
46
+ ...(e.name !== undefined ? { name: e.name } : {}),
47
+ ...(e.title !== undefined ? { title: e.title } : {}),
48
+ ...(e.machineID !== undefined ? { machineID: e.machineID } : {}),
49
+ ...(harness !== undefined ? { harness } : {}),
50
+ ...(e.project !== undefined ? { project: e.project } : {}),
51
+ online: e.online,
52
+ ...(e.lastSeen !== undefined ? { lastSeen: e.lastSeen } : {}),
53
+ })
54
+ }
55
+ return out
56
+ },
57
+ }
58
+ : {}),
59
+ ...(teams
60
+ ? {
61
+ async teamRoles(userID: string) {
62
+ const active: Team[] = []
63
+ let cursor: string | undefined
64
+ do {
65
+ const page = await teams.teams.list(userID, { state: "active", limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
66
+ active.push(...page.items)
67
+ cursor = page.cursor
68
+ } while (cursor)
69
+ const named = active.flatMap((t) => t.roles.filter((r) => r.profile).map((r) => ({ team: t, role: r })))
70
+ if (!named.length) return []
71
+ const counts = await teams.seats.currentCounts(userID)
72
+ return named.map(({ team, role }): UsageTeamRoleRef => ({
73
+ teamID: team.id,
74
+ teamName: team.name,
75
+ teamTitle: team.title,
76
+ role: role.name,
77
+ ...(role.label !== undefined ? { label: role.label } : {}),
78
+ profileID: role.profile!.profileID,
79
+ ...(role.profile!.version !== undefined ? { version: role.profile!.version } : {}),
80
+ staffed: counts.get(team.id)?.get(role.name) ?? 0,
81
+ }))
82
+ },
83
+ }
84
+ : {}),
85
+ ...(launches
86
+ ? {
87
+ async launches(userID: string) {
88
+ return (await launches.list(userID)).map((l) => ({
89
+ id: l.id,
90
+ state: l.state,
91
+ ...(l.outcome !== undefined ? { outcome: l.outcome } : {}),
92
+ ...(l.by !== undefined ? { by: l.by } : {}),
93
+ ...(l.sessionID !== undefined ? { sessionID: l.sessionID } : {}),
94
+ created: l.created,
95
+ at: l.at,
96
+ ...(l.spec.agentProfile ? { agentProfile: { profileID: l.spec.agentProfile.profileID, version: l.spec.agentProfile.version } } : {}),
97
+ }))
98
+ },
99
+ }
100
+ : {}),
101
+ ...(workspaces
102
+ ? {
103
+ async workspaces(userID: string, sessionIDs: readonly string[]) {
104
+ const memberships = await workspaces.members.membershipsOfMany(userID, sessionIDs)
105
+ if (!memberships.size) return new Map()
106
+ const titles = new Map<string, string>()
107
+ let cursor: string | undefined
108
+ do {
109
+ const page = await workspaces.workspaces.list(userID, { limit: LIST_LIMIT_MAX, ...(cursor ? { cursor } : {}) })
110
+ for (const w of page.items) titles.set(w.id, w.title)
111
+ cursor = page.cursor
112
+ } while (cursor)
113
+ return new Map(
114
+ [...memberships].map(([sessionID, ids]) => [sessionID, ids.map((workspaceID) => ({ workspaceID, ...(titles.has(workspaceID) ? { title: titles.get(workspaceID)! } : {}) }))] as const),
115
+ )
116
+ },
117
+ }
118
+ : {}),
119
+ }
120
+ }
@@ -0,0 +1,364 @@
1
+ // Where a profile is used (RP-08): GET /api/agent/profiles/:id/usage and the per-profile counts of
2
+ // GET /api/agent/profiles?include=usage. Read-only; it changes nothing and confers nothing.
3
+ //
4
+ // Sources (one bounded pass each, never one read per session — no N+1):
5
+ // - pins: ONE scan of the user's `profile-assignments` collection (ProfileStore.assignmentsScan), capped at
6
+ // `limits.assignments`. No reverse index: assignments are keyed by session and bounded by the user's sessions
7
+ // (the directory keeps at most SESSION_LIMIT), so a capped scan is cheaper than keeping a second index consistent
8
+ // with every pin, unpin and retention write. Past the cap the answer says `truncated: ["sessions"]`.
9
+ // - sessions: one directory read for all pinned session IDs (name, title, machine, project, presence), with the
10
+ // harness of the session's owning connection when it is online (ports.sessions).
11
+ // - team roles: the published roles of the user's active teams that name the profile, with their current staffed
12
+ // seat counts (ports.teamRoles: one team list and one seats scan).
13
+ // - launches: the user's retained launches whose spec named the profile (ports.launches: one list).
14
+ // - workspaces: the current workspace memberships of the pinned sessions (ports.workspaces: one members scan).
15
+ //
16
+ // Authorization: the route needs profiles:read (users always; session tokens never). Each category then mirrors the
17
+ // existing read rule of what it shows, and a category the caller may not read is left out entirely (no counts
18
+ // either) and named in `hidden`:
19
+ // sessions apps need sessions:read (session IDs, presence, machine, harness, project)
20
+ // teams apps need teams:read or teams:manage (team, role; no session IDs)
21
+ // seat occupancy (`staffed`, `staffedSeats`) also needs sessions:read, like the team seat, roster,
22
+ // status and staffing routes; without it the roles are shown without those fields
23
+ // launches apps need sessions:launch, and see only the launches they started (launch.by), like GET /launches;
24
+ // a launch's session ID also needs sessions:read
25
+ // workspaces apps need workspaces:read and sessions:read (derived from the sessions' memberships)
26
+ // Another user's or an unknown profile is the same 404 (the profile is looked up under the caller's user first).
27
+ import { LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX, type ProfileAssignment } from "../../protocol"
28
+ import type { AgentActor } from "../agent/actor"
29
+ import { AgentError } from "../agent/errors"
30
+ import type { ProfileStore } from "./store"
31
+
32
+ /** One pinned session as the directory and registry know it (absent: not in the directory any more). */
33
+ export interface UsageSessionInfo {
34
+ readonly name?: string
35
+ readonly title?: string
36
+ readonly machineID?: string
37
+ /** The harness of the connection that owns the session (online sessions only). */
38
+ readonly harness?: string
39
+ readonly project?: string
40
+ readonly online: boolean
41
+ readonly lastSeen?: number
42
+ }
43
+
44
+ /** A published team role that names a profile. */
45
+ export interface UsageTeamRoleRef {
46
+ readonly teamID: string
47
+ readonly teamName: string
48
+ readonly teamTitle: string
49
+ readonly role: string
50
+ readonly label?: string
51
+ readonly profileID: string
52
+ /** The role's requested version; absent: the latest at staffing time. */
53
+ readonly version?: number
54
+ /** Current seats in that role. */
55
+ readonly staffed: number
56
+ }
57
+
58
+ /** A retained launch, as far as usage needs it. */
59
+ export interface UsageLaunch {
60
+ readonly id: string
61
+ readonly state: string
62
+ readonly outcome?: string
63
+ /** Who launched it (`user`, `app:<token>`, a session ID): the launch read rule. */
64
+ readonly by?: string
65
+ readonly sessionID?: string
66
+ readonly created: number
67
+ /** When it entered its state. */
68
+ readonly at: number
69
+ readonly agentProfile?: { readonly profileID: string; readonly version: number }
70
+ }
71
+
72
+ export interface UsageLimits {
73
+ /** Pins scanned per request. */
74
+ readonly assignments: number
75
+ /** Team roles, launches and workspaces listed (counts stay exact). */
76
+ readonly teamRoles: number
77
+ readonly launches: number
78
+ readonly workspaces: number
79
+ /** Distinct team IDs per profile in the list counts. */
80
+ readonly teamsPerCount: number
81
+ }
82
+
83
+ export const USAGE_LIMITS: UsageLimits = { assignments: 10_000, teamRoles: 100, launches: 50, workspaces: 50, teamsPerCount: 16 }
84
+
85
+ type Memberships = ReadonlyMap<string, readonly { readonly workspaceID: string; readonly title?: string }[]>
86
+
87
+ /** The hub's sources; a missing port reads as "nothing uses it there". */
88
+ export interface UsagePorts {
89
+ sessions?(userID: string, sessionIDs: readonly string[]): ReadonlyMap<string, UsageSessionInfo>
90
+ teamRoles?(userID: string): Promise<readonly UsageTeamRoleRef[]>
91
+ launches?(userID: string): Promise<readonly UsageLaunch[]>
92
+ workspaces?(userID: string, sessionIDs: readonly string[]): Promise<Memberships>
93
+ }
94
+
95
+ /** What one caller may see. */
96
+ export interface UsageView {
97
+ readonly sessions: boolean
98
+ readonly teams: boolean
99
+ /** false: hidden; otherwise every launch, or (apps) only the ones `by` started. */
100
+ readonly launches: false | { readonly by?: string }
101
+ readonly workspaces: boolean
102
+ }
103
+
104
+ export type UsageCategory = "sessions" | "teams" | "launches" | "workspaces"
105
+
106
+ /** The caller's view: users see everything; apps by scope (see the header); session tokens never get here. */
107
+ export function usageView(actor: AgentActor): UsageView {
108
+ if (actor.kind === "user") return { sessions: true, teams: true, launches: {}, workspaces: true }
109
+ if (actor.kind === "session") return { sessions: false, teams: false, launches: false, workspaces: false }
110
+ const has = (scope: string) => actor.scopes.includes(scope)
111
+ return {
112
+ sessions: has("sessions:read"),
113
+ teams: has("teams:read") || has("teams:manage"),
114
+ launches: has("sessions:launch") ? { by: `app:${actor.tokenID}` } : false,
115
+ workspaces: has("workspaces:read") && has("sessions:read"),
116
+ }
117
+ }
118
+
119
+ /** Seat occupancy is session information: team roles plus sessions:read (the team seat/roster/status rule). */
120
+ const seatsVisible = (view: UsageView): boolean => view.teams && view.sessions
121
+
122
+ function hiddenOf(view: UsageView, categories: readonly UsageCategory[]): UsageCategory[] {
123
+ const shown: Record<UsageCategory, boolean> = { sessions: view.sessions, teams: view.teams, launches: view.launches !== false, workspaces: view.workspaces }
124
+ return categories.filter((c) => !shown[c])
125
+ }
126
+
127
+ export interface UsageSessionItem {
128
+ readonly sessionID: string
129
+ readonly name?: string
130
+ readonly title?: string
131
+ readonly machineID?: string
132
+ readonly harness?: string
133
+ readonly project?: string
134
+ readonly online: boolean
135
+ readonly lastSeen?: number
136
+ /** Not in the directory any more (deleted); the pin is still stored. */
137
+ readonly known?: false
138
+ /** The requested (pinned) version and the pin's revision (for an explicit upgrade with expectedRevision). */
139
+ readonly version: number
140
+ readonly revision: number
141
+ /** Pinned to a version older than the profile's latest. */
142
+ readonly olderVersion: boolean
143
+ readonly ack?: { readonly status: string; readonly at: number; readonly offline?: true }
144
+ /** What the session last confirmed running. */
145
+ readonly effective?: { readonly version: number; readonly status: string; readonly at: number }
146
+ }
147
+
148
+ export interface UsageCounts {
149
+ sessions?: number
150
+ online?: number
151
+ olderVersion?: number
152
+ teamRoles?: number
153
+ staffedSeats?: number
154
+ launches?: number
155
+ liveLaunches?: number
156
+ workspaces?: number
157
+ }
158
+
159
+ /** The list's per-profile counts (no workspaces: that would need the members scan for every page). */
160
+ export interface UsageListCounts extends UsageCounts {
161
+ /** Names (the #team slug, which team routes also accept) of the active teams whose roles name the profile. */
162
+ teams?: string[]
163
+ harnesses?: string[]
164
+ }
165
+
166
+ export interface ProfileUsageAnswer {
167
+ readonly profileID: string
168
+ readonly latestVersion: number
169
+ readonly counts: UsageCounts
170
+ readonly sessions?: { readonly items: UsageSessionItem[]; readonly cursor?: string }
171
+ readonly projects?: string[]
172
+ /** `staffed` only when seats are visible (sessions:read). */
173
+ readonly teamRoles?: (Omit<UsageTeamRoleRef, "profileID" | "staffed"> & { staffed?: number })[]
174
+ readonly launches?: { id: string; state: string; outcome?: string; version: number; sessionID?: string; created: number; updated: number }[]
175
+ readonly workspaces?: { workspaceID: string; title?: string; sessions: number }[]
176
+ readonly hidden?: UsageCategory[]
177
+ readonly truncated?: ("sessions" | "teamRoles" | "launches" | "workspaces")[]
178
+ }
179
+
180
+ const encodeCursor = (sessionID: string) => Buffer.from(JSON.stringify({ k: sessionID })).toString("base64url")
181
+ function decodeCursor(cursor: string): string {
182
+ try {
183
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { k?: unknown }
184
+ if (typeof parsed.k === "string") return parsed.k
185
+ } catch {
186
+ // answered below
187
+ }
188
+ throw new AgentError("invalid", "Bad cursor; start again without one")
189
+ }
190
+
191
+ function pageLimit(limit: number | undefined): number {
192
+ if (limit === undefined) return LIST_LIMIT_DEFAULT
193
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
194
+ return limit
195
+ }
196
+
197
+ const LIVE = (l: UsageLaunch) => l.state !== "ended"
198
+ const byNewest = (a: UsageLaunch, b: UsageLaunch) => b.created - a.created || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
199
+
200
+ export class ProfileUsage {
201
+ private readonly profiles: Pick<ProfileStore, "get" | "assignmentsScan">
202
+ private readonly ports: UsagePorts
203
+ readonly limits: UsageLimits
204
+
205
+ constructor(options: { readonly profiles: Pick<ProfileStore, "get" | "assignmentsScan">; readonly ports?: UsagePorts; readonly limits?: Partial<UsageLimits> }) {
206
+ this.profiles = options.profiles
207
+ this.ports = options.ports ?? {}
208
+ this.limits = { ...USAGE_LIMITS, ...options.limits }
209
+ }
210
+
211
+ /** One profile's usage for `view`; not_found for an unknown or another user's profile. */
212
+ async usage(userID: string, profileID: string, view: UsageView, page: { readonly limit?: number; readonly cursor?: string } = {}): Promise<ProfileUsageAnswer> {
213
+ const limit = pageLimit(page.limit)
214
+ const after = page.cursor === undefined ? undefined : decodeCursor(page.cursor)
215
+ const profile = await this.profiles.get(userID, profileID)
216
+ if (!profile) throw new AgentError("not_found", `No profile ${profileID}`)
217
+ const counts: UsageCounts = {}
218
+ const truncated: NonNullable<ProfileUsageAnswer["truncated"]> = []
219
+ const answer: { -readonly [K in keyof ProfileUsageAnswer]: ProfileUsageAnswer[K] } = { profileID: profile.id, latestVersion: profile.latestVersion, counts }
220
+
221
+ let pins: ProfileAssignment[] = []
222
+ if (view.sessions || view.workspaces) {
223
+ const scan = await this.profiles.assignmentsScan(userID, this.limits.assignments)
224
+ if (scan.truncated) truncated.push("sessions")
225
+ pins = scan.items.filter((a) => a.profileID === profile.id).sort((a, b) => (a.sessionID < b.sessionID ? -1 : a.sessionID > b.sessionID ? 1 : 0))
226
+ }
227
+ const ids = pins.map((p) => p.sessionID)
228
+
229
+ if (view.sessions) {
230
+ const info = ids.length && this.ports.sessions ? this.ports.sessions(userID, ids) : new Map<string, UsageSessionInfo>()
231
+ const items = pins.map((pin) => sessionItem(pin, info.get(pin.sessionID), profile.latestVersion))
232
+ counts.sessions = items.length
233
+ counts.online = items.filter((s) => s.online).length
234
+ counts.olderVersion = items.filter((s) => s.olderVersion).length
235
+ const rest = after === undefined ? items : items.filter((s) => s.sessionID > after)
236
+ const shown = rest.slice(0, limit)
237
+ answer.sessions = { items: shown, ...(rest.length > limit ? { cursor: encodeCursor(shown.at(-1)!.sessionID) } : {}) }
238
+ answer.projects = [...new Set(items.map((s) => s.project).filter((p): p is string => !!p))].sort()
239
+ }
240
+
241
+ if (view.teams) {
242
+ const refs = this.ports.teamRoles ? (await this.ports.teamRoles(userID)).filter((r) => r.profileID === profile.id) : []
243
+ const seats = seatsVisible(view)
244
+ counts.teamRoles = refs.length
245
+ if (seats) counts.staffedSeats = refs.reduce((n, r) => n + r.staffed, 0)
246
+ if (refs.length > this.limits.teamRoles) truncated.push("teamRoles")
247
+ answer.teamRoles = refs.slice(0, this.limits.teamRoles).map(({ profileID: _p, staffed, ...role }) => (seats ? { ...role, staffed } : role))
248
+ }
249
+
250
+ if (view.launches !== false) {
251
+ const by = view.launches.by
252
+ const named = this.ports.launches ? (await this.ports.launches(userID)).filter((l) => l.agentProfile?.profileID === profile.id && (by === undefined || l.by === by)) : []
253
+ counts.launches = named.length
254
+ counts.liveLaunches = named.filter(LIVE).length
255
+ if (named.length > this.limits.launches) truncated.push("launches")
256
+ answer.launches = [...named]
257
+ .sort(byNewest)
258
+ .slice(0, this.limits.launches)
259
+ .map((l) => ({
260
+ id: l.id,
261
+ state: l.state,
262
+ ...(l.outcome !== undefined ? { outcome: l.outcome } : {}),
263
+ version: l.agentProfile!.version,
264
+ ...(view.sessions && l.sessionID !== undefined ? { sessionID: l.sessionID } : {}),
265
+ created: l.created,
266
+ updated: l.at,
267
+ }))
268
+ }
269
+
270
+ if (view.workspaces) {
271
+ const memberships = ids.length && this.ports.workspaces ? await this.ports.workspaces(userID, ids) : new Map()
272
+ const found = new Map<string, { workspaceID: string; title?: string; sessions: number }>()
273
+ for (const id of ids) {
274
+ for (const m of memberships.get(id) ?? []) {
275
+ const entry = found.get(m.workspaceID) ?? { workspaceID: m.workspaceID, ...(m.title !== undefined ? { title: m.title } : {}), sessions: 0 }
276
+ entry.sessions++
277
+ found.set(m.workspaceID, entry)
278
+ }
279
+ }
280
+ const all = [...found.values()].sort((a, b) => b.sessions - a.sessions || (a.workspaceID < b.workspaceID ? -1 : 1))
281
+ counts.workspaces = all.length
282
+ if (all.length > this.limits.workspaces) truncated.push("workspaces")
283
+ answer.workspaces = all.slice(0, this.limits.workspaces)
284
+ }
285
+
286
+ const hidden = hiddenOf(view, ["sessions", "teams", "launches", "workspaces"])
287
+ if (hidden.length) answer.hidden = hidden
288
+ if (truncated.length) answer.truncated = truncated
289
+ return answer
290
+ }
291
+
292
+ /** Per-profile counts for a page of the list (one pass per source, whatever the page size). */
293
+ async counts(
294
+ userID: string,
295
+ profiles: readonly { readonly id: string; readonly latestVersion: number }[],
296
+ view: UsageView,
297
+ ): Promise<{ usage: Record<string, UsageListCounts>; hidden?: UsageCategory[]; truncated?: ["sessions"] }> {
298
+ const usage: Record<string, UsageListCounts> = {}
299
+ const latest = new Map(profiles.map((p) => [p.id, p.latestVersion]))
300
+ for (const p of profiles) usage[p.id] = {}
301
+ let truncated = false
302
+ if (profiles.length && view.sessions) {
303
+ const scan = await this.profiles.assignmentsScan(userID, this.limits.assignments)
304
+ truncated = scan.truncated
305
+ const pins = scan.items.filter((a) => latest.has(a.profileID))
306
+ const info = pins.length && this.ports.sessions ? this.ports.sessions(userID, pins.map((p) => p.sessionID)) : new Map<string, UsageSessionInfo>()
307
+ for (const p of profiles) Object.assign(usage[p.id]!, { sessions: 0, online: 0, olderVersion: 0, harnesses: [] })
308
+ const harnesses = new Map<string, Set<string>>()
309
+ for (const pin of pins) {
310
+ const c = usage[pin.profileID]!
311
+ const s = info.get(pin.sessionID)
312
+ c.sessions!++
313
+ if (s?.online) c.online!++
314
+ if (pin.version < latest.get(pin.profileID)!) c.olderVersion!++
315
+ if (s?.harness) harnesses.set(pin.profileID, (harnesses.get(pin.profileID) ?? new Set()).add(s.harness))
316
+ }
317
+ for (const [id, set] of harnesses) usage[id]!.harnesses = [...set].sort()
318
+ }
319
+ if (profiles.length && view.teams) {
320
+ const refs = this.ports.teamRoles ? await this.ports.teamRoles(userID) : []
321
+ const seats = seatsVisible(view)
322
+ for (const p of profiles) Object.assign(usage[p.id]!, { teamRoles: 0, ...(seats ? { staffedSeats: 0 } : {}), teams: [] })
323
+ for (const ref of refs) {
324
+ const c = usage[ref.profileID]
325
+ if (!c) continue
326
+ c.teamRoles!++
327
+ if (seats) c.staffedSeats! += ref.staffed
328
+ if (!c.teams!.includes(ref.teamName) && c.teams!.length < this.limits.teamsPerCount) c.teams!.push(ref.teamName)
329
+ }
330
+ }
331
+ if (profiles.length && view.launches !== false) {
332
+ const by = view.launches.by
333
+ const all = this.ports.launches ? await this.ports.launches(userID) : []
334
+ for (const p of profiles) Object.assign(usage[p.id]!, { launches: 0, liveLaunches: 0 })
335
+ for (const l of all) {
336
+ const c = l.agentProfile && usage[l.agentProfile.profileID]
337
+ if (!c || (by !== undefined && l.by !== by)) continue
338
+ c.launches!++
339
+ if (LIVE(l)) c.liveLaunches!++
340
+ }
341
+ }
342
+ const hidden = hiddenOf(view, ["sessions", "teams", "launches"])
343
+ return { usage, ...(hidden.length ? { hidden } : {}), ...(truncated ? { truncated: ["sessions"] as ["sessions"] } : {}) }
344
+ }
345
+ }
346
+
347
+ function sessionItem(pin: ProfileAssignment, info: UsageSessionInfo | undefined, latestVersion: number): UsageSessionItem {
348
+ return {
349
+ sessionID: pin.sessionID,
350
+ ...(info?.name !== undefined ? { name: info.name } : {}),
351
+ ...(info?.title !== undefined ? { title: info.title } : {}),
352
+ ...(info?.machineID !== undefined ? { machineID: info.machineID } : {}),
353
+ ...(info?.harness !== undefined ? { harness: info.harness } : {}),
354
+ ...(info?.project !== undefined ? { project: info.project } : {}),
355
+ online: info?.online === true,
356
+ ...(info?.lastSeen !== undefined ? { lastSeen: info.lastSeen } : {}),
357
+ ...(info ? {} : { known: false as const }),
358
+ version: pin.version,
359
+ revision: pin.revision,
360
+ olderVersion: pin.version < latestVersion,
361
+ ...(pin.ack ? { ack: { status: pin.ack.status, at: pin.ack.at, ...(pin.ack.offline ? { offline: true as const } : {}) } } : {}),
362
+ ...(pin.effective && pin.effective.profileID === pin.profileID ? { effective: { version: pin.effective.version, status: pin.effective.status, at: pin.effective.at } } : {}),
363
+ }
364
+ }
@@ -33,6 +33,7 @@ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = O
33
33
  decision: "Decision",
34
34
  message: "Message",
35
35
  providerError: "Model provider error",
36
+ concierge: "Concierge",
36
37
  })
37
38
 
38
39
  /** True for a known notification kind (API input). */
@@ -63,6 +64,8 @@ export interface PushPayload {
63
64
  readonly test?: true
64
65
  /** Per-session kinds (providerError, subagentFailed): the session it is about, so the service worker tags each apart. */
65
66
  readonly tagKey?: string
67
+ /** (CON-04) Where a tap opens, when not the session page (kind concierge: /concierge). */
68
+ readonly url?: string
66
69
  }
67
70
 
68
71
  const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
@@ -213,6 +216,9 @@ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTi
213
216
  return { kind, sessionID, title, body: "A decision waits for you. Tap to answer.", ...(extra.decisionID ? { decisionID: extra.decisionID } : {}) }
214
217
  case "message":
215
218
  return { kind, sessionID, title, body: "Tap to see it." }
219
+ // The Concierge's session is hidden: the tap opens the Concierge, never its session page; no text.
220
+ case "concierge":
221
+ return { kind, sessionID, title: "Concierge", body: "The Concierge has something for you. Tap to open it.", url: "/concierge" }
216
222
  case "permission":
217
223
  // Never the request's summary (it may be a command line); the service worker fetches it from the hub.
218
224
  return { kind, sessionID, title, body: "Tap to review and answer.", ...(extra.requestID ? { requestID: extra.requestID } : {}) }
@@ -240,6 +246,7 @@ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number;
240
246
  case "decision":
241
247
  case "message":
242
248
  case "providerError":
249
+ case "concierge":
243
250
  return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
244
251
  case "done":
245
252
  case "stopped":