@huynle/hub 0.8.0 → 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 (112) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +84 -15
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +649 -242
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  9. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  10. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  11. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  12. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  13. package/packages/cli/src/cli.ts +3 -3
  14. package/packages/cli/src/http.ts +1 -0
  15. package/packages/cli/src/launches.ts +9 -3
  16. package/packages/cli/src/login.ts +2 -2
  17. package/packages/cli/src/mcp.ts +7 -3
  18. package/packages/cli/src/messaging.ts +4 -2
  19. package/packages/cli/src/profiles-mcp.ts +301 -0
  20. package/packages/cli/src/setup/commands.ts +20 -2
  21. package/packages/cli/src/spoke-services.ts +1 -1
  22. package/packages/cli/src/teams-mcp.ts +59 -2
  23. package/packages/codex/package.json +1 -1
  24. package/packages/codex/src/paths.ts +3 -3
  25. package/packages/node/package.json +1 -1
  26. package/packages/node/src/config.ts +10 -2
  27. package/packages/node/src/opencode-warm.ts +27 -8
  28. package/packages/node/src/run.ts +3 -2
  29. package/packages/preset/package.json +1 -1
  30. package/packages/protocol/package.json +1 -1
  31. package/packages/protocol/src/index.ts +7 -1
  32. package/packages/protocol/src/launch.ts +14 -0
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/errors.ts +1 -0
  37. package/packages/sdk/src/index.ts +1 -0
  38. package/packages/sdk/src/types.ts +22 -2
  39. package/packages/sdk/src/wake.ts +20 -0
  40. package/packages/spoke-sdk/package.json +1 -1
  41. package/src/central/agent/acting.ts +3 -0
  42. package/src/central/agent/audit.ts +1 -1
  43. package/src/central/agent/decisions.ts +58 -2
  44. package/src/central/agent/delivery.ts +101 -27
  45. package/src/central/agent/errors.ts +2 -0
  46. package/src/central/agent/escalation.ts +10 -2
  47. package/src/central/agent/events.ts +7 -1
  48. package/src/central/agent/launch-engine.ts +173 -30
  49. package/src/central/agent/launch-routes.ts +20 -9
  50. package/src/central/agent/launch-spec.ts +26 -2
  51. package/src/central/agent/launch-store.ts +55 -2
  52. package/src/central/agent/message-limits.ts +89 -11
  53. package/src/central/agent/message-routes.ts +8 -3
  54. package/src/central/agent/messages.ts +3 -2
  55. package/src/central/agent/placement.ts +93 -18
  56. package/src/central/agent/read-routes.ts +3 -0
  57. package/src/central/agent/routes.ts +4 -2
  58. package/src/central/agent/webhooks.ts +17 -2
  59. package/src/central/artifacts/jobs-routes.ts +4 -1
  60. package/src/central/concierge/board-routes.ts +13 -2
  61. package/src/central/concierge/inbound.ts +4 -7
  62. package/src/central/concierge/prompt.ts +4 -3
  63. package/src/central/concierge/proposals.ts +8 -2
  64. package/src/central/concierge/routes.ts +62 -2
  65. package/src/central/concierge/session-routes.ts +62 -6
  66. package/src/central/concierge/supervisor.ts +34 -7
  67. package/src/central/concierge/watchlist-routes.ts +4 -2
  68. package/src/central/config.ts +21 -5
  69. package/src/central/http.ts +38 -15
  70. package/src/central/main.ts +1 -1
  71. package/src/central/registry.ts +26 -1
  72. package/src/central/teams/intake.ts +20 -6
  73. package/src/central/teams/staffing.ts +22 -0
  74. package/src/central/workspaces/sidechats-routes.ts +1 -1
  75. package/src/index.ts +39 -18
  76. package/src/migrate.ts +109 -3
  77. package/src/spoke/env-inject.ts +8 -1
  78. package/src/spoke/hub-tool.ts +24 -10
  79. package/src/spoke/launchers.ts +6 -4
  80. package/src/spoke/listing.ts +18 -6
  81. package/src/spoke/localhub.ts +14 -4
  82. package/src/spoke/locations.ts +40 -0
  83. package/src/spoke/session-context.ts +107 -27
  84. package/src/spoke/spoke.ts +13 -5
  85. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  86. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  87. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  88. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  89. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  90. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  91. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  92. package/web/dist/assets/index-BabrNDB3.css +1 -0
  93. package/web/dist/assets/index-lIOfSYck.js +34 -0
  94. package/web/dist/assets/live-BDq0AABg.js +5 -0
  95. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  96. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
  97. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
  98. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
  99. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  100. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  101. package/web/dist/index.html +4 -4
  102. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  103. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  104. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  105. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  106. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  107. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  108. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  109. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  110. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  111. package/web/dist/assets/live-YyTjE47g.js +0 -5
  112. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -5,6 +5,9 @@
5
5
  // POST /api/agent/concierge/enable user {expectedRevision, timeZone?} → {settings, status}
6
6
  // POST /api/agent/concierge/disable user {expectedRevision} → {settings, status}
7
7
  // POST /api/agent/concierge/restart user → {status}
8
+ // GET /api/agent/concierge/machines/:id/models user → {models: [{providerID, modelID, name, enabled}]} (FX-04:
9
+ // the Model dropdown; the machine's OpenCode service catalog, read through a session it reports; 404 for a
10
+ // machine that is not the user's, 503 offline without a connected service or when the spoke cannot answer)
8
11
  // GET /api/agent/concierge/notes user; the Concierge → {text, revision, updatedAt?}
9
12
  // PUT /api/agent/concierge/notes user; the Concierge {text, expectedRevision} → {text, revision, updatedAt}
10
13
  //
@@ -32,14 +35,71 @@ export async function admitConcierge(identity: ConciergeIdentity, actor: AgentAc
32
35
  throw new AgentError("not_found", "Not found")
33
36
  }
34
37
 
35
- export function conciergeRoutes(deps: { readonly service: ConciergeService; readonly identity: ConciergeIdentity }): AgentRoute[] {
36
- const { service, identity } = deps
38
+ /** (FX-04) One entry of a machine's model list: names only (never provider options or keys). */
39
+ export interface MachineModel {
40
+ readonly providerID: string
41
+ readonly modelID: string
42
+ readonly name: string
43
+ readonly enabled: boolean
44
+ }
45
+
46
+ /** (FX-04) What the models route needs of the registry and the spokes. */
47
+ export interface MachineModelPorts {
48
+ /** The machine's connected OpenCode service connection. */
49
+ readonly serviceFor: (machineID: string) => string | undefined
50
+ readonly reportedSessions: (connID: string) => readonly string[]
51
+ readonly request: (connID: string, req: { readonly op: "models"; readonly sessionID: string }) => Promise<{ readonly models?: readonly unknown[] }>
52
+ }
53
+
54
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
55
+
56
+ /**
57
+ * (FX-04) The machine's OpenCode model list. The spoke's `models` op is machine-wide but addressed through a session it
58
+ * reports (any: its catalog is the same); a service that reports no session yet cannot be asked (the console falls back
59
+ * to typing the model).
60
+ */
61
+ export function machineModels(ports: MachineModelPorts): (machineID: string) => Promise<MachineModel[]> {
62
+ return async (machineID) => {
63
+ const connID = ports.serviceFor(machineID)
64
+ if (connID === undefined) throw new AgentError("offline", "No OpenCode service is connected on that machine")
65
+ const sessionID = ports.reportedSessions(connID)[0]
66
+ if (sessionID === undefined) throw new AgentError("offline", "Its OpenCode service reports no session yet, so its model list cannot be read")
67
+ let result: { readonly models?: readonly unknown[] }
68
+ try {
69
+ result = await ports.request(connID, { op: "models", sessionID })
70
+ } catch (cause) {
71
+ throw new AgentError("offline", `Could not read the machine's model list: ${String(cause instanceof Error ? cause.message : cause).slice(0, 200)}`)
72
+ }
73
+ return (result.models ?? []).flatMap((m) =>
74
+ isObject(m) && typeof m.providerID === "string" && typeof m.modelID === "string"
75
+ ? [{ providerID: m.providerID, modelID: m.modelID, name: typeof m.name === "string" ? m.name : m.modelID, enabled: m.enabled !== false }]
76
+ : [],
77
+ )
78
+ }
79
+ }
80
+
81
+ export function conciergeRoutes(deps: { readonly service: ConciergeService; readonly identity: ConciergeIdentity; readonly models?: (machineID: string) => Promise<MachineModel[]> }): AgentRoute[] {
82
+ const { service, identity, models } = deps
37
83
  return [
38
84
  { method: "GET", path: PATH, allow: {}, handle: async ({ user }) => ok(await service.view(user.id)) },
39
85
  { method: "PUT", path: `${PATH}/settings`, allow: {}, handle: async ({ request, user }) => ok(await service.updateSettings(user.id, await boundedJson(request))) },
40
86
  { method: "POST", path: `${PATH}/enable`, allow: {}, handle: async ({ request, user }) => ok(await service.enable(user.id, await boundedJson(request))) },
41
87
  { method: "POST", path: `${PATH}/disable`, allow: {}, handle: async ({ request, user }) => ok(await service.disable(user.id, await boundedJson(request))) },
42
88
  { method: "POST", path: `${PATH}/restart`, allow: {}, handle: async ({ user }) => ok(await service.restart(user.id)) },
89
+ ...(models
90
+ ? [
91
+ {
92
+ method: "GET",
93
+ path: `${PATH}/machines/:id/models`,
94
+ allow: {},
95
+ handle: async ({ params, user }) => {
96
+ // Another user's machine and an unknown one answer alike.
97
+ if (!service.machineViews(user.id).some((m) => m.id === params.id)) throw new AgentError("not_found", "No such machine")
98
+ return ok({ models: await models(params.id!) })
99
+ },
100
+ } satisfies AgentRoute,
101
+ ]
102
+ : []),
43
103
  {
44
104
  method: "GET",
45
105
  path: `${PATH}/notes`,
@@ -47,23 +47,79 @@ export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; reado
47
47
 
48
48
  const key = (method: string, path: string) => `${method} ${path}`
49
49
 
50
+ /**
51
+ * (FX-07) How long a Concierge identity check waits for the state store (the route gate, the acting guard, the inbound
52
+ * and outbound rules, team staffing): past it the check fails closed with `busy` (503).
53
+ */
54
+ export const IDENTITY_TIMEOUT_MS = 5_000
55
+
56
+ /** (FX-07) The refusal while the hub's state does not answer: neutral (no Concierge wording), retried by clients. */
57
+ export const stateBusy = () => new AgentError("busy", "Hub state is busy, try again", { extra: { retryAfter: Math.ceil(IDENTITY_TIMEOUT_MS / 1000) } })
58
+
59
+ /** `read`, or a `busy` rejection once `ms` passed (the timer never outlives the answer; a late answer is ignored). */
60
+ function within<T>(read: Promise<T>, ms: number): Promise<T> {
61
+ let timer: ReturnType<typeof setTimeout> | undefined
62
+ const late = new Promise<never>((_, reject) => {
63
+ timer = setTimeout(() => reject(stateBusy()), ms)
64
+ })
65
+ read.catch(() => {}) // a late rejection is not unhandled
66
+ return Promise.race([read, late]).finally(() => clearTimeout(timer))
67
+ }
68
+
69
+ const TIMED = Symbol("concierge.timedIdentity")
70
+
71
+ /**
72
+ * (FX-07) The identity with a time limit on each read: past it, the read rejects `busy`. A user whose read timed out
73
+ * is answered `busy` at once for the next `ms` (no new store read: a stuck store is not asked again per check, so
74
+ * the checks of one request share one wait and reads do not pile up). Wrapping a timed identity returns it as is.
75
+ */
76
+ export function timedIdentity(identity: ConciergeIdentity, ms: number = IDENTITY_TIMEOUT_MS, now: () => number = Date.now): ConciergeIdentity {
77
+ if ((identity as { [TIMED]?: true })[TIMED]) return identity
78
+ const stuckUntil = new Map<string, number>()
79
+ const timed = {
80
+ [TIMED]: true as const,
81
+ async current(userID: string) {
82
+ const until = stuckUntil.get(userID)
83
+ if (until !== undefined) {
84
+ if (now() < until) throw stateBusy()
85
+ stuckUntil.delete(userID)
86
+ }
87
+ try {
88
+ return await within(identity.current(userID), ms)
89
+ } catch (cause) {
90
+ if (cause instanceof AgentError && cause.code === "busy") stuckUntil.set(userID, now() + ms)
91
+ throw cause
92
+ }
93
+ },
94
+ }
95
+ return timed
96
+ }
97
+
50
98
  /**
51
99
  * The gate agentApi runs for every session actor before the route's own checks: a Concierge session (current or
52
- * previous) may call only the allow-list and `extra` (the tiers slot's routes). A failed identity read refuses.
100
+ * previous) may call only the allow-list and `extra` (the tiers slot's routes). (FX-07) An identity read that fails or
101
+ * has not answered within `timeoutMs` refuses every other route with a neutral 503 `busy` (fail closed, without
102
+ * telling an ordinary session it is the Concierge); the allow-list still passes.
53
103
  */
54
- export function conciergeSessionGate(identity: ConciergeIdentity, extra: () => readonly AgentRoute[] = () => []): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
104
+ export function conciergeSessionGate(
105
+ identity: ConciergeIdentity,
106
+ extra: () => readonly AgentRoute[] = () => [],
107
+ options: { readonly timeoutMs?: number } = {},
108
+ ): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
55
109
  const allowed = new Set(CONCIERGE_SESSION_ROUTES.map((r) => key(r.method, r.path)))
110
+ const timed = timedIdentity(identity, options.timeoutMs ?? IDENTITY_TIMEOUT_MS)
56
111
  return async (actor, route) => {
57
112
  if (actor.kind !== "session") return
113
+ const listed = () => allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)
58
114
  let concierge: boolean
59
115
  try {
60
- const current = await identity.current(actor.userID)
116
+ const current = await timed.current(actor.userID)
61
117
  concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
62
118
  } catch {
63
- concierge = true
119
+ if (listed()) return
120
+ throw stateBusy()
64
121
  }
65
- if (!concierge) return
66
- if (allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)) return
122
+ if (!concierge || listed()) return
67
123
  throw new AgentError("forbidden", "The Concierge cannot use this route", { extra: { policy: "concierge" } })
68
124
  }
69
125
  }
@@ -126,13 +126,30 @@ export class StatusStore {
126
126
  }
127
127
  }
128
128
 
129
- /** The Concierge-only authority read from the state record: the current session and every hidden one. */
130
- export function recordIdentity(store: StatusStore): { current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> } {
129
+ export interface IdentityOptions {
130
+ /**
131
+ * (FX-07) The user's sessions a machine reports locked now (its spoke holds them as Concierge sessions: they carry
132
+ * an agent or a context revision). The state record keeps only PAST_SESSIONS_MAX past sessions; an older one its
133
+ * machine still reports is still a Concierge session (and one no machine reports cannot use its tokens).
134
+ */
135
+ readonly reported?: (userID: string) => Iterable<string>
136
+ }
137
+
138
+ /**
139
+ * The Concierge-only authority read from the state record: the current session and every hidden one (plus, FX-07,
140
+ * those a machine reports locked). A failing report adds nothing; the record's own sessions always count.
141
+ */
142
+ export function recordIdentity(store: StatusStore, options: IdentityOptions = {}): { current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> } {
131
143
  return {
132
144
  current: async (userID) => {
133
145
  const r = await store.get(userID)
134
- const all = [...new Set([...(r.pastSessionIDs ?? []), ...r.hiddenSessionIDs])]
135
- return { ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}), hiddenSessionIDs: all }
146
+ const all = new Set([...(r.pastSessionIDs ?? []), ...r.hiddenSessionIDs])
147
+ try {
148
+ if (options.reported) for (const id of options.reported(userID)) all.add(id)
149
+ } catch (cause) {
150
+ console.error(`[hub] concierge locked-session report failed: ${(cause as Error).message}`)
151
+ }
152
+ return { ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}), hiddenSessionIDs: [...all] }
136
153
  },
137
154
  }
138
155
  }
@@ -158,6 +175,9 @@ export function conciergeLaunchSpec(userID: string, settings: ConciergeSettings,
158
175
  }
159
176
  }
160
177
 
178
+ /** A queued launch's waiting reason as the Concierge's status: "Waiting: …" (kept when it already says Waiting). */
179
+ const waitingText = (reason: string): string => (reason.startsWith("Waiting") ? reason : `Waiting: ${reason}`)
180
+
161
181
  /** Whether a launch is a Concierge launch (its label). */
162
182
  export const isConciergeLaunch = (launch: Pick<Launch, "spec">): boolean => launch.spec.labels?.["hub/system"] === "concierge"
163
183
 
@@ -410,12 +430,19 @@ export class Supervisor {
410
430
  await this.follow(userID, settings, await this.store.read(userID))
411
431
  }
412
432
 
413
- /** The recorded launch: running → adopt it; ended → back off; else wait. */
433
+ /** The recorded launch: running → adopt it; ended → back off; else wait (starting says why it waits, FX-02). */
414
434
  private async follow(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
415
- const r = snap.record
416
- const launch = await this.deps.launches.view(userID, r.launchID!)
435
+ const launch = await this.deps.launches.view(userID, snap.record.launchID!)
417
436
  if (!launch || launch.state === "ended") return this.ended(userID, snap, launch)
418
437
  if (launch.state === "running" && launch.sessionID !== undefined) return this.adopt(userID, snap, launch.sessionID, launch.machineID)
438
+ if (snap.record.state === "starting") {
439
+ const reason = launch.state === "queued" && launch.waiting ? waitingText(launch.waiting.reason) : "Starting"
440
+ if (snap.record.reason !== reason) {
441
+ if (!(await this.write(userID, snap, { ...snap.record, reason }))) return
442
+ snap = await this.store.read(userID)
443
+ }
444
+ }
445
+ const r = snap.record
419
446
  if (launch.sessionID !== undefined && !r.hiddenSessionIDs.includes(launch.sessionID)) {
420
447
  // Hidden from the console's lists as soon as the session exists, and never auto-named from its title.
421
448
  if (await this.write(userID, snap, { ...r, hiddenSessionIDs: addHidden(r.hiddenSessionIDs, launch.sessionID) })) await this.unname(userID, [launch.sessionID])
@@ -140,8 +140,10 @@ export async function watchFacts(deps: WatchFactDeps, userID: string, hidden: re
140
140
  }
141
141
  const prefs = deps.store.viewPrefs(userID)
142
142
  const archived = new Set(prefs.hiddenSessions)
143
- // (CON-12) Machines that report their sessions now: a session one of them no longer reports went away.
144
- const reporting = new Set(catalog.filter((m) => m.processes.length > 0).map((m) => m.id))
143
+ // (CON-12) Machines that report their sessions now: a session one of them no longer reports went away. (FX-07) A
144
+ // process reports only once its first sessions frame came (right after hello it lists none yet), so a machine with
145
+ // a process still pending is not reporting: its sessions are offline for that moment, never gone.
146
+ const reporting = new Set(catalog.filter((m) => m.processes.length > 0 && m.processes.every((p) => !p.sessionsPending)).map((m) => m.id))
145
147
  for (const record of deps.store.agentData(userID).sessions ?? []) {
146
148
  const known = facts.get(record.sessionID)
147
149
  if (!known) {
@@ -7,7 +7,7 @@ import path from "node:path"
7
7
  import { hubEnv } from "../env"
8
8
  import { isLoopbackHost } from "../loopback"
9
9
  import { formatPattern, parsePattern } from "./agent/policy"
10
- import { APP_DIR, configBase, legacyPaths, migrateFirst, stateBase } from "../migrate"
10
+ import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
11
11
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
12
12
 
13
13
  export const GOOGLE_ISSUER = "https://accounts.google.com"
@@ -99,6 +99,11 @@ export interface HubLaunches {
99
99
  readonly maxActive?: number
100
100
  /** New launches per clock hour. */
101
101
  readonly perHour?: number
102
+ /**
103
+ * Minutes the same node instance may leave a running launch out of its reports (its session gone) before it ends
104
+ * lost (default 5; 0 or false: never).
105
+ */
106
+ readonly lostAfterMinutes?: number | false
102
107
  }
103
108
 
104
109
  /** hub.json `agents`, validated. */
@@ -133,7 +138,9 @@ const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "au
133
138
  const CLUSTER_KEYS = ["database", "valkey"] as const
134
139
  const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
135
140
  const WEBHOOK_KEYS = new Set(["allowPrivate"])
136
- const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour"] as const
141
+ const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour", "lostAfterMinutes"] as const
142
+ /** The longest lostAfterMinutes (a day): far below setTimeout's 32-bit limit. */
143
+ const LOST_AFTER_MAX = 1440
137
144
  const LAUNCH_CAP_MAX = 100_000
138
145
  const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
139
146
  const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
@@ -322,13 +329,20 @@ function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (
322
329
  function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
323
330
  if (!isObj(raw)) fail("agents.launches must be an object")
324
331
  const input = raw as Record<string, unknown>
325
- const caps: Record<string, number> = {}
332
+ const caps: Record<string, number | false> = {}
326
333
  for (const key of Object.keys(input)) {
327
334
  if (!(LAUNCH_CAP_KEYS as readonly string[]).includes(key)) {
328
335
  warnings.push(`${file}: unknown key "agents.launches.${key}" ignored`)
329
336
  continue
330
337
  }
331
338
  const value = input[key]
339
+ if (key === "lostAfterMinutes") {
340
+ if (value !== false && (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > LOST_AFTER_MAX)) {
341
+ fail(`agents.launches.lostAfterMinutes must be a whole number from 0 to ${LOST_AFTER_MAX}, or false (0 and false: never)`)
342
+ }
343
+ caps[key] = value as number | false
344
+ continue
345
+ }
332
346
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > LAUNCH_CAP_MAX) {
333
347
  fail(`agents.launches.${key} must be a whole number from 1 to ${LAUNCH_CAP_MAX}`)
334
348
  }
@@ -462,7 +476,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
462
476
  host,
463
477
  port,
464
478
  publicURL,
465
- stateDir: env.HUB_STATE_DIR || file.stateDir || path.join(stateBase(home), APP_DIR),
479
+ stateDir: env.HUB_STATE_DIR || file.stateDir || resolveHubStateDir(env, home).dir,
466
480
  apiKey: env.OPENROUTER_API_KEY || undefined,
467
481
  sttModel: env.HUB_STT_MODEL || "openai/gpt-4o-mini-transcribe",
468
482
  ttsModel: env.HUB_TTS_MODEL || "microsoft/mai-voice-2.1-flash",
@@ -504,7 +518,9 @@ export function loadCentralConfig(
504
518
  const config = centralConfig(env, home, hub)
505
519
  if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
506
520
  // hub.pid is the spoke's auto-start lock for the legacy location, not state.
507
- migrateFirst(legacyPaths(stateBase(home)), config.stateDir, { ...migrate, skip: ["hub.pid"] })
521
+ migrateStateDir(stateLegacyPaths(env, home), config.stateDir, { ...migrate, skip: ["hub.pid"] })
522
+ const resolvedDir = resolveHubStateDir(env, home)
523
+ if (resolvedDir.hint) warnings.push(resolvedDir.hint)
508
524
  }
509
525
  return { config, warnings, path: file, loaded: hub !== undefined }
510
526
  }
@@ -91,9 +91,10 @@ import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge
91
91
  import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
92
92
  import { watchedSessions } from "./concierge/notifier"
93
93
  import { NotesStore } from "./concierge/notes"
94
- import { conciergeSessionGate } from "./concierge/session-routes"
94
+ import { conciergeSessionGate, timedIdentity } from "./concierge/session-routes"
95
95
  import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
96
96
  import { ConciergeService, type MachineFacts } from "./concierge/service"
97
+ import { machineModels } from "./concierge/routes"
97
98
  import { SettingsStore } from "./concierge/settings"
98
99
  import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
99
100
  import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
@@ -409,7 +410,12 @@ export function startCentral(options: CentralOptions): Central {
409
410
  // CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
410
411
  // push remapping all follow it); the service itself is built below, once its parts exist.
411
412
  const conciergeStatus = new StatusStore(backends.state, now)
412
- const conciergeIdentity = recordIdentity(conciergeStatus)
413
+ // (FX-07) Beyond the past sessions the record keeps: those a machine reports locked (its spoke holds them as Concierge sessions).
414
+ const conciergeIdentity = recordIdentity(conciergeStatus, { reported: (userID) => registry.lockedSessions(userID) })
415
+ // (FX-07) The identity checks on request paths (route gate, manage guard, acting guard, inbound/outbound rules, team
416
+ // staffing, Decision and audit labels) wait at most IDENTITY_TIMEOUT_MS for the store: past it they refuse with busy
417
+ // (503), never hang; one shared breaker answers busy at once for a while after a read timed out.
418
+ const conciergeTimedIdentity = timedIdentity(conciergeIdentity)
413
419
  // Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
414
420
  const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
415
421
  /** SSE subscribers, one user each. */
@@ -796,7 +802,7 @@ export function startCentral(options: CentralOptions): Central {
796
802
  const delivery = new Delivery({
797
803
  teams: teamPolicy,
798
804
  // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
799
- inbound: conciergeInbound({ identity: conciergeIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
805
+ inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
800
806
  state: backends.state,
801
807
  counters: backends.counters,
802
808
  registry,
@@ -814,6 +820,8 @@ export function startCentral(options: CentralOptions): Central {
814
820
  },
815
821
  // Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
816
822
  alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
823
+ // A message pending for 24 h expired undelivered: journaled (message.changed, delivery "expired") for its sender.
824
+ expired: (userID, message) => feed.publish(userID, message),
817
825
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
818
826
  // Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
819
827
  ...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
@@ -851,7 +859,8 @@ export function startCentral(options: CentralOptions): Central {
851
859
  if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
852
860
  await conciergeTierHooks.checkVia(userID, decision, via, by)
853
861
  },
854
- concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID),
862
+ // (FX-07) Time-limited and fail closed: a timed-out read refuses the ask with busy (no label silently dropped).
863
+ concierge: conciergeProtects(conciergeTimedIdentity),
855
864
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
856
865
  })
857
866
  leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
@@ -891,7 +900,8 @@ export function startCentral(options: CentralOptions): Central {
891
900
  // Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
892
901
  // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
893
902
  if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
894
- launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launch) }, { tenant: userID }), { origin: true })
903
+ // The user's live view: flagged when the launch's app token was revoked (launches is set below, before any change).
904
+ launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: userID }), { origin: true })
895
905
  // Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
896
906
  let profileApplier: ProfileApplier | undefined
897
907
  const launches = new LaunchEngine({
@@ -914,7 +924,15 @@ export function startCentral(options: CentralOptions): Central {
914
924
  users: () => store.users().map((u) => u.id),
915
925
  now,
916
926
  onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
927
+ // FX-02: a waiting text change of the same kind: the console's live stream (and the Concierge's status) only,
928
+ // never the journal or webhooks.
929
+ onLaunchWaiting: (launch) => {
930
+ broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: launch.owner })
931
+ if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
932
+ },
917
933
  intents,
934
+ // FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
935
+ launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
918
936
  profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
919
937
  // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
920
938
  ...(config.cluster
@@ -1055,7 +1073,7 @@ export function startCentral(options: CentralOptions): Central {
1055
1073
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
1056
1074
  // Every stored agent message also goes to the user's console streams (live transcript rows).
1057
1075
  feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
1058
- const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID) })
1076
+ const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID) })
1059
1077
  const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
1060
1078
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1061
1079
  /** An agent as envelopes name it (a session by its directory name). */
@@ -1114,11 +1132,11 @@ export function startCentral(options: CentralOptions): Central {
1114
1132
  const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1115
1133
  // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1116
1134
  const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
1117
- const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now })
1135
+ const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1118
1136
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1119
1137
  launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1120
1138
  // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
1121
- teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text)).message.id }) })
1139
+ teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text, options = {}) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, options.hop === undefined ? {} : { chainHop: options.hop })).message.id }), hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }) })
1122
1140
  // Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
1123
1141
  // supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
1124
1142
  const workspaceCursors = journalCursors(tokenKeys, agentEvents)
@@ -1278,7 +1296,7 @@ export function startCentral(options: CentralOptions): Central {
1278
1296
  state: backends.state,
1279
1297
  counters: backends.counters,
1280
1298
  decisions,
1281
- acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeIdentity), conciergeActor: conciergeProtects(conciergeIdentity) })),
1299
+ acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity) })),
1282
1300
  audit,
1283
1301
  protects: conciergeProtects(conciergeIdentity),
1284
1302
  published: (userID, message) => feed.publish(userID, message),
@@ -1365,7 +1383,7 @@ export function startCentral(options: CentralOptions): Central {
1365
1383
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1366
1384
  })
1367
1385
  void conciergeLeader.start()
1368
- const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeIdentity, protects: conciergeProtects(conciergeIdentity), service: conciergeService, routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1386
+ const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1369
1387
  const resolveActor = createResolver({
1370
1388
  cookieName: COOKIE,
1371
1389
  findDevice: (token) => deviceOfToken(token),
@@ -1383,7 +1401,7 @@ export function startCentral(options: CentralOptions): Central {
1383
1401
  },
1384
1402
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
1385
1403
  // CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
1386
- gate: conciergeSessionGate(conciergeIdentity, () => conciergeParts.tiers.routes()),
1404
+ gate: conciergeSessionGate(conciergeTimedIdentity, () => conciergeParts.tiers.routes()),
1387
1405
  })
1388
1406
 
1389
1407
  // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
@@ -1401,11 +1419,12 @@ export function startCentral(options: CentralOptions): Central {
1401
1419
  const { pathname } = url
1402
1420
  const method = request.method
1403
1421
  try {
1404
- if (method === "GET" && pathname === "/readyz") return readyz()
1405
- if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
1406
- if (method === "GET" && pathname === "/healthz") {
1407
- return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
1422
+ // HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
1423
+ if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
1424
+ const response = pathname === "/readyz" ? await readyz() : healthz()
1425
+ return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response
1408
1426
  }
1427
+ if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
1409
1428
  // A shared (cluster) store loads its records on first use; the file store is ready at once.
1410
1429
  await store.ready()
1411
1430
  if (method === "POST" && pathname === "/spoke/enroll") {
@@ -2472,6 +2491,10 @@ export function startCentral(options: CentralOptions): Central {
2472
2491
  }
2473
2492
 
2474
2493
  /** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
2494
+ function healthz(): Response {
2495
+ return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
2496
+ }
2497
+
2475
2498
  async function readyz(): Promise<Response> {
2476
2499
  if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
2477
2500
  if (config.cluster) {
@@ -68,7 +68,7 @@ Usage: hub-server [--help | --version] (also installed as opencode-hub and ope
68
68
  Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
69
69
  HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
70
70
  HUB_PUBLIC_URL origin used in links and cookies (default http://<host>:<port>)
71
- HUB_STATE_DIR machines, devices, keys (default ~/.local/state/hub)
71
+ HUB_STATE_DIR machines, devices, keys (default $XDG_STATE_HOME/hub or ~/.local/state/hub)
72
72
  OPENROUTER_API_KEY default speech key; or set speech providers in the web app (Settings → Speech)
73
73
  HUB_CONFIG path of hub.json
74
74
 
@@ -253,6 +253,8 @@ export interface CatalogProcess {
253
253
  readonly connected: number
254
254
  readonly lastSeen: number
255
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
256
258
  }
257
259
 
258
260
  /** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
@@ -279,6 +281,8 @@ interface Conn {
279
281
  sessions: Map<string, SessionInfo>
280
282
  /** The last sessions frame as JSON, to skip unchanged snapshots. */
281
283
  sessionsKey: string
284
+ /** (FX-07) No sessions frame yet. */
285
+ sessionsPending?: true
282
286
  /** sessionID → activity sequence number of the last event this process sent for it. */
283
287
  activity: Map<string, number>
284
288
  /**
@@ -304,6 +308,8 @@ export interface SliceConn {
304
308
  readonly launchID?: string
305
309
  /** The machine's terminal context, when this connection sent the latest one. */
306
310
  readonly terminals?: MachineTerminals
311
+ /** (FX-07) No sessions frame yet (absent from older pods: reported). */
312
+ readonly sessionsPending?: true
307
313
  }
308
314
 
309
315
  /** A root session's status tree (live: run starts and recommendations included) and activity feed. */
@@ -598,6 +604,7 @@ export class Registry {
598
604
  lastSeen: t,
599
605
  sessions: new Map(),
600
606
  sessionsKey: "[]",
607
+ sessionsPending: true,
601
608
  activity: new Map(),
602
609
  })
603
610
  this.changed(this.tenant(this.conns.get(connID)!))
@@ -640,7 +647,9 @@ export class Registry {
640
647
  conn.lastSeen = this.now()
641
648
  // Spokes re-send their snapshot periodically; only changes reach the phones.
642
649
  const key = JSON.stringify(sessions)
643
- if (key !== conn.sessionsKey) {
650
+ const first = conn.sessionsPending === true
651
+ delete conn.sessionsPending
652
+ if (key !== conn.sessionsKey || first) {
644
653
  conn.sessionsKey = key
645
654
  conn.sessions = new Map(sessions.map((s) => [s.id, s]))
646
655
  this.syncBoards()
@@ -1232,6 +1241,7 @@ export class Registry {
1232
1241
  features: [...conn.features].sort(),
1233
1242
  connected: conn.connected,
1234
1243
  lastSeen: conn.lastSeen,
1244
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1235
1245
  sessions: this.visible(conn).map((s) => {
1236
1246
  const fields = this.browserFields(s.id)
1237
1247
  return {
@@ -1249,6 +1259,19 @@ export class Registry {
1249
1259
  return [...machines.values()]
1250
1260
  }
1251
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
+
1252
1275
  /** The session as reported by any process plus its live status; undefined when nobody reports it. */
1253
1276
  session(sessionID: string): SessionView | undefined {
1254
1277
  let info: SessionInfo | undefined
@@ -1893,6 +1916,7 @@ export class Registry {
1893
1916
  activity: [...conn.activity],
1894
1917
  ...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
1895
1918
  ...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
1919
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1896
1920
  }
1897
1921
  })
1898
1922
  const boards: SliceBoard[] = [...this.boards]
@@ -1966,6 +1990,7 @@ export class Registry {
1966
1990
  sessionsKey: "",
1967
1991
  activity: new Map(c.activity ?? []),
1968
1992
  ...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
1993
+ ...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
1969
1994
  remote: { pod, tenant },
1970
1995
  })
1971
1996
  if (c.terminals) terminals.set(c.machine.id, c.terminals)