@huynle/hub 0.8.0 → 0.8.2

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 (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  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 +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -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
  }
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
8
8
  import path from "node:path"
9
9
  import { promisify } from "node:util"
10
10
  import { brotliCompress, constants as zlib, gzip } from "node:zlib"
11
- import { formAnswerProblem, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
11
+ import { formAnswerProblem, NOT_A_USER_MESSAGE, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
12
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
13
13
  import { speakable } from "../speakable"
14
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -33,7 +33,7 @@ import { Escalations } from "./agent/escalation"
33
33
  import { GrantRequests } from "./agent/grant-requests"
34
34
  import { partyFromActor } from "./agent/envelope"
35
35
  import { Delivery } from "./agent/delivery"
36
- import { AgentError, type AgentErrorCode } from "./agent/errors"
36
+ import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
37
37
  import type { Launch } from "./agent/launch-store"
38
38
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
39
39
  import { hubDirectory } from "./agent/directory-routes"
@@ -91,15 +91,17 @@ 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"
100
101
  import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
101
102
  import { grantState } from "./agent/grants"
102
103
  import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
104
+ import { inRevertWindow, RevertWindows } from "./revert-windows"
103
105
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
104
106
  import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
105
107
 
@@ -297,8 +299,6 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
297
299
  */
298
300
  export const TURN_ACCEPT_MS = 1500
299
301
  const NO_OWNER = "No OpenCode process for this session is connected"
300
- /** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
301
- const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
302
302
  /** A message ID in a fork or revert request (as the spoke protocol bounds it). */
303
303
  const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
304
304
  const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
@@ -409,7 +409,12 @@ export function startCentral(options: CentralOptions): Central {
409
409
  // CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
410
410
  // push remapping all follow it); the service itself is built below, once its parts exist.
411
411
  const conciergeStatus = new StatusStore(backends.state, now)
412
- const conciergeIdentity = recordIdentity(conciergeStatus)
412
+ // (FX-07) Beyond the past sessions the record keeps: those a machine reports locked (its spoke holds them as Concierge sessions).
413
+ const conciergeIdentity = recordIdentity(conciergeStatus, { reported: (userID) => registry.lockedSessions(userID) })
414
+ // (FX-07) The identity checks on request paths (route gate, manage guard, acting guard, inbound/outbound rules, team
415
+ // staffing, Decision and audit labels) wait at most IDENTITY_TIMEOUT_MS for the store: past it they refuse with busy
416
+ // (503), never hang; one shared breaker answers busy at once for a while after a read timed out.
417
+ const conciergeTimedIdentity = timedIdentity(conciergeIdentity)
413
418
  // Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
414
419
  const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
415
420
  /** SSE subscribers, one user each. */
@@ -796,7 +801,7 @@ export function startCentral(options: CentralOptions): Central {
796
801
  const delivery = new Delivery({
797
802
  teams: teamPolicy,
798
803
  // 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) } }),
804
+ inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
800
805
  state: backends.state,
801
806
  counters: backends.counters,
802
807
  registry,
@@ -814,6 +819,8 @@ export function startCentral(options: CentralOptions): Central {
814
819
  },
815
820
  // Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
816
821
  alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
822
+ // A message pending for 24 h expired undelivered: journaled (message.changed, delivery "expired") for its sender.
823
+ expired: (userID, message) => feed.publish(userID, message),
817
824
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
818
825
  // Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
819
826
  ...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
@@ -851,7 +858,8 @@ export function startCentral(options: CentralOptions): Central {
851
858
  if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
852
859
  await conciergeTierHooks.checkVia(userID, decision, via, by)
853
860
  },
854
- concierge: (userID, sessionID) => isConciergeSession(conciergeIdentity, userID, sessionID),
861
+ // (FX-07) Time-limited and fail closed: a timed-out read refuses the ask with busy (no label silently dropped).
862
+ concierge: conciergeProtects(conciergeTimedIdentity),
855
863
  ...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
856
864
  })
857
865
  leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
@@ -862,6 +870,15 @@ export function startCentral(options: CentralOptions): Central {
862
870
  await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
863
871
  }
864
872
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
873
+ // Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
874
+ const revertWindows = new RevertWindows(backends.state)
875
+ registry.onSessions((connID) => {
876
+ const tenant = registry.connTenant(connID)
877
+ if (tenant === undefined) return
878
+ for (const { sessionID, committed } of registry.revertCommits(connID)) {
879
+ void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
880
+ }
881
+ })
865
882
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
866
883
  // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
867
884
  const intents = new SessionIntents({
@@ -891,7 +908,8 @@ export function startCentral(options: CentralOptions): Central {
891
908
  // Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
892
909
  // launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
893
910
  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 })
911
+ // The user's live view: flagged when the launch's app token was revoked (launches is set below, before any change).
912
+ launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: userID }), { origin: true })
895
913
  // Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
896
914
  let profileApplier: ProfileApplier | undefined
897
915
  const launches = new LaunchEngine({
@@ -914,7 +932,15 @@ export function startCentral(options: CentralOptions): Central {
914
932
  users: () => store.users().map((u) => u.id),
915
933
  now,
916
934
  onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
935
+ // FX-02: a waiting text change of the same kind: the console's live stream (and the Concierge's status) only,
936
+ // never the journal or webhooks.
937
+ onLaunchWaiting: (launch) => {
938
+ broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: launch.owner })
939
+ if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
940
+ },
917
941
  intents,
942
+ // FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
943
+ launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
918
944
  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
945
  // Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
920
946
  ...(config.cluster
@@ -1055,7 +1081,7 @@ export function startCentral(options: CentralOptions): Central {
1055
1081
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
1056
1082
  // Every stored agent message also goes to the user's console streams (live transcript rows).
1057
1083
  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) })
1084
+ 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
1085
  const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
1060
1086
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1061
1087
  /** An agent as envelopes name it (a session by its directory name). */
@@ -1114,11 +1140,11 @@ export function startCentral(options: CentralOptions): Central {
1114
1140
  const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1115
1141
  // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1116
1142
  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 })
1143
+ 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
1144
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1119
1145
  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
1146
  // 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 }) })
1147
+ 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
1148
  // Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
1123
1149
  // supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
1124
1150
  const workspaceCursors = journalCursors(tokenKeys, agentEvents)
@@ -1278,7 +1304,7 @@ export function startCentral(options: CentralOptions): Central {
1278
1304
  state: backends.state,
1279
1305
  counters: backends.counters,
1280
1306
  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) })),
1307
+ 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), currentConcierge: async (userID, sessionID) => (await conciergeTimedIdentity.current(userID)).sessionID === sessionID })),
1282
1308
  audit,
1283
1309
  protects: conciergeProtects(conciergeIdentity),
1284
1310
  published: (userID, message) => feed.publish(userID, message),
@@ -1365,7 +1391,7 @@ export function startCentral(options: CentralOptions): Central {
1365
1391
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1366
1392
  })
1367
1393
  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 } }
1394
+ 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
1395
  const resolveActor = createResolver({
1370
1396
  cookieName: COOKIE,
1371
1397
  findDevice: (token) => deviceOfToken(token),
@@ -1383,7 +1409,7 @@ export function startCentral(options: CentralOptions): Central {
1383
1409
  },
1384
1410
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
1385
1411
  // CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
1386
- gate: conciergeSessionGate(conciergeIdentity, () => conciergeParts.tiers.routes()),
1412
+ gate: conciergeSessionGate(conciergeTimedIdentity, () => conciergeParts.tiers.routes()),
1387
1413
  })
1388
1414
 
1389
1415
  // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
@@ -1401,11 +1427,12 @@ export function startCentral(options: CentralOptions): Central {
1401
1427
  const { pathname } = url
1402
1428
  const method = request.method
1403
1429
  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() })
1430
+ // HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
1431
+ if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
1432
+ const response = pathname === "/readyz" ? await readyz() : healthz()
1433
+ return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response
1408
1434
  }
1435
+ if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
1409
1436
  // A shared (cluster) store loads its records on first use; the file store is ready at once.
1410
1437
  await store.ready()
1411
1438
  if (method === "POST" && pathname === "/spoke/enroll") {
@@ -1738,7 +1765,7 @@ export function startCentral(options: CentralOptions): Central {
1738
1765
  route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
1739
1766
  route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
1740
1767
  const messageID = messageIDInput(url.searchParams.get("messageID"))
1741
- const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
1768
+ const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID }).catch(refused())
1742
1769
  return Response.json(preview, { headers: NO_STORE })
1743
1770
  }),
1744
1771
  route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
@@ -1797,11 +1824,18 @@ export function startCentral(options: CentralOptions): Central {
1797
1824
  const raw = url.searchParams.get("limit")
1798
1825
  const limit = raw === null ? undefined : Number(raw)
1799
1826
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
1800
- const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
1827
+ const [listed, windows] = await Promise.all([
1828
+ delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit }),
1829
+ revertWindows.list(user.id, params.session!),
1830
+ ])
1831
+ // Rows of a history a committed revert removed stay out.
1832
+ const messages = windows.length ? listed.filter((m) => !inRevertWindow(m.at, windows)) : listed
1801
1833
  return Response.json({ messages }, { headers: NO_STORE })
1802
1834
  }),
1835
+ // (M1) Embedded images become placeholders here too, so a spoke that still sends them (older versions, other
1836
+ // harnesses) never ships image bytes to the console in a tool detail.
1803
1837
  route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
1804
- Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
1838
+ Response.json(redactEmbeddedMedia(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! })), { headers: NO_STORE })),
1805
1839
  route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params }) => timelineFile(params.session!, params.message!, params.index!)),
1806
1840
  route(
1807
1841
  "GET",
@@ -2144,16 +2178,26 @@ export function startCentral(options: CentralOptions): Central {
2144
2178
  return raw
2145
2179
  }
2146
2180
 
2147
- /** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
2148
- function busyRefusal(cause: unknown): unknown {
2149
- return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
2181
+ /**
2182
+ * A spoke's refusal of a fork or revert op: code "busy" → 409 busy, "not_found" → 404 (failure() maps both). Older
2183
+ * spokes send no code: their busy texts (the spoke's REVERT_BUSY, OpenCode's "Session is busy: …") and
2184
+ * NOT_A_USER_MESSAGE are recognised instead; anything else unchanged (502).
2185
+ */
2186
+ function forkRevertRefusal(cause: unknown, busy = REVERT_BUSY): unknown {
2187
+ if (!(cause instanceof SpokeError) || cause.code !== undefined) return cause
2188
+ if (cause.message === REVERT_BUSY || /Session is busy/i.test(cause.message)) return new HttpError(409, busy, "busy")
2189
+ if (cause.message === NOT_A_USER_MESSAGE) return new HttpError(404, NOT_A_USER_MESSAGE, "not_found")
2190
+ return cause
2191
+ }
2192
+ const refused = (busy?: string) => (cause: unknown) => {
2193
+ throw forkRevertRefusal(cause, busy)
2150
2194
  }
2151
2195
 
2152
2196
  /** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
2153
2197
  async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2154
2198
  const messageID = messageIDInput((await body(request)).messageID)
2155
2199
  const connID = featureOwner(sessionID, "fork")
2156
- const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
2200
+ const result = await spokes.request(connID, { op: "fork", sessionID, messageID }).catch(refused())
2157
2201
  await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
2158
2202
  return Response.json(result, { status: 201, headers: NO_STORE })
2159
2203
  }
@@ -2166,9 +2210,7 @@ export function startCentral(options: CentralOptions): Central {
2166
2210
  const files = input.files !== false
2167
2211
  const connID = featureOwner(sessionID, "revert")
2168
2212
  if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
2169
- const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
2170
- throw busyRefusal(cause)
2171
- })
2213
+ const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch(refused())
2172
2214
  await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
2173
2215
  return Response.json(result, { headers: NO_STORE })
2174
2216
  }
@@ -2176,9 +2218,8 @@ export function startCentral(options: CentralOptions): Central {
2176
2218
  /** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
2177
2219
  async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
2178
2220
  const connID = featureOwner(sessionID, "revert")
2179
- await spokes.request(connID, { op, sessionID }).catch((cause) => {
2180
- throw busyRefusal(cause)
2181
- })
2221
+ if (registry.running(sessionID)) throw new HttpError(409, REVERT_END_BUSY, "busy")
2222
+ await spokes.request(connID, { op, sessionID }).catch(refused(REVERT_END_BUSY))
2182
2223
  await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
2183
2224
  return new Response(null, { status: 204 })
2184
2225
  }
@@ -2472,6 +2513,10 @@ export function startCentral(options: CentralOptions): Central {
2472
2513
  }
2473
2514
 
2474
2515
  /** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
2516
+ function healthz(): Response {
2517
+ return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
2518
+ }
2519
+
2475
2520
  async function readyz(): Promise<Response> {
2476
2521
  if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
2477
2522
  if (config.cluster) {
@@ -2619,11 +2664,15 @@ function decisionAnswer(input: Record<string, unknown>): { option?: string; text
2619
2664
  return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
2620
2665
  }
2621
2666
 
2622
- /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
2667
+ /**
2668
+ * Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by the agent API's code
2669
+ * table (a revert-staged `conflict` 409, a `busy` 503 with retryAfter), never a bare 500 for a known code.
2670
+ */
2623
2671
  function decisionFailure(cause: unknown): Response {
2624
2672
  if (!(cause instanceof AgentError)) throw cause
2625
- const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423, offline: 503, unsupported: 422 }
2626
- return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
2673
+ const status = cause.code === "gone" ? 409 : AGENT_ERROR_STATUS[cause.code] ?? 500
2674
+ const extra = cause.options.extra && typeof cause.options.extra.retryAfter === "number" ? { retryAfter: cause.options.extra.retryAfter } : {}
2675
+ return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}), ...extra }, { status })
2627
2676
  }
2628
2677
 
2629
2678
  /** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
@@ -2767,6 +2816,9 @@ function failure(cause: unknown): Response {
2767
2816
  if (cause instanceof ValidationError) return error(400, cause.message)
2768
2817
  if (cause instanceof SyntaxError) return error(400, "Invalid JSON")
2769
2818
  if (cause instanceof SpeechError) return error(502, cause.message)
2819
+ // A spoke's coded refusal (Res.code): busy → 409, not found → 404; anything else from a spoke is a bad gateway.
2820
+ if (cause instanceof SpokeError && cause.code === "busy") return error(409, cause.message, "busy")
2821
+ if (cause instanceof SpokeError && cause.code === "not_found") return error(404, cause.message, "not_found")
2770
2822
  if (cause instanceof SpokeError) return error(502, cause.message)
2771
2823
  if (cause instanceof RequestTooLargeError) return error(413, cause.message)
2772
2824
  if (cause instanceof OidcError) return authPage(cause.status, "Sign-in failed", cause.message)
@@ -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