import { readSse } from "./sse"; import type { AgentsResponse, AuditPage, BusEvent, ConversationInfo, ConversationSummary, EntriesPage, HistoryMessage, JobsResponse, LimitsResponse, MemoryFile, MemoryTree, SessionsResponse, TokenRow, UsageGroup, UsageResponse, } from "./types"; const TRAILING_SLASHES = /\/+$/; export class ApiError extends Error { status: number; body: unknown; constructor(status: number, message: string, body: unknown) { super(message); this.status = status; this.body = body; } } function messageOf(status: number, body: unknown): string { if (body && typeof body === "object") { const record = body as Record; for (const key of ["error", "detail"]) { if (typeof record[key] === "string") { return record[key] as string; } } } if (typeof body === "string" && body) { return body; } return `HTTP ${status}`; } async function bodyOf(response: Response): Promise { const text = await response.text(); try { return JSON.parse(text); } catch { return text; } } export type Params = Record; function query(params?: Params): string { if (!params) { return ""; } const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== "") { search.set(key, String(value)); } } const text = search.toString(); return text ? `?${text}` : ""; } export interface ClientOptions { onUnauthorized?: () => Promise; } // One client per signed-in session: the gateway origin, a bearer, and a // hook that refreshes the bearer once when the gateway rotated it. export class ApiClient { base: string; token: string; private readonly onUnauthorized?: () => Promise; constructor(base: string, token: string, options: ClientOptions = {}) { this.base = base.replace(TRAILING_SLASHES, ""); this.token = token; this.onUnauthorized = options.onUnauthorized; } url(path: string, params?: Params): string { return `${this.base}${path}${query(params)}`; } private headers(extra: Record = {}): Record { return { Authorization: `Bearer ${this.token}`, ...extra }; } private async request( method: string, path: string, body?: unknown, params?: Params, retried = false ): Promise { const response = await fetch(this.url(path, params), { body: body === undefined ? undefined : JSON.stringify(body), headers: this.headers( body === undefined ? {} : { "Content-Type": "application/json" } ), method, }); if (response.status === 401 && !retried && this.onUnauthorized) { const token = await this.onUnauthorized(); if (token) { this.token = token; return this.request(method, path, body, params, true); } } if (!response.ok) { const payload = await bodyOf(response); throw new ApiError( response.status, messageOf(response.status, payload), payload ); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } get(path: string, params?: Params): Promise { return this.request("GET", path, undefined, params); } post(path: string, body?: unknown): Promise { return this.request("POST", path, body ?? {}); } patch(path: string, body?: unknown): Promise { return this.request("PATCH", path, body ?? {}); } async stream( path: string, onEvent: (event: BusEvent) => void, signal: AbortSignal ): Promise { const response = await fetch(this.url(path), { headers: this.headers({ Accept: "text/event-stream" }), signal, }); if (!response.ok) { const payload = await bodyOf(response); throw new ApiError( response.status, messageOf(response.status, payload), payload ); } await readSse(response, onEvent, signal); } agents(): Promise { return this.get("/api/agents"); } conversations(params?: { kind?: string; status?: string; limit?: number; }): Promise<{ conversations: ConversationSummary[] }> { return this.get("/api/conversations", params); } conversation(id: string): Promise { return this.get(`/api/conversations/${id}`); } history(id: string): Promise<{ id: string; messages: HistoryMessage[] }> { return this.get(`/api/conversations/${id}/history`); } entries( id: string, params?: { subpath?: string; offset?: number; limit?: number } ): Promise { return this.get(`/api/conversations/${id}/entries`, params); } postMessage( id: string, text: string, origin = "panel" ): Promise<{ id: string; item: number; status: string }> { return this.post(`/api/conversations/${id}/messages`, { origin, text }); } inject( id: string, text: string, urgency: "normal" | "urgent", origin = "panel" ): Promise<{ id: string; item: number; priority: string }> { return this.post(`/api/conversations/${id}/inject`, { origin, text, urgency, }); } answer( id: string, questionId: string, answer: string ): Promise<{ id: string; question_id: string }> { return this.post(`/api/conversations/${id}/answer`, { answer, question_id: questionId, }); } branch( id: string, body: { seed?: string; text?: string; title?: string; window?: number } ): Promise { return this.post(`/api/conversations/${id}/branch`, body); } merge( id: string ): Promise<{ id: string; status: string; fork: string; text: string }> { return this.post(`/api/conversations/${id}/merge`); } bind( id: string, frontend: string, externalId: string, visible: boolean ): Promise { return this.post(`/api/conversations/${id}/bind`, { external_id: externalId, frontend, visible, }); } setFlags( id: string, flags: Record ): Promise { return this.patch(`/api/conversations/${id}/flags`, flags); } update( id: string, body: { status?: string; title?: string } ): Promise { return this.patch(`/api/conversations/${id}`, body); } spawn(body: { kind: string; agent?: string; seed?: string; text?: string; title?: string; }): Promise { return this.post("/api/conversations", body); } sessions(): Promise { return this.get("/api/sessions"); } jobs(): Promise { return this.get("/api/jobs"); } runJob(name: string): Promise<{ job: number | null }> { return this.post(`/api/jobs/${encodeURIComponent(name)}/run`); } cancelJob(id: number): Promise<{ cancelled: number }> { return this.request("DELETE", `/api/jobs/queue/${id}`); } usage(params: { since?: string; until?: string; hours?: number; group_by: UsageGroup; }): Promise { return this.get("/api/usage", params); } limits(): Promise { return this.get("/api/limits"); } memory(): Promise { return this.get("/api/memory"); } memoryFile(path: string): Promise { return this.get("/api/memory/file", { path }); } tokens(includeRevoked = false): Promise<{ tokens: TokenRow[] }> { return this.get("/api/tokens", { include_revoked: includeRevoked ? 1 : undefined, }); } createToken( name: string, scope: string ): Promise<{ token: TokenRow; plaintext: string }> { return this.post("/api/tokens", { name, scope }); } revokeToken(id: number): Promise<{ id: number; revoked: boolean }> { return this.post(`/api/tokens/${id}/revoke`); } audit(params?: { before?: number; limit?: number }): Promise { return this.get("/api/audit", params); } }