@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,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":
@@ -11,7 +11,7 @@
11
11
  // another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
- import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
14
+ import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
15
  import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
@@ -222,6 +222,11 @@ export interface SessionView extends BrowserFields {
222
222
  /** Open in a terminal (any reporting process says so); where. */
223
223
  readonly open?: boolean
224
224
  readonly terminal?: TerminalInfo
225
+ /** A staged revert (feature "revert"), as the most recent report says. */
226
+ readonly revert?: SessionRevertInfo
227
+ /** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
228
+ readonly agent?: string
229
+ readonly contextRevision?: number
225
230
  readonly busy: boolean
226
231
  readonly conversation: boolean
227
232
  /** Status tree of the root session that contains this session. */
@@ -405,6 +410,7 @@ export class Registry {
405
410
  private readonly sessionsListeners = new Set<(connID: string) => void>()
406
411
  /** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
407
412
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
413
+ private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
408
414
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
409
415
  private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
410
416
  /** Form IDs already alerted (forms may be forwarded by several processes). */
@@ -450,7 +456,7 @@ export class Registry {
450
456
  private remoteBoards = new Map<string, SliceBoard>()
451
457
  private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
452
458
  private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
453
- private remotePresence: Array<{ sessionID: string; at: number }> = []
459
+ private remotePresence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
454
460
  private remoteBusy = new Set<string>()
455
461
  private remoteTerminals = new Map<string, MachineTerminals>()
456
462
 
@@ -573,6 +579,12 @@ export class Registry {
573
579
  return () => this.humanTurnListeners.delete(listener)
574
580
  }
575
581
 
582
+ /** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
583
+ onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
584
+ this.sessionDeletedListeners.add(listener)
585
+ return () => this.sessionDeletedListeners.delete(listener)
586
+ }
587
+
576
588
  connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
577
589
  const t = this.now()
578
590
  this.conns.set(connID, {
@@ -736,6 +748,7 @@ export class Registry {
736
748
  for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
737
749
  }
738
750
  if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
751
+ if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
739
752
  if (closedPermission) this.permissionClosed(...closedPermission)
740
753
  }
741
754
 
@@ -1037,6 +1050,17 @@ export class Registry {
1037
1050
  return false
1038
1051
  }
1039
1052
 
1053
+ /**
1054
+ * (CON-06) True while this device reported this exact session visible within PRESENCE_WINDOW_MS (on any pod):
1055
+ * the voice-confirm check (a paired device with the Concierge open).
1056
+ */
1057
+ present(deviceID: string, sessionID: string): boolean {
1058
+ const t = this.now()
1059
+ const local = this.presence.get(`${deviceID} ${sessionID}`)
1060
+ if (local && t - local.at <= PRESENCE_WINDOW_MS) return true
1061
+ return this.remotePresence.some((entry) => entry.deviceID === deviceID && entry.sessionID === sessionID && t - entry.at <= PRESENCE_WINDOW_MS)
1062
+ }
1063
+
1040
1064
  /** Whether any process exposes the session to voice; undefined when no process reports it. */
1041
1065
  exposed(sessionID: string): boolean | undefined {
1042
1066
  let reported = false
@@ -1248,6 +1272,9 @@ export class Registry {
1248
1272
  ...(info.projectName ? { projectName: info.projectName } : {}),
1249
1273
  updated: info.updated,
1250
1274
  ...(terminal ? { open: true, terminal } : {}),
1275
+ ...(info.revert ? { revert: info.revert } : {}),
1276
+ ...(info.agent !== undefined ? { agent: info.agent } : {}),
1277
+ ...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
1251
1278
  ...fields,
1252
1279
  busy: this.busy(sessionID),
1253
1280
  conversation: this.conversations.has(sessionID),
@@ -1568,7 +1595,8 @@ export class Registry {
1568
1595
  * Connection that should run a turn for the session:
1569
1596
  * 1. a process reporting it as owned, most recent activity first;
1570
1597
  * 2. else a service process that reports it;
1571
- * 3. else the service process of a machine that reports it;
1598
+ * 3. else the (never launch-bound) service process of a machine that reports it, of the same harness as its
1599
+ * reporters (never a service of another harness: a Claude Code service cannot serve an OpenCode session);
1572
1600
  * 4. else any process that reports it, most recent activity first;
1573
1601
  * 5. else none.
1574
1602
  */
@@ -1587,8 +1615,17 @@ export class Registry {
1587
1615
  if (owned.length) return recent(owned)
1588
1616
  const services = reporters.filter((c) => c.hello.kind === "service")
1589
1617
  if (services.length) return recent(services)
1618
+ // A machine's service runs one harness: only a service of the session's harness (its reporters') may run it.
1590
1619
  const machines = new Set(reporters.map((c) => c.machine.id))
1591
- return recent(all.filter((c) => c.hello.kind === "service" && machines.has(c.machine.id))) ?? recent(reporters)
1620
+ const harnesses = new Set(reporters.map((c) => helloHarness(c.hello).harness))
1621
+ const machineServices = all.filter(
1622
+ (c) =>
1623
+ c.hello.kind === "service" &&
1624
+ c.launchID === undefined &&
1625
+ machines.has(c.machine.id) &&
1626
+ harnesses.has(helloHarness(c.hello).harness),
1627
+ )
1628
+ return recent(machineServices) ?? recent(reporters)
1592
1629
  }
1593
1630
 
1594
1631
  /**
@@ -1910,7 +1947,7 @@ export class Registry {
1910
1947
  const boards = new Map<string, SliceBoard>()
1911
1948
  const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
1912
1949
  const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
1913
- const presence: Array<{ sessionID: string; at: number }> = []
1950
+ const presence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
1914
1951
  const busy = new Set<string>()
1915
1952
  const terminals = new Map<string, MachineTerminals>()
1916
1953
  for (const [pod, slices] of this.remoteSlices) {
@@ -1944,7 +1981,7 @@ export class Registry {
1944
1981
  if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
1945
1982
  else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
1946
1983
  }
1947
- for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
1984
+ for (const entry of slice.presence ?? []) presence.push({ ...(typeof entry.deviceID === "string" ? { deviceID: entry.deviceID } : {}), sessionID: entry.sessionID, at: entry.at })
1948
1985
  for (const sessionID of slice.busy ?? []) busy.add(sessionID)
1949
1986
  }
1950
1987
  }