@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
@@ -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. */
@@ -248,6 +253,8 @@ export interface CatalogProcess {
248
253
  readonly connected: number
249
254
  readonly lastSeen: number
250
255
  readonly sessions: CatalogSession[]
256
+ /** (FX-07) Connected, but its first sessions frame has not come yet: an empty `sessions` says nothing then. */
257
+ readonly sessionsPending?: true
251
258
  }
252
259
 
253
260
  /** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
@@ -274,6 +281,8 @@ interface Conn {
274
281
  sessions: Map<string, SessionInfo>
275
282
  /** The last sessions frame as JSON, to skip unchanged snapshots. */
276
283
  sessionsKey: string
284
+ /** (FX-07) No sessions frame yet. */
285
+ sessionsPending?: true
277
286
  /** sessionID → activity sequence number of the last event this process sent for it. */
278
287
  activity: Map<string, number>
279
288
  /**
@@ -299,6 +308,8 @@ export interface SliceConn {
299
308
  readonly launchID?: string
300
309
  /** The machine's terminal context, when this connection sent the latest one. */
301
310
  readonly terminals?: MachineTerminals
311
+ /** (FX-07) No sessions frame yet (absent from older pods: reported). */
312
+ readonly sessionsPending?: true
302
313
  }
303
314
 
304
315
  /** A root session's status tree (live: run starts and recommendations included) and activity feed. */
@@ -405,6 +416,7 @@ export class Registry {
405
416
  private readonly sessionsListeners = new Set<(connID: string) => void>()
406
417
  /** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
407
418
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
419
+ private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
408
420
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
409
421
  private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
410
422
  /** Form IDs already alerted (forms may be forwarded by several processes). */
@@ -450,7 +462,7 @@ export class Registry {
450
462
  private remoteBoards = new Map<string, SliceBoard>()
451
463
  private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
452
464
  private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
453
- private remotePresence: Array<{ sessionID: string; at: number }> = []
465
+ private remotePresence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
454
466
  private remoteBusy = new Set<string>()
455
467
  private remoteTerminals = new Map<string, MachineTerminals>()
456
468
 
@@ -573,6 +585,12 @@ export class Registry {
573
585
  return () => this.humanTurnListeners.delete(listener)
574
586
  }
575
587
 
588
+ /** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
589
+ onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
590
+ this.sessionDeletedListeners.add(listener)
591
+ return () => this.sessionDeletedListeners.delete(listener)
592
+ }
593
+
576
594
  connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
577
595
  const t = this.now()
578
596
  this.conns.set(connID, {
@@ -586,6 +604,7 @@ export class Registry {
586
604
  lastSeen: t,
587
605
  sessions: new Map(),
588
606
  sessionsKey: "[]",
607
+ sessionsPending: true,
589
608
  activity: new Map(),
590
609
  })
591
610
  this.changed(this.tenant(this.conns.get(connID)!))
@@ -628,7 +647,9 @@ export class Registry {
628
647
  conn.lastSeen = this.now()
629
648
  // Spokes re-send their snapshot periodically; only changes reach the phones.
630
649
  const key = JSON.stringify(sessions)
631
- if (key !== conn.sessionsKey) {
650
+ const first = conn.sessionsPending === true
651
+ delete conn.sessionsPending
652
+ if (key !== conn.sessionsKey || first) {
632
653
  conn.sessionsKey = key
633
654
  conn.sessions = new Map(sessions.map((s) => [s.id, s]))
634
655
  this.syncBoards()
@@ -736,6 +757,7 @@ export class Registry {
736
757
  for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
737
758
  }
738
759
  if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
760
+ if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
739
761
  if (closedPermission) this.permissionClosed(...closedPermission)
740
762
  }
741
763
 
@@ -1037,6 +1059,17 @@ export class Registry {
1037
1059
  return false
1038
1060
  }
1039
1061
 
1062
+ /**
1063
+ * (CON-06) True while this device reported this exact session visible within PRESENCE_WINDOW_MS (on any pod):
1064
+ * the voice-confirm check (a paired device with the Concierge open).
1065
+ */
1066
+ present(deviceID: string, sessionID: string): boolean {
1067
+ const t = this.now()
1068
+ const local = this.presence.get(`${deviceID} ${sessionID}`)
1069
+ if (local && t - local.at <= PRESENCE_WINDOW_MS) return true
1070
+ return this.remotePresence.some((entry) => entry.deviceID === deviceID && entry.sessionID === sessionID && t - entry.at <= PRESENCE_WINDOW_MS)
1071
+ }
1072
+
1040
1073
  /** Whether any process exposes the session to voice; undefined when no process reports it. */
1041
1074
  exposed(sessionID: string): boolean | undefined {
1042
1075
  let reported = false
@@ -1208,6 +1241,7 @@ export class Registry {
1208
1241
  features: [...conn.features].sort(),
1209
1242
  connected: conn.connected,
1210
1243
  lastSeen: conn.lastSeen,
1244
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1211
1245
  sessions: this.visible(conn).map((s) => {
1212
1246
  const fields = this.browserFields(s.id)
1213
1247
  return {
@@ -1225,6 +1259,19 @@ export class Registry {
1225
1259
  return [...machines.values()]
1226
1260
  }
1227
1261
 
1262
+ /**
1263
+ * (FX-07) The tenant's sessions a spoke reports locked now (the Concierge's: `locked`, from the first frame after a
1264
+ * restart, and `agent` / `contextRevision`, sent only for locked sessions), on this pod's connections and other pods'.
1265
+ */
1266
+ lockedSessions(tenant: string): string[] {
1267
+ const out = new Set<string>()
1268
+ for (const conn of this.all()) {
1269
+ if (this.tenant(conn) !== tenant) continue
1270
+ for (const s of conn.sessions.values()) if (s.locked === true || s.agent !== undefined || s.contextRevision !== undefined) out.add(s.id)
1271
+ }
1272
+ return [...out]
1273
+ }
1274
+
1228
1275
  /** The session as reported by any process plus its live status; undefined when nobody reports it. */
1229
1276
  session(sessionID: string): SessionView | undefined {
1230
1277
  let info: SessionInfo | undefined
@@ -1248,6 +1295,9 @@ export class Registry {
1248
1295
  ...(info.projectName ? { projectName: info.projectName } : {}),
1249
1296
  updated: info.updated,
1250
1297
  ...(terminal ? { open: true, terminal } : {}),
1298
+ ...(info.revert ? { revert: info.revert } : {}),
1299
+ ...(info.agent !== undefined ? { agent: info.agent } : {}),
1300
+ ...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
1251
1301
  ...fields,
1252
1302
  busy: this.busy(sessionID),
1253
1303
  conversation: this.conversations.has(sessionID),
@@ -1568,7 +1618,8 @@ export class Registry {
1568
1618
  * Connection that should run a turn for the session:
1569
1619
  * 1. a process reporting it as owned, most recent activity first;
1570
1620
  * 2. else a service process that reports it;
1571
- * 3. else the service process of a machine that reports it;
1621
+ * 3. else the (never launch-bound) service process of a machine that reports it, of the same harness as its
1622
+ * reporters (never a service of another harness: a Claude Code service cannot serve an OpenCode session);
1572
1623
  * 4. else any process that reports it, most recent activity first;
1573
1624
  * 5. else none.
1574
1625
  */
@@ -1587,8 +1638,17 @@ export class Registry {
1587
1638
  if (owned.length) return recent(owned)
1588
1639
  const services = reporters.filter((c) => c.hello.kind === "service")
1589
1640
  if (services.length) return recent(services)
1641
+ // A machine's service runs one harness: only a service of the session's harness (its reporters') may run it.
1590
1642
  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)
1643
+ const harnesses = new Set(reporters.map((c) => helloHarness(c.hello).harness))
1644
+ const machineServices = all.filter(
1645
+ (c) =>
1646
+ c.hello.kind === "service" &&
1647
+ c.launchID === undefined &&
1648
+ machines.has(c.machine.id) &&
1649
+ harnesses.has(helloHarness(c.hello).harness),
1650
+ )
1651
+ return recent(machineServices) ?? recent(reporters)
1592
1652
  }
1593
1653
 
1594
1654
  /**
@@ -1856,6 +1916,7 @@ export class Registry {
1856
1916
  activity: [...conn.activity],
1857
1917
  ...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
1858
1918
  ...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
1919
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1859
1920
  }
1860
1921
  })
1861
1922
  const boards: SliceBoard[] = [...this.boards]
@@ -1910,7 +1971,7 @@ export class Registry {
1910
1971
  const boards = new Map<string, SliceBoard>()
1911
1972
  const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
1912
1973
  const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
1913
- const presence: Array<{ sessionID: string; at: number }> = []
1974
+ const presence: Array<{ deviceID?: string; sessionID: string; at: number }> = []
1914
1975
  const busy = new Set<string>()
1915
1976
  const terminals = new Map<string, MachineTerminals>()
1916
1977
  for (const [pod, slices] of this.remoteSlices) {
@@ -1929,6 +1990,7 @@ export class Registry {
1929
1990
  sessionsKey: "",
1930
1991
  activity: new Map(c.activity ?? []),
1931
1992
  ...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
1993
+ ...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
1932
1994
  remote: { pod, tenant },
1933
1995
  })
1934
1996
  if (c.terminals) terminals.set(c.machine.id, c.terminals)
@@ -1944,7 +2006,7 @@ export class Registry {
1944
2006
  if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
1945
2007
  else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
1946
2008
  }
1947
- for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
2009
+ for (const entry of slice.presence ?? []) presence.push({ ...(typeof entry.deviceID === "string" ? { deviceID: entry.deviceID } : {}), sessionID: entry.sessionID, at: entry.at })
1948
2010
  for (const sessionID of slice.busy ?? []) busy.add(sessionID)
1949
2011
  }
1950
2012
  }
@@ -126,6 +126,8 @@ export interface PushPrefs {
126
126
  * (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
127
127
  */
128
128
  providerError: boolean
129
+ /** (CON-04) The Concierge needs you: its questions, permissions, Decisions, messages and spoken replies (opens /concierge). */
130
+ concierge: boolean
129
131
  }
130
132
 
131
133
  export const PUSH_PREF_KEYS = [
@@ -147,6 +149,7 @@ export const PUSH_PREF_KEYS = [
147
149
  "decision",
148
150
  "message",
149
151
  "providerError",
152
+ "concierge",
150
153
  ] as const satisfies readonly (keyof PushPrefs)[]
151
154
 
152
155
  /** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
@@ -170,6 +173,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
170
173
  decision: true,
171
174
  message: true,
172
175
  providerError: true,
176
+ concierge: true,
173
177
  })
174
178
 
175
179
  /** Default and allowed range of a device's activity update interval, in minutes. */
@@ -0,0 +1,34 @@
1
+ // Shared storage shapes of the team modules (HWA-33): the staffing aggregate is read by the definition store (a
2
+ // publish must not drop a staffed role) and owned by seats.ts.
3
+ import { CONTRACT_VERSION, type TeamStaffing } from "../../protocol"
4
+ import { AgentError } from "../agent/errors"
5
+
6
+ /** One aggregate per team (key: team ID): its staffing records, current and ended, and the staffing Idempotency-Keys. */
7
+ export const TEAM_SEATS = "team-seats"
8
+
9
+ export interface SeatKey {
10
+ readonly staffingID: string
11
+ readonly digest: string
12
+ readonly at: number
13
+ }
14
+
15
+ export interface SeatsDoc {
16
+ readonly v: 1
17
+ readonly teamID: string
18
+ readonly seats: readonly TeamStaffing[]
19
+ readonly keys: Readonly<Record<string, SeatKey>>
20
+ }
21
+
22
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
23
+
24
+ /** The stored staffing aggregate (undefined when missing); newer versions are unsupported, malformed ones internal. */
25
+ export function readSeatsDoc(raw: unknown, teamID: string): SeatsDoc | undefined {
26
+ if (raw === undefined) return undefined
27
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
28
+ throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "team-staffing" } })
29
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.seats)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
30
+ return { v: 1, teamID, seats: raw.seats as TeamStaffing[], keys: isObj(raw.keys) ? (raw.keys as Record<string, SeatKey>) : {} }
31
+ }
32
+
33
+ /** Current seats (not ended). */
34
+ export const currentSeats = (doc: SeatsDoc | undefined): TeamStaffing[] => (doc?.seats ?? []).filter((s) => s.until === undefined)
@@ -0,0 +1,346 @@
1
+ // Team-aware discovery (HWA-40; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams
2
+ // "Discovery (HWA-40)"; decisions zltiwb4s 2026-10-28 addition, r3vv4eon (+correction), 5etngvgm, qxzrdc6c).
3
+ //
4
+ // Who sees whom, for who / status / sessions / show / log / watch (with or without ?session=) and events:
5
+ // - A session caller holding a seat in at least one ACTIVE team (paused teams count; archived ones do not — the same
6
+ // rule teamDeliveryVerdict uses) is team-scoped: it sees itself, every current member of its teams (grouped by team,
7
+ // with each member's roles there) and, unless the owner turned `teamIsolation` on, sessions in no active team.
8
+ // Sessions seated only in teams the caller is not in are invisible; so are the caller's teammates' seats in teams
9
+ // the caller is not in (never listed). Under isolation, sessions in no team are hidden too.
10
+ // - Sessions in no active team, the user (console/device) and app tokens keep today's owner-wide view (r3vv4eon),
11
+ // whatever teamIsolation says (the frozen contract: isolation hides non-team sessions FROM TEAM MEMBERS only).
12
+ // They may filter by team. Without any team the answers are exactly today's.
13
+ // - An invisible session reads exactly like a foreign or unknown one: not_found "No session <addr>", and address
14
+ // resolution never counts it (no ambiguity, no candidate, no prefix match reveals it): Directory.resolve takes the
15
+ // visibility predicate for that.
16
+ // - Every census reads the teams and seats fresh (no cache), so staffing, unstaffing, replacement generations,
17
+ // pause/archive and the isolation setting apply to the very next read.
18
+ //
19
+ // Directions: `reach` is what the caller may send to a member (`send`) and receive from it (`receive`), each kind
20
+ // asked of teamDeliveryVerdict (never re-derived): per team with that team as the explicit context (the `#team`
21
+ // address), and per entry without one (a plain address). Visibility is not authorization: delivery is still enforced
22
+ // by the TeamPolicy (HWA-34) and acting by grants.
23
+ //
24
+ // Team role addresses (`@@role#team`, `@@role.project#team`) resolve here for reads (show, log, watch): the sessions
25
+ // currently seated in that role of that active team, restricted to what the caller may see.
26
+ //
27
+ // Cost per census: one team list (paged, ≤ TEAM_LIMITS.teamsPerUser) and one current-seat read per active team, plus
28
+ // the settings for team-scoped callers.
29
+ import { parseTeamRoleAddress, teamDeliveryVerdict, type TeamMessageKind, type TeamSeat } from "../../protocol"
30
+ import type { AgentActor } from "../agent/actor"
31
+ import type { Directory, DirectoryEntry, Resolved } from "../agent/directory"
32
+ import { AgentError } from "../agent/errors"
33
+ import type { Registry } from "../registry"
34
+ import type { TeamSeatStore } from "./seats"
35
+ import type { Team, TeamStore } from "./store"
36
+
37
+ const KINDS: readonly TeamMessageKind[] = ["msg", "ask"]
38
+ /** Pages of the team list read per census (teamsPerUser is 100; a page holds the list's default limit). */
39
+ const MAX_TEAM_PAGES = 20
40
+ /** Most candidates listed in an ambiguous error (as the directory). */
41
+ const CANDIDATES_MAX = 20
42
+
43
+ /** What the caller may send to / receive from a session, from teamDeliveryVerdict. */
44
+ export interface Reach {
45
+ readonly send: TeamMessageKind[]
46
+ readonly receive: TeamMessageKind[]
47
+ readonly direction: "two-way" | "outbound" | "inbound" | "none"
48
+ /** The caller can send nothing to it (a restricted path, or outside the caller's teams). */
49
+ readonly unreachable?: true
50
+ }
51
+
52
+ /** One team a listed session is seated in. */
53
+ export interface TeamMembership {
54
+ readonly teamID: string
55
+ readonly team: string
56
+ readonly roles: string[]
57
+ /** Team-scoped callers only, never for themselves: with this team as the explicit context. */
58
+ readonly reach?: Reach
59
+ }
60
+
61
+ export interface TeamGroupMember {
62
+ readonly sessionID: string
63
+ readonly role: string
64
+ readonly generation: number
65
+ }
66
+
67
+ /** A team in a discovery answer, with its listed members. */
68
+ export interface TeamGroup {
69
+ readonly teamID: string
70
+ readonly name: string
71
+ readonly title: string
72
+ readonly mode: "open" | "restricted"
73
+ readonly paused?: true
74
+ /** The caller's own roles there (team-scoped callers). */
75
+ readonly yourRoles?: string[]
76
+ readonly members: TeamGroupMember[]
77
+ }
78
+
79
+ /** A who entry: the directory entry plus additive team fields. */
80
+ export type DiscoveryEntry = DirectoryEntry & {
81
+ readonly harness?: string
82
+ readonly teams?: TeamMembership[]
83
+ /** Team-scoped callers only, never for themselves: what a plain address (no #team) reaches. */
84
+ readonly reach?: Reach
85
+ }
86
+
87
+ export interface WhoAnswer {
88
+ readonly sessions: DiscoveryEntry[]
89
+ /** Present when any listed session is seated in a team (always for team-scoped callers). */
90
+ readonly teams?: TeamGroup[]
91
+ /** "team" when the caller sees its teams only; absent for the owner-wide view. */
92
+ readonly scope?: "team"
93
+ }
94
+
95
+ export interface WhoFilter {
96
+ readonly project?: string
97
+ readonly role?: string
98
+ /** A team name or ID. */
99
+ readonly team?: string
100
+ }
101
+
102
+ interface Seat extends TeamSeat {
103
+ readonly generation: number
104
+ }
105
+
106
+ /** One read of the user's active teams and current seats, and what the caller may see. */
107
+ export interface Census {
108
+ readonly userID: string
109
+ /** Set when the caller is team-scoped. */
110
+ readonly callerID?: string
111
+ readonly callerTeams: ReadonlySet<string>
112
+ readonly isolation: boolean
113
+ readonly teams: ReadonlyMap<string, Team>
114
+ /** Current seats in active teams, per session. */
115
+ readonly seats: ReadonlyMap<string, readonly Seat[]>
116
+ }
117
+
118
+ export interface TeamDiscoveryDeps {
119
+ readonly teams: Pick<TeamStore, "list" | "settings">
120
+ readonly seats: Pick<TeamSeatStore, "list">
121
+ readonly directory: Pick<Directory, "who" | "resolve">
122
+ /** Harness per reported session of the user (who entries carry it when known). */
123
+ readonly harnesses?: (userID: string) => ReadonlyMap<string, string>
124
+ }
125
+
126
+ export const teamScoped = (census: Census): boolean => census.callerTeams.size > 0
127
+
128
+ /** Whether the census's caller may see the session (always, for the owner-wide view). */
129
+ export function visible(census: Census, sessionID: string): boolean {
130
+ if (!teamScoped(census) || sessionID === census.callerID) return true
131
+ const seats = census.seats.get(sessionID) ?? []
132
+ if (seats.some((s) => census.callerTeams.has(s.teamID))) return true
133
+ return !census.isolation && seats.length === 0
134
+ }
135
+
136
+ const missing = (addr: string) => new AgentError("not_found", `No session ${addr}`)
137
+
138
+ /** Journal subject fields that name a session (an event naming any hidden one is hidden as a whole). */
139
+ const SESSION_SUBJECT_FIELDS = ["sessionID", "rootID", "senderID", "recipientID", "sourceSessionID", "parentID"] as const
140
+
141
+ /**
142
+ * The journal events a team-scoped caller may read (unfiltered GET /events and GET /watch, HWA-28): none naming a
143
+ * session it cannot see, nor a team it holds no seat in. Undefined for the owner-wide view (no filtering at all).
144
+ */
145
+ export function eventVisibility(census: Census): ((subject: Readonly<Record<string, string>>) => boolean) | undefined {
146
+ if (!teamScoped(census)) return undefined
147
+ return (subject) => {
148
+ if (subject.teamID !== undefined && !census.callerTeams.has(subject.teamID)) return false
149
+ return SESSION_SUBJECT_FIELDS.every((field) => subject[field] === undefined || visible(census, subject[field]!))
150
+ }
151
+ }
152
+
153
+ export class TeamDiscovery {
154
+ constructor(private readonly deps: TeamDiscoveryDeps) {}
155
+
156
+ /** Reads the user's active teams, their current seats and the caller's scope. */
157
+ async census(userID: string, actor: AgentActor): Promise<Census> {
158
+ const teams = new Map<string, Team>()
159
+ let cursor: string | undefined
160
+ for (let page = 0; page < MAX_TEAM_PAGES; page++) {
161
+ const { items, cursor: next } = await this.deps.teams.list(userID, { state: "active", ...(cursor ? { cursor } : {}) })
162
+ for (const team of items) teams.set(team.id, team)
163
+ if (!next) break
164
+ cursor = next
165
+ }
166
+ const seats = new Map<string, Seat[]>()
167
+ for (const teamID of [...teams.keys()]) {
168
+ let current
169
+ try {
170
+ current = await this.deps.seats.list(userID, teamID, { current: true })
171
+ } catch {
172
+ teams.delete(teamID) // a seat aggregate this build cannot read takes no part (as in delivery)
173
+ continue
174
+ }
175
+ for (const s of current) seats.set(s.sessionID, [...(seats.get(s.sessionID) ?? []), { teamID, role: s.role, generation: s.generation }])
176
+ }
177
+ const callerID = actor.kind === "session" ? actor.sessionID : undefined
178
+ const callerTeams = new Set((callerID !== undefined ? (seats.get(callerID) ?? []) : []).map((s) => s.teamID))
179
+ let isolation = false
180
+ if (callerTeams.size) {
181
+ try {
182
+ isolation = (await this.deps.teams.settings(userID)).teamIsolation
183
+ } catch {
184
+ isolation = true // unreadable settings: show less, never more
185
+ }
186
+ }
187
+ return { userID, ...(callerTeams.size ? { callerID } : {}), callerTeams, isolation, teams, seats }
188
+ }
189
+
190
+ /**
191
+ * The sessions an address names for the caller (Directory.resolve plus `@@role#team`), never an invisible one.
192
+ * not_found when nothing visible matches; ambiguous lists visible candidates only.
193
+ */
194
+ resolve(census: Census, addr: string): Resolved {
195
+ const team = parseTeamRoleAddress(addr)
196
+ if (!team) return this.deps.directory.resolve(census.userID, addr, { visible: (id) => visible(census, id) })
197
+ const record = [...census.teams.values()].find((t) => t.name === team.team)
198
+ if (!record || (teamScoped(census) && !census.callerTeams.has(record.id))) throw missing(addr)
199
+ const sessions: DirectoryEntry[] = []
200
+ for (const [sessionID, seats] of census.seats) {
201
+ if (!seats.some((s) => s.teamID === record.id && s.role === team.role) || !visible(census, sessionID)) continue
202
+ const entry = this.entry(census.userID, sessionID)
203
+ if (entry && (team.project === undefined || entry.project === team.project)) sessions.push(entry)
204
+ }
205
+ if (!sessions.length) throw missing(addr)
206
+ return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions }
207
+ }
208
+
209
+ /** Exactly one visible session (a role with several visible holders is ambiguous). */
210
+ resolveOne(census: Census, addr: string): DirectoryEntry {
211
+ const { sessions } = this.resolve(census, addr)
212
+ if (sessions.length > 1) {
213
+ const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
214
+ throw new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
215
+ }
216
+ return sessions[0]!
217
+ }
218
+
219
+ /** GET /who: the caller's visible sessions, filtered, with team memberships, reach and groups. */
220
+ who(census: Census, filter: WhoFilter = {}): WhoAnswer {
221
+ const { userID } = census
222
+ const project = filter.project?.toLowerCase()
223
+ let role = filter.role?.replace(/^@@/, "")
224
+ let teamName = filter.team
225
+ if (role?.includes("#")) {
226
+ const parsed = parseTeamRoleAddress(`@@${role}`)
227
+ if (!parsed) throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project and #team")
228
+ if (teamName !== undefined && teamName !== parsed.team) throw new AgentError("invalid", "role names another team than team")
229
+ role = parsed.project ? `${parsed.role}.${parsed.project}` : parsed.role
230
+ teamName = parsed.team
231
+ }
232
+ const team = teamName !== undefined ? this.team(census, teamName) : undefined
233
+ const [roleName, inlineProject] = role?.split(".") ?? []
234
+ const roleProject = inlineProject?.toLowerCase() ?? project
235
+ // Global role holders (Directory.who validates the role) and the base list.
236
+ const globalHolders = role !== undefined ? new Set(this.deps.directory.who(userID, { ...(project ? { project } : {}), role }).map((e) => e.sessionID)) : undefined
237
+ const listed = new Map(this.deps.directory.who(userID, project ? { project } : {}).map((e) => [e.sessionID, e] as const))
238
+ // Seated members the directory does not list (offline, unnamed): every teammate, and a filtered team's members.
239
+ const extra = new Set(team ? [team.id] : [...census.callerTeams])
240
+ for (const [sessionID, seats] of census.seats) {
241
+ if (listed.has(sessionID) || !seats.some((s) => extra.has(s.teamID))) continue
242
+ const entry = this.entry(userID, sessionID)
243
+ if (entry && !entry.archived && (project === undefined || entry.project === project || entry.roles.some((r) => r.project === project))) listed.set(sessionID, entry)
244
+ }
245
+ const entries = [...listed.values()]
246
+ .filter((e) => visible(census, e.sessionID))
247
+ .filter((e) => !team || this.shown(census, e.sessionID).some((s) => s.teamID === team.id))
248
+ .filter((e) => {
249
+ if (role === undefined) return true
250
+ const seated = this.shown(census, e.sessionID).some((s) => s.role === roleName && (!team || s.teamID === team.id) && (roleProject === undefined || e.project === roleProject))
251
+ return seated || (!team && globalHolders!.has(e.sessionID))
252
+ })
253
+ const harnesses = this.deps.harnesses?.(userID)
254
+ const sessions = entries.map((e) => this.annotate(census, e, harnesses?.get(e.sessionID)))
255
+ const teams = this.groups(census, sessions.map((s) => s.sessionID), team?.id)
256
+ return { sessions, ...(teams.length ? { teams } : {}), ...(teamScoped(census) ? { scope: "team" as const } : {}) }
257
+ }
258
+
259
+ /** Team groups of the listed sessions (the caller's teams only, all of them, when team-scoped), by team name. */
260
+ groups(census: Census, sessionIDs: readonly string[], only?: string): TeamGroup[] {
261
+ const out = new Map<string, TeamGroupMember[]>()
262
+ if (teamScoped(census)) for (const teamID of census.callerTeams) if (only === undefined || only === teamID) out.set(teamID, [])
263
+ for (const id of sessionIDs) {
264
+ for (const s of this.shown(census, id)) {
265
+ if (only !== undefined && s.teamID !== only) continue
266
+ out.set(s.teamID, [...(out.get(s.teamID) ?? []), { sessionID: id, role: s.role, generation: s.generation }])
267
+ }
268
+ }
269
+ return [...out]
270
+ .map(([teamID, members]) => {
271
+ const team = census.teams.get(teamID)!
272
+ const yours = census.callerID !== undefined ? (census.seats.get(census.callerID) ?? []).filter((s) => s.teamID === teamID).map((s) => s.role) : []
273
+ return { teamID, name: team.name, title: team.title, mode: team.mode, ...(team.paused ? { paused: true as const } : {}), ...(yours.length ? { yourRoles: yours } : {}), members }
274
+ })
275
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
276
+ }
277
+
278
+ /** The session's team memberships as the caller may see them (show), each with reach when team-scoped. */
279
+ memberships(census: Census, sessionID: string): TeamMembership[] {
280
+ const byTeam = new Map<string, string[]>()
281
+ for (const s of this.shown(census, sessionID)) byTeam.set(s.teamID, [...(byTeam.get(s.teamID) ?? []), s.role])
282
+ return [...byTeam].map(([teamID, roles]) => {
283
+ const reach = this.reachOf(census, sessionID, teamID)
284
+ return { teamID, team: census.teams.get(teamID)!.name, roles, ...(reach ? { reach } : {}) }
285
+ })
286
+ }
287
+
288
+ /** What a team-scoped caller may send to / receive from the session (undefined otherwise, and for itself). */
289
+ reachOf(census: Census, sessionID: string, teamID?: string): Reach | undefined {
290
+ const caller = census.callerID
291
+ if (!teamScoped(census) || caller === undefined || sessionID === caller) return undefined
292
+ const mine = census.seats.get(caller) ?? []
293
+ const theirs = census.seats.get(sessionID) ?? []
294
+ const allowed = (from: readonly Seat[], to: readonly Seat[], kind: TeamMessageKind) =>
295
+ teamDeliveryVerdict({ kind, toUser: false, replyToPermittedAsk: false, senderSeats: from, recipientSeats: to, ...(teamID !== undefined ? { teamID } : {}), teams: census.teams }).allow
296
+ const send = KINDS.filter((k) => allowed(mine, theirs, k))
297
+ const receive = KINDS.filter((k) => allowed(theirs, mine, k))
298
+ const direction = send.length && receive.length ? "two-way" : send.length ? "outbound" : receive.length ? "inbound" : "none"
299
+ return { send, receive, direction, ...(send.length ? {} : { unreachable: true as const }) }
300
+ }
301
+
302
+ // ---------- internals ----------
303
+
304
+ /** The seats of a session the caller may be told about: in its own teams when team-scoped, else all. */
305
+ private shown(census: Census, sessionID: string): readonly Seat[] {
306
+ const seats = census.seats.get(sessionID) ?? []
307
+ return teamScoped(census) ? seats.filter((s) => census.callerTeams.has(s.teamID)) : seats
308
+ }
309
+
310
+ private annotate(census: Census, entry: DirectoryEntry, harness: string | undefined): DiscoveryEntry {
311
+ const teams = this.memberships(census, entry.sessionID)
312
+ const reach = this.reachOf(census, entry.sessionID)
313
+ return { ...entry, ...(harness ? { harness } : {}), ...(teams.length ? { teams } : {}), ...(reach ? { reach } : {}) }
314
+ }
315
+
316
+ /** A team filter: by name or ID, among the caller's teams when team-scoped (else not_found, like a missing one). */
317
+ private team(census: Census, nameOrID: string): Team {
318
+ const team = census.teams.get(nameOrID) ?? [...census.teams.values()].find((t) => t.name === nameOrID)
319
+ if (!team || (teamScoped(census) && !census.callerTeams.has(team.id))) throw new AgentError("not_found", `No team ${nameOrID}`)
320
+ return team
321
+ }
322
+
323
+ private entry(userID: string, sessionID: string): DirectoryEntry | undefined {
324
+ try {
325
+ return this.deps.directory.resolve(userID, sessionID).sessions.find((e) => e.sessionID === sessionID)
326
+ } catch {
327
+ return undefined
328
+ }
329
+ }
330
+ }
331
+
332
+ /** The harness of each session the user's spokes report now (roots and their subagents), from the catalog. */
333
+ export function catalogHarnesses(registry: Pick<Registry, "catalog" | "sessionTenant">): (userID: string) => ReadonlyMap<string, string> {
334
+ return (userID) => {
335
+ const out = new Map<string, string>()
336
+ for (const machine of registry.catalog()) {
337
+ for (const process of machine.processes) {
338
+ for (const s of process.sessions) {
339
+ if (registry.sessionTenant(s.id) !== userID) continue
340
+ for (const id of [s.id, ...s.status.map((e) => e.id)]) if (!out.has(id)) out.set(id, process.harness)
341
+ }
342
+ }
343
+ }
344
+ return out
345
+ }
346
+ }