@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.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- 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).
|
|
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(
|
|
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
|
|
116
|
+
const current = await timed.current(actor.userID)
|
|
61
117
|
concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
|
|
62
118
|
} catch {
|
|
63
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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 =
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/central/config.ts
CHANGED
|
@@ -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,
|
|
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 ||
|
|
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
|
-
|
|
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
|
}
|
package/src/central/http.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1407
|
-
return
|
|
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
|
|
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
|
-
/**
|
|
2148
|
-
|
|
2149
|
-
|
|
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((
|
|
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
|
-
|
|
2180
|
-
|
|
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
|
-
/**
|
|
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
|
|
2626
|
-
|
|
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)
|
package/src/central/main.ts
CHANGED
|
@@ -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
|
|