@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,767 @@
1
+ // CON-04 Concierge supervisor (contract "Supervise / relaunch", "State record", "Disable"): one hub-launched,
2
+ // hub-owned OpenCode session per user, on the machine the user picked in Settings → Concierge.
3
+ //
4
+ // State record: collection `concierge`, key `state`, per user: the public ConciergeStatus plus launchSeq (the launch
5
+ // key counter), pendingSeq (a launch being accepted), attempts (relaunch times, last hour), backoffIndex,
6
+ // runningSince, fresh (the next launch follows an enable or a Restart, not a failure) and lastLaunchID. Every write
7
+ // is a compare-and-set on the record's version (StatusStore.cas): a deposed leader, a second pod or a racing route
8
+ // handler loses the race instead of launching twice, and the launch key (concierge:<user hash>:<seq>) is written
9
+ // BEFORE LaunchEngine.accept, so a retried accept of the same seq is idempotent.
10
+ //
11
+ // One pass (reconcile, serialized per user in this process; never rejects):
12
+ // settings disabled → finish a disable whose record is not disabled yet (a crash between the settings and record
13
+ // writes, or one left stopping; failed waits for the user), release stray @@concierge holders, stop live launches
14
+ // failed → nothing (Restart, or disable + enable)
15
+ // a launch recorded → follow it: running → claim @@concierge (managed, exclusive; other holders released first),
16
+ // hide the session, push its context; ended for any reason → backoff (CONCIERGE_LIMITS.relaunchBackoffMs, reset
17
+ // after healthyAfterMs running) or failed past relaunchesPerHour; queued/provisioning → keep waiting
18
+ // a launch being accepted → accept the same key again (idempotent)
19
+ // backoff not due → nothing
20
+ // otherwise → entitlements.check, the machine's eligibility (ineligible → waiting_machine with its reason), then
21
+ // a new launch: in process, `by: "app:concierge"`, no prompt, no name, no roles (the user's standing setting is
22
+ // the authority; audited as concierge.launch / concierge.relaunch)
23
+ // Live Concierge launches (label hub/system=concierge) other than the recorded one are stopped (orphans of a lost
24
+ // race or a restart), and every live one while the Concierge is disabled. Each relaunch is a new session; the old
25
+ // ID stays in hiddenSessionIDs (at most 20, oldest dropped). (CON-09a) A host session outlives its stopped launch on
26
+ // the machine's OpenCode, so every previous session (Restart, a relaunch, and each other hidden one when a new one is
27
+ // adopted) is retired: its context replaced by a locked, powerless one (no board, notes or tools).
28
+ import { createHash } from "node:crypto"
29
+ import { CONCIERGE_AGENT, CONCIERGE_LIMITS, CONCIERGE_PARTY, CONCIERGE_ROLE, type ConciergeSettings, type ConciergeStatus } from "../../../packages/protocol/src/concierge"
30
+ import type { Clock, StateStore, Timers } from "../backend/types"
31
+ import type { Directory } from "../agent/directory"
32
+ import { AgentError } from "../agent/errors"
33
+ import type { Grants } from "../agent/grants"
34
+ import type { LaunchEngine } from "../agent/launch-engine"
35
+ import type { Launch } from "../agent/launch-store"
36
+ import type { ConciergeEntitlements } from "./entitlements"
37
+ import { CONCIERGE_COLLECTION } from "./notes"
38
+ import type { SettingsStore } from "./settings"
39
+
40
+ const KEY = "state"
41
+ const HOUR_MS = 3_600_000
42
+ export const SUPERVISOR_TICK_MS = 5_000
43
+ /** Why a Concierge found on another agent is suspended (Settings shows it). */
44
+ export const CONCIERGE_TAMPERED = "The Concierge's agent was changed outside the hub"
45
+ /** The label every Concierge launch carries (never interpreted by the hub elsewhere). */
46
+ export const CONCIERGE_LABEL = { "hub/system": "concierge" } as const
47
+
48
+ export interface ConciergeRecord extends ConciergeStatus {
49
+ readonly launchSeq: number
50
+ readonly pendingSeq?: number
51
+ readonly attempts: readonly number[]
52
+ readonly backoffIndex: number
53
+ readonly runningSince?: number
54
+ readonly fresh?: boolean
55
+ readonly lastLaunchID?: string
56
+ readonly settingsRevision?: number
57
+ /**
58
+ * Every Concierge session this user ever had (at most PAST_SESSIONS_MAX, oldest dropped), beyond the 20 the status
59
+ * shows as hidden: a past Concierge session never becomes an ordinary session (identity, guards, gate).
60
+ */
61
+ readonly pastSessionIDs?: readonly string[]
62
+ }
63
+
64
+ export const PAST_SESSIONS_MAX = 500
65
+
66
+ const DEFAULT_RECORD: ConciergeRecord = { state: "disabled", hiddenSessionIDs: [], generation: 0, since: 0, launchSeq: 0, attempts: [], backoffIndex: 0 }
67
+
68
+ /** The public status of a record (no internals, no undefined fields). */
69
+ export function statusOf(r: ConciergeRecord): ConciergeStatus {
70
+ return {
71
+ state: r.state,
72
+ ...(r.reason !== undefined ? { reason: r.reason.slice(0, 200) } : {}),
73
+ ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}),
74
+ hiddenSessionIDs: [...r.hiddenSessionIDs],
75
+ ...(r.launchID !== undefined ? { launchID: r.launchID } : {}),
76
+ ...(r.machineID !== undefined ? { machineID: r.machineID } : {}),
77
+ generation: r.generation,
78
+ since: r.since,
79
+ ...(r.nextAttemptAt !== undefined ? { nextAttemptAt: r.nextAttemptAt } : {}),
80
+ }
81
+ }
82
+
83
+ /** Drops undefined fields (records are stored as JSON). */
84
+ const clean = <T extends object>(value: T): T => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
85
+
86
+ export interface RecordSnapshot {
87
+ readonly record: ConciergeRecord
88
+ readonly version: number
89
+ }
90
+
91
+ /** The per-user state record, written only by compare-and-set. */
92
+ export class StatusStore {
93
+ constructor(
94
+ private readonly state: StateStore,
95
+ private readonly now: Clock = Date.now,
96
+ ) {}
97
+
98
+ async read(userID: string): Promise<RecordSnapshot> {
99
+ const stored = await this.state.get<ConciergeRecord>(userID, CONCIERGE_COLLECTION, KEY)
100
+ return { record: stored?.value ?? DEFAULT_RECORD, version: stored?.version ?? 0 }
101
+ }
102
+
103
+ async get(userID: string): Promise<ConciergeRecord> {
104
+ return (await this.read(userID)).record
105
+ }
106
+
107
+ /** Writes `next` when the record is still at `snap`'s version; the past sessions only ever grow (bounded). */
108
+ async cas(userID: string, snap: RecordSnapshot, next: ConciergeRecord): Promise<boolean> {
109
+ const past = [...new Set([...(snap.record.pastSessionIDs ?? []), ...snap.record.hiddenSessionIDs, ...next.hiddenSessionIDs, ...(next.pastSessionIDs ?? []), ...(next.sessionID !== undefined ? [next.sessionID] : [])])].slice(-PAST_SESSIONS_MAX)
110
+ return (await this.state.put(userID, CONCIERGE_COLLECTION, KEY, clean({ ...next, pastSessionIDs: past }), { ifVersion: snap.version })).ok
111
+ }
112
+
113
+ /** Read-modify-write, retried on a lost race. */
114
+ async update(userID: string, change: (current: ConciergeRecord) => ConciergeRecord): Promise<ConciergeRecord> {
115
+ for (let attempt = 0; attempt < 16; attempt++) {
116
+ const snap = await this.read(userID)
117
+ const next = clean(change(snap.record))
118
+ if (await this.cas(userID, snap, next)) return next
119
+ }
120
+ throw new AgentError("conflict", "The Concierge state kept changing; retry")
121
+ }
122
+
123
+ /** The clock (records' `since`). */
124
+ time(): number {
125
+ return this.now()
126
+ }
127
+ }
128
+
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[] }> } {
143
+ return {
144
+ current: async (userID) => {
145
+ const r = await store.get(userID)
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] }
153
+ },
154
+ }
155
+ }
156
+
157
+ /** A Concierge launch key: one per launch attempt of the user. */
158
+ export function conciergeLaunchKey(userID: string, seq: number): string {
159
+ return `concierge:${createHash("sha256").update(userID).digest("hex").slice(0, 16)}:${seq}`
160
+ }
161
+
162
+ /** The launch spec of attempt `seq` (contract "Launch"). */
163
+ export function conciergeLaunchSpec(userID: string, settings: ConciergeSettings, seq: number): Record<string, unknown> {
164
+ return {
165
+ key: conciergeLaunchKey(userID, seq),
166
+ roles: [],
167
+ harness: "opencode",
168
+ agent: CONCIERGE_AGENT,
169
+ ...(settings.model !== undefined ? { model: settings.model } : {}),
170
+ workspace: { repo: settings.directory, mode: "checkout" },
171
+ runtime: { kind: "host" },
172
+ placement: { machine: settings.machineID, mode: "require" },
173
+ labels: { ...CONCIERGE_LABEL },
174
+ end: { when: "never", cleanup: "keep" },
175
+ }
176
+ }
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
+
181
+ /** Whether a launch is a Concierge launch (its label). */
182
+ export const isConciergeLaunch = (launch: Pick<Launch, "spec">): boolean => launch.spec.labels?.["hub/system"] === "concierge"
183
+
184
+ export const addHidden = (hidden: readonly string[], sessionID: string): string[] =>
185
+ hidden.includes(sessionID) ? [...hidden] : [...hidden, sessionID].slice(-CONCIERGE_LIMITS.hiddenHistory)
186
+
187
+ export interface Eligibility {
188
+ readonly eligible: boolean
189
+ readonly online: boolean
190
+ readonly reason?: string
191
+ }
192
+
193
+ export interface SupervisorDeps {
194
+ readonly state: StateStore
195
+ readonly settings: SettingsStore
196
+ readonly launches: Pick<LaunchEngine, "accept" | "view" | "stop"> & Partial<Pick<LaunchEngine, "list">>
197
+ /** (CON-09a) `unname`: Concierge sessions hold no @name (none derived from their titles). */
198
+ readonly directory: Pick<Directory, "resolve" | "claimRole" | "releaseRole"> & Partial<Pick<Directory, "unname">>
199
+ readonly grants: Pick<Grants, "revoke">
200
+ /** Whether the user's machine can host the Concierge now (a node, a launch- and sessionContext-capable spoke). */
201
+ readonly eligibility: (userID: string, machineID: string) => Eligibility
202
+ readonly entitlements: ConciergeEntitlements
203
+ /** The session context (context.ts): pushed once running, cleared on disable. Never rejects in production. */
204
+ readonly context: {
205
+ push(userID: string, sessionID: string): Promise<void>
206
+ clear(userID: string, sessionID: string): Promise<void>
207
+ /**
208
+ * (CON-09a) A previous Concierge session: locked (the concierge agent kept) and powerless (no board, notes or
209
+ * tools), so a session left running on its machine can do nothing. Never rejects in production.
210
+ */
211
+ retire?(userID: string, sessionID: string): Promise<void>
212
+ }
213
+ /** Every change of the public status (console SSE, journal). */
214
+ readonly onStatus?: (userID: string, status: ConciergeStatus) => void
215
+ /** A session became the Concierge: detach it from workspaces and teams (it is hub-owned). Never rejects in production. */
216
+ readonly onAdopted?: (userID: string, sessionID: string) => Promise<void>
217
+ /** The Concierge was suspended (an alert for the user). */
218
+ readonly onSuspended?: (userID: string, sessionID: string) => void
219
+ /** The admin audit: concierge.launch | relaunch | restart | disable | suspended. */
220
+ readonly audit?: (userID: string, action: string) => Promise<void>
221
+ readonly users: () => Promise<Iterable<string>>
222
+ readonly now?: Clock
223
+ readonly timers?: Timers
224
+ readonly tickMs?: number
225
+ }
226
+
227
+ const defaultTimers: Timers = {
228
+ set: (fn, ms) => {
229
+ const handle = setTimeout(fn, ms)
230
+ ;(handle as { unref?: () => void }).unref?.()
231
+ return handle
232
+ },
233
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
234
+ }
235
+
236
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)).slice(0, 160)
237
+ const ignorable = (cause: unknown) => cause instanceof AgentError && (cause.code === "gone" || cause.code === "not_found")
238
+
239
+ export class Supervisor {
240
+ readonly store: StatusStore
241
+ private readonly now: Clock
242
+ private readonly timers: Timers
243
+ private readonly chains = new Map<string, Promise<void>>()
244
+ private running = false
245
+ private timer: unknown
246
+
247
+ constructor(private readonly deps: SupervisorDeps) {
248
+ this.now = deps.now ?? Date.now
249
+ this.timers = deps.timers ?? defaultTimers
250
+ this.store = new StatusStore(deps.state, this.now)
251
+ }
252
+
253
+ // ---- identity and status ----
254
+
255
+ /** The user's current Concierge session (state record) and every hidden one: the Concierge-only authority. */
256
+ current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> {
257
+ return recordIdentity(this.store).current(userID)
258
+ }
259
+
260
+ async status(userID: string): Promise<ConciergeStatus> {
261
+ return statusOf(await this.store.get(userID))
262
+ }
263
+
264
+ // ---- leader loop ----
265
+
266
+ /** Leader elected: a pass over every user now, then every tickMs. */
267
+ start(): void {
268
+ if (this.running) return
269
+ this.running = true
270
+ void this.tick()
271
+ }
272
+
273
+ /** Deposed (or shutting down): no more passes from this process. */
274
+ stop(): void {
275
+ this.running = false
276
+ if (this.timer !== undefined) this.timers.clear(this.timer)
277
+ this.timer = undefined
278
+ }
279
+
280
+ get leading(): boolean {
281
+ return this.running
282
+ }
283
+
284
+ /** Something about the user changed (a launch, a node, a spoke): a pass now when this process supervises. */
285
+ kick(userID: string): void {
286
+ if (this.running) void this.reconcile(userID)
287
+ }
288
+
289
+ private async tick(): Promise<void> {
290
+ try {
291
+ for (const userID of await this.deps.users()) {
292
+ if (!this.running) break
293
+ await this.reconcile(userID)
294
+ }
295
+ } catch (cause) {
296
+ console.error(`[hub] concierge supervisor pass failed: ${errorText(cause)}`)
297
+ } finally {
298
+ if (this.running) this.timer = this.timers.set(() => void (this.running ? this.tick() : undefined), this.deps.tickMs ?? SUPERVISOR_TICK_MS)
299
+ }
300
+ }
301
+
302
+ /** One pass for the user (serialized per user in this process). Never rejects. */
303
+ reconcile(userID: string): Promise<void> {
304
+ const previous = this.chains.get(userID) ?? Promise.resolve()
305
+ const run = previous.then(() => this.pass(userID)).catch((cause) => console.error(`[hub] concierge pass failed: ${errorText(cause)}`))
306
+ this.chains.set(userID, run)
307
+ void run.finally(() => {
308
+ if (this.chains.get(userID) === run) this.chains.delete(userID)
309
+ })
310
+ return run
311
+ }
312
+
313
+ // ---- writes ----
314
+
315
+ private async write(userID: string, snap: RecordSnapshot, next: ConciergeRecord): Promise<boolean> {
316
+ const ok = await this.store.cas(userID, snap, next)
317
+ if (ok) this.changed(userID, snap.record, next)
318
+ return ok
319
+ }
320
+
321
+ private changed(userID: string, before: ConciergeRecord, after: ConciergeRecord): void {
322
+ const status = statusOf(after)
323
+ if (JSON.stringify(statusOf(before)) === JSON.stringify(status)) return
324
+ try {
325
+ this.deps.onStatus?.(userID, status)
326
+ } catch (cause) {
327
+ console.error(`[hub] concierge status listener failed: ${errorText(cause)}`)
328
+ }
329
+ }
330
+
331
+ private async audit(userID: string, action: string): Promise<void> {
332
+ await this.deps.audit?.(userID, action).catch((cause) => console.error(`[hub] concierge audit failed: ${errorText(cause)}`))
333
+ }
334
+
335
+ /** A change through update() with the status listener. */
336
+ private async mutate(userID: string, change: (r: ConciergeRecord) => ConciergeRecord): Promise<ConciergeRecord> {
337
+ let before = DEFAULT_RECORD
338
+ const after = await this.store.update(userID, (r) => ((before = r), change(r)))
339
+ this.changed(userID, before, after)
340
+ return after
341
+ }
342
+
343
+ // ---- the pass ----
344
+
345
+ private async pass(userID: string): Promise<void> {
346
+ const settings = await this.deps.settings.get(userID)
347
+ const snap = await this.store.read(userID)
348
+ const r = snap.record
349
+ if (!settings.enabled) {
350
+ // Disabled settings with a record that never got there (a crash between the two writes, or a disable left
351
+ // stopping): finish the disable. failed stays failed (the user retries).
352
+ if (r.state !== "disabled" && r.state !== "failed") await this.disable(userID)
353
+ else await this.releaseHolders(userID)
354
+ // A launch accepted while disabling (a racing or deposed pass) is stopped.
355
+ await this.reap(userID)
356
+ return
357
+ }
358
+ if (r.state === "failed") return
359
+ if (r.state === "disabled" || r.state === "stopping") {
360
+ // Enabled, but the record never left disabled (a crash between the two writes): start fresh.
361
+ await this.write(userID, snap, { ...r, state: "starting", reason: "Starting", since: this.now(), fresh: true })
362
+ return this.pass(userID)
363
+ }
364
+ if (r.launchID !== undefined) await this.follow(userID, settings, snap)
365
+ else if (r.pendingSeq !== undefined) await this.accept(userID, settings, r.pendingSeq)
366
+ else if (!(r.state === "backoff" && (r.nextAttemptAt ?? 0) > this.now())) await this.begin(userID, settings, snap)
367
+ await this.reap(userID)
368
+ }
369
+
370
+ /** A new launch attempt (see the header). */
371
+ private async begin(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
372
+ const r = snap.record
373
+ const t = this.now()
374
+ const since = (state: string) => (r.state === state ? r.since : t)
375
+ const entitled = await this.deps.entitlements.check(userID)
376
+ if (!entitled.ok) {
377
+ await this.write(userID, snap, { ...r, state: "failed", reason: entitled.reason, since: t, nextAttemptAt: undefined })
378
+ return
379
+ }
380
+ if (settings.machineID === undefined || settings.directory === undefined) {
381
+ await this.write(userID, snap, { ...r, state: "failed", reason: "Pick a machine and a directory in Settings → Concierge", since: t, nextAttemptAt: undefined })
382
+ return
383
+ }
384
+ const machine = this.deps.eligibility(userID, settings.machineID)
385
+ if (!machine.eligible) {
386
+ const reason = machine.reason ?? "Waiting for the machine"
387
+ if (r.state !== "waiting_machine" || r.reason !== reason || r.machineID !== settings.machineID) {
388
+ await this.write(userID, snap, { ...r, state: "waiting_machine", reason, machineID: settings.machineID, since: since("waiting_machine"), nextAttemptAt: undefined })
389
+ }
390
+ return
391
+ }
392
+ const relaunch = r.fresh !== true
393
+ const recent = r.attempts.filter((at) => t - at < HOUR_MS)
394
+ if (relaunch && recent.length >= CONCIERGE_LIMITS.relaunchesPerHour) return this.fail(userID, snap, recent.length)
395
+ const seq = r.launchSeq + 1
396
+ const next: ConciergeRecord = {
397
+ ...r,
398
+ state: "starting",
399
+ reason: "Starting",
400
+ since: since("starting"),
401
+ machineID: settings.machineID,
402
+ launchSeq: seq,
403
+ pendingSeq: seq,
404
+ attempts: relaunch ? [...recent, t] : recent,
405
+ fresh: undefined,
406
+ nextAttemptAt: undefined,
407
+ settingsRevision: settings.revision,
408
+ }
409
+ if (!(await this.write(userID, snap, next))) return
410
+ await this.audit(userID, relaunch ? "concierge.relaunch" : "concierge.launch")
411
+ await this.accept(userID, settings, seq)
412
+ }
413
+
414
+ /** Accepts launch attempt `seq` (idempotent per key) and records its ID. */
415
+ private async accept(userID: string, settings: ConciergeSettings, seq: number): Promise<void> {
416
+ let launch: Launch
417
+ try {
418
+ const accepted = await this.deps.launches.accept(userID, conciergeLaunchSpec(userID, settings, seq), { by: CONCIERGE_PARTY })
419
+ launch = accepted.launch
420
+ if (accepted.created) this.deps.entitlements.record(userID, { kind: "launch", n: 1 })
421
+ } catch (cause) {
422
+ const snap = await this.store.read(userID)
423
+ if (snap.record.pendingSeq !== seq) return
424
+ await this.write(userID, snap, this.backoff(snap.record, `The launch was refused: ${errorText(cause)}`))
425
+ return
426
+ }
427
+ const snap = await this.store.read(userID)
428
+ if (snap.record.pendingSeq !== seq || snap.record.launchID !== undefined) return
429
+ if (!(await this.write(userID, snap, { ...snap.record, launchID: launch.id, pendingSeq: undefined }))) return
430
+ await this.follow(userID, settings, await this.store.read(userID))
431
+ }
432
+
433
+ /** The recorded launch: running → adopt it; ended → back off; else wait (starting says why it waits, FX-02). */
434
+ private async follow(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
435
+ const launch = await this.deps.launches.view(userID, snap.record.launchID!)
436
+ if (!launch || launch.state === "ended") return this.ended(userID, snap, launch)
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
446
+ if (launch.sessionID !== undefined && !r.hiddenSessionIDs.includes(launch.sessionID)) {
447
+ // Hidden from the console's lists as soon as the session exists, and never auto-named from its title.
448
+ if (await this.write(userID, snap, { ...r, hiddenSessionIDs: addHidden(r.hiddenSessionIDs, launch.sessionID) })) await this.unname(userID, [launch.sessionID])
449
+ }
450
+ void settings
451
+ }
452
+
453
+ private async holds(userID: string, sessionID: string): Promise<boolean> {
454
+ try {
455
+ return this.deps.directory.resolve(userID, `@@${CONCIERGE_ROLE}`).sessions.some((s) => s.sessionID === sessionID)
456
+ } catch {
457
+ return false
458
+ }
459
+ }
460
+
461
+ /** Releases @@concierge from every holder but `keep` (idempotent). */
462
+ private async releaseHolders(userID: string, keep?: string): Promise<void> {
463
+ let holders: string[]
464
+ try {
465
+ holders = this.deps.directory.resolve(userID, `@@${CONCIERGE_ROLE}`).sessions.map((s) => s.sessionID)
466
+ } catch {
467
+ return
468
+ }
469
+ for (const id of holders) {
470
+ if (id === keep) continue
471
+ await this.deps.directory.releaseRole(userID, id, CONCIERGE_ROLE, undefined, { managed: true }).catch((cause) => {
472
+ if (!ignorable(cause)) throw cause
473
+ })
474
+ }
475
+ }
476
+
477
+ /** Drops (and stops deriving) the @names of Concierge sessions. */
478
+ private async unname(userID: string, sessionIDs: readonly string[]): Promise<void> {
479
+ await this.deps.directory.unname?.(userID, sessionIDs).catch((cause) => console.error(`[hub] concierge unname failed: ${errorText(cause)}`))
480
+ }
481
+
482
+ /** Retires previous Concierge sessions (see SupervisorDeps.context.retire). */
483
+ private async retire(userID: string, sessionIDs: readonly (string | undefined)[]): Promise<void> {
484
+ for (const id of new Set(sessionIDs)) {
485
+ if (id === undefined) continue
486
+ await this.deps.context.retire?.(userID, id).catch((cause) => console.error(`[hub] concierge retire failed: ${errorText(cause)}`))
487
+ }
488
+ }
489
+
490
+ private async release(userID: string, sessionID: string | undefined): Promise<void> {
491
+ if (sessionID === undefined) return
492
+ await this.deps.directory.releaseRole(userID, sessionID, CONCIERGE_ROLE, undefined, { managed: true }).catch((cause) => {
493
+ if (!ignorable(cause)) console.error(`[hub] concierge role release failed: ${errorText(cause)}`)
494
+ })
495
+ }
496
+
497
+ /** The launch runs: the session becomes the current Concierge (role, hidden, context). */
498
+ private async adopt(userID: string, snap: RecordSnapshot, sessionID: string, machineID: string | undefined): Promise<void> {
499
+ const r = snap.record
500
+ if (r.state === "running" && r.sessionID === sessionID) {
501
+ if (await this.holds(userID, sessionID)) return
502
+ // The role dropped (offline past ROLE_OFFLINE_MS): claim it again (a new generation).
503
+ }
504
+ let generation: number
505
+ try {
506
+ await this.releaseHolders(userID, sessionID)
507
+ generation = (await this.deps.directory.claimRole(userID, sessionID, CONCIERGE_ROLE, { byUser: true, exclusive: true, managed: true })).generation
508
+ } catch (cause) {
509
+ const reason = `Waiting for the session: ${errorText(cause)}`
510
+ if (r.reason !== reason) await this.write(userID, snap, { ...r, reason })
511
+ return
512
+ }
513
+ const t = this.now()
514
+ const same = r.state === "running" && r.sessionID === sessionID
515
+ const next: ConciergeRecord = {
516
+ ...r,
517
+ state: "running",
518
+ reason: undefined,
519
+ sessionID,
520
+ ...(machineID !== undefined ? { machineID } : {}),
521
+ generation,
522
+ since: same ? r.since : t,
523
+ runningSince: same ? r.runningSince : t,
524
+ hiddenSessionIDs: addHidden(r.hiddenSessionIDs, sessionID),
525
+ }
526
+ if (!(await this.write(userID, snap, next))) return
527
+ if (same) return
528
+ // The current and every past Concierge session: no @name (an auto name derived from a title, e.g. @greeting).
529
+ await this.unname(userID, [...new Set([...(r.pastSessionIDs ?? []), ...next.hiddenSessionIDs])])
530
+ await this.deps.onAdopted?.(userID, sessionID).catch((cause) => console.error(`[hub] concierge detach failed: ${errorText(cause)}`))
531
+ await this.deps.context.push(userID, sessionID).catch((cause) => console.error(`[hub] concierge context push failed: ${errorText(cause)}`))
532
+ // Every earlier Concierge session a spoke may still run (a lost race, a restart, a relaunch) is retired.
533
+ await this.retire(userID, next.hiddenSessionIDs.filter((id) => id !== sessionID))
534
+ }
535
+
536
+ /** The backoff record after a failed or ended launch. */
537
+ private backoff(r: ConciergeRecord, reason: string): ConciergeRecord {
538
+ const t = this.now()
539
+ const healthy = r.runningSince !== undefined && t - r.runningSince >= CONCIERGE_LIMITS.healthyAfterMs
540
+ const index = healthy ? 0 : r.backoffIndex
541
+ const delays = CONCIERGE_LIMITS.relaunchBackoffMs
542
+ const delay = delays[Math.min(index, delays.length - 1)]!
543
+ return {
544
+ ...r,
545
+ state: "backoff",
546
+ reason,
547
+ since: t,
548
+ launchID: undefined,
549
+ pendingSeq: undefined,
550
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
551
+ sessionID: undefined,
552
+ runningSince: undefined,
553
+ nextAttemptAt: t + delay,
554
+ backoffIndex: index + 1,
555
+ }
556
+ }
557
+
558
+ private async fail(userID: string, snap: RecordSnapshot, relaunches: number): Promise<void> {
559
+ const r = snap.record
560
+ await this.write(userID, snap, {
561
+ ...r,
562
+ state: "failed",
563
+ reason: `Relaunched ${relaunches} times in the last hour: Restart it in Settings → Concierge`,
564
+ since: this.now(),
565
+ launchID: undefined,
566
+ pendingSeq: undefined,
567
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
568
+ sessionID: undefined,
569
+ runningSince: undefined,
570
+ nextAttemptAt: undefined,
571
+ })
572
+ }
573
+
574
+ /** The launch ended for any reason but a service stop (those clear launchID first): relaunch with backoff. */
575
+ private async ended(userID: string, snap: RecordSnapshot, launch: Launch | undefined): Promise<void> {
576
+ const r = snap.record
577
+ const t = this.now()
578
+ const recent = r.attempts.filter((at) => t - at < HOUR_MS)
579
+ const ok = recent.length >= CONCIERGE_LIMITS.relaunchesPerHour ? (await this.fail(userID, snap, recent.length), true) : await this.write(userID, snap, this.backoff(r, `The Concierge session ended (${launch?.outcome ?? "gone"}); relaunching`))
580
+ if (ok) {
581
+ await this.release(userID, r.sessionID)
582
+ await this.retire(userID, [r.sessionID])
583
+ }
584
+ }
585
+
586
+ /** Stops live Concierge launches other than the recorded one (orphans). */
587
+ private async reap(userID: string): Promise<void> {
588
+ if (!this.deps.launches.list) return
589
+ const r = await this.store.get(userID)
590
+ const live = await this.deps.launches.list(userID, { states: ["queued", "placing", "provisioning", "running"] })
591
+ const disabled = r.state === "disabled" || r.state === "failed"
592
+ for (const launch of live) {
593
+ if (!isConciergeLaunch(launch)) continue
594
+ if (!disabled && launch.id === r.launchID) continue
595
+ if (!disabled && r.pendingSeq !== undefined && launch.spec.key === conciergeLaunchKey(userID, r.pendingSeq)) continue
596
+ await this.deps.launches.stop(userID, launch.id, "Not the current Concierge").catch((cause) => {
597
+ if (!ignorable(cause)) console.error(`[hub] concierge orphan stop failed: ${errorText(cause)}`)
598
+ })
599
+ }
600
+ }
601
+
602
+ // ---- user operations (the service calls them) ----
603
+
604
+ /**
605
+ * Disable, after the settings say disabled: stopping, then revoke the grant, stop the launch(es), release
606
+ * @@concierge, clear the context; then disabled. Each step is idempotent; a failed one leaves failed with a
607
+ * reason (retry by disabling again). Notes and watchlist are kept.
608
+ */
609
+ async disable(userID: string): Promise<ConciergeStatus> {
610
+ const before = await this.store.get(userID)
611
+ if (before.state === "disabled") {
612
+ await this.releaseHolders(userID).catch(() => undefined)
613
+ return statusOf(before)
614
+ }
615
+ const stopping = await this.mutate(userID, (r) => ({ ...r, state: "stopping", reason: "Stopping", since: this.now(), nextAttemptAt: undefined }))
616
+ const problems: string[] = []
617
+ const step = async (fn: () => Promise<unknown>) => {
618
+ try {
619
+ await fn()
620
+ } catch (cause) {
621
+ if (!ignorable(cause)) problems.push(errorText(cause))
622
+ }
623
+ }
624
+ await step(async () => {
625
+ const settings = await this.deps.settings.get(userID)
626
+ if (settings.grantID === undefined) return
627
+ await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => {
628
+ if (!ignorable(cause)) throw cause
629
+ })
630
+ await this.deps.settings.update(userID, (s) => {
631
+ const { grantID: _gone, ...rest } = s
632
+ return rest
633
+ })
634
+ })
635
+ await step(async () => {
636
+ if (stopping.launchID !== undefined) await this.deps.launches.stop(userID, stopping.launchID, "The Concierge was disabled").catch((cause) => {
637
+ if (!ignorable(cause)) throw cause
638
+ })
639
+ if (!this.deps.launches.list) return
640
+ for (const launch of await this.deps.launches.list(userID, { states: ["queued", "placing", "provisioning", "running"] })) {
641
+ if (!isConciergeLaunch(launch)) continue
642
+ await this.deps.launches.stop(userID, launch.id, "The Concierge was disabled").catch((cause) => {
643
+ if (!ignorable(cause)) throw cause
644
+ })
645
+ }
646
+ })
647
+ await step(() => this.releaseHolders(userID))
648
+ if (stopping.sessionID !== undefined) await this.deps.context.clear(userID, stopping.sessionID).catch(() => undefined)
649
+ const t = this.now()
650
+ const done = await this.mutate(userID, (r) =>
651
+ problems.length
652
+ ? { ...r, state: "failed", reason: `Disable did not finish: ${problems[0]}; disable again`, since: t }
653
+ : {
654
+ ...r,
655
+ state: "disabled",
656
+ reason: undefined,
657
+ since: t,
658
+ sessionID: undefined,
659
+ launchID: undefined,
660
+ pendingSeq: undefined,
661
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
662
+ nextAttemptAt: undefined,
663
+ runningSince: undefined,
664
+ attempts: [],
665
+ backoffIndex: 0,
666
+ fresh: undefined,
667
+ hiddenSessionIDs: r.sessionID !== undefined ? addHidden(r.hiddenSessionIDs, r.sessionID) : r.hiddenSessionIDs,
668
+ },
669
+ )
670
+ if (!problems.length) await this.audit(userID, "concierge.disable")
671
+ return statusOf(done)
672
+ }
673
+
674
+ /** Enable (the settings already say enabled): a fresh start. */
675
+ async enable(userID: string): Promise<ConciergeStatus> {
676
+ const next = await this.mutate(userID, (r) =>
677
+ r.state === "disabled" || r.state === "failed" || r.state === "stopping"
678
+ ? { ...r, state: "starting", reason: "Starting", since: this.now(), fresh: true, attempts: [], backoffIndex: 0, nextAttemptAt: undefined, launchID: undefined, pendingSeq: undefined, sessionID: undefined }
679
+ : r,
680
+ )
681
+ this.kick(userID)
682
+ return statusOf(next)
683
+ }
684
+
685
+ /**
686
+ * Suspends the Concierge at once (the spoke reports its session on another agent, or anything else that means the
687
+ * hub no longer controls it): the grant is revoked, the session loses @@concierge and the Concierge-only authority
688
+ * (it moves to the hidden history), its launch is stopped, and the state is failed with `reason`. Nothing relaunches
689
+ * it: only the user's Restart (or disable + enable). Idempotent for the same session.
690
+ */
691
+ async suspend(userID: string, sessionID: string, reason: string): Promise<ConciergeStatus> {
692
+ const before = await this.store.get(userID)
693
+ if (before.sessionID !== sessionID) return statusOf(before)
694
+ const t = this.now()
695
+ const after = await this.mutate(userID, (r) =>
696
+ r.sessionID !== sessionID
697
+ ? r
698
+ : {
699
+ ...r,
700
+ state: "failed",
701
+ reason,
702
+ since: t,
703
+ sessionID: undefined,
704
+ launchID: undefined,
705
+ pendingSeq: undefined,
706
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
707
+ runningSince: undefined,
708
+ nextAttemptAt: undefined,
709
+ hiddenSessionIDs: addHidden(r.hiddenSessionIDs, sessionID),
710
+ },
711
+ )
712
+ const settings = await this.deps.settings.get(userID)
713
+ if (settings.grantID !== undefined) {
714
+ await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => {
715
+ if (!ignorable(cause)) console.error(`[hub] concierge suspend: grant revoke failed: ${errorText(cause)}`)
716
+ })
717
+ await this.deps.settings.update(userID, (s) => {
718
+ const { grantID: _gone, ...rest } = s
719
+ return rest
720
+ })
721
+ }
722
+ await this.releaseHolders(userID).catch(() => undefined)
723
+ if (before.launchID !== undefined) await this.deps.launches.stop(userID, before.launchID, reason).catch(() => undefined)
724
+ await this.deps.context.clear(userID, sessionID).catch(() => undefined)
725
+ await this.audit(userID, "concierge.suspended")
726
+ try {
727
+ this.deps.onSuspended?.(userID, sessionID)
728
+ } catch (cause) {
729
+ console.error(`[hub] concierge suspend listener failed: ${errorText(cause)}`)
730
+ }
731
+ return statusOf(after)
732
+ }
733
+
734
+ /** Restart: ends the current launch and launches again at once (no backoff, no relaunch count). */
735
+ async restart(userID: string): Promise<ConciergeStatus> {
736
+ let old: ConciergeRecord = DEFAULT_RECORD
737
+ const next = await this.mutate(userID, (r) => {
738
+ old = r
739
+ return {
740
+ ...r,
741
+ state: "starting",
742
+ reason: "Restarting",
743
+ since: this.now(),
744
+ launchID: undefined,
745
+ pendingSeq: undefined,
746
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
747
+ sessionID: undefined,
748
+ runningSince: undefined,
749
+ nextAttemptAt: undefined,
750
+ backoffIndex: 0,
751
+ attempts: [],
752
+ fresh: true,
753
+ hiddenSessionIDs: r.sessionID !== undefined ? addHidden(r.hiddenSessionIDs, r.sessionID) : r.hiddenSessionIDs,
754
+ }
755
+ })
756
+ if (old.launchID !== undefined) {
757
+ await this.deps.launches.stop(userID, old.launchID, "The Concierge restarted").catch((cause) => {
758
+ if (!ignorable(cause)) console.error(`[hub] concierge restart stop failed: ${errorText(cause)}`)
759
+ })
760
+ }
761
+ await this.release(userID, old.sessionID)
762
+ await this.retire(userID, [old.sessionID])
763
+ await this.audit(userID, "concierge.restart")
764
+ this.kick(userID)
765
+ return statusOf(next)
766
+ }
767
+ }