// Typed client for the agent orchestrator (the "agent" tab). Mirrors the // backend's polling contract: POST /api/agent starts/continues a session, // GET /api/agent/?since= returns an append-only transcript delta. // Reuses the same windowed-bearer derivation + 401-retry as PanelClient. import { deriveToken } from "./token"; import type { Settings } from "./panel"; import { ApiError } from "./panel"; export type AgentEvent = | { i: number; ts: number; type: "user"; text: string } | { i: number; ts: number; type: "assistant"; text: string } | { i: number; ts: number; type: "thinking"; text: string } | { i: number; ts: number; type: "tool_use"; name: string; summary: string } | { i: number; ts: number; type: "tool_result"; text: string; is_error: boolean } | { i: number; ts: number; type: "status"; state: "starting" | "idle" | "running" | "error"; detail?: string; cost_usd?: number | null; }; export type AgentStatus = "starting" | "idle" | "running" | "error"; export interface AgentModel { tier: "claude" | "open"; provider: string; model: string; label: string; default: boolean; } export interface AgentSnapshot { session_id: string; tier: "claude" | "open" | "mock"; model: string | null; status: AgentStatus; events: AgentEvent[]; next: number; } export interface AgentStart { session_id: string; tier: "claude" | "open" | "mock"; model: string | null; status: AgentStatus; workspace_id?: string; } export interface AgentFile { path: string; size: number; mtime: number; } export class AgentClient { constructor(private settings: Settings) {} private async bearer(windowsBack = 0): Promise { if (this.settings.rotationSecret) return deriveToken(this.settings.rotationSecret, windowsBack); return this.settings.token; } private fetchWith(token: string, path: string, init?: RequestInit): Promise { return fetch(this.settings.baseUrl + path, { ...init, headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers || {}), }, }); } /** Authenticated fetch with a single 401 retry on the previous token window. */ private async raw(path: string, init?: RequestInit): Promise { let r = await this.fetchWith(await this.bearer(), path, init); if (r.status === 401 && this.settings.rotationSecret) { r = await this.fetchWith(await this.bearer(1), path, init); } return r; } private async req(path: string, init?: RequestInit): Promise { const r = await this.raw(path, init); if (!r.ok) { let msg = `HTTP ${r.status}`; try { const j = await r.json(); if (j?.error) msg = j.error; } catch { /* non-json body */ } throw new ApiError(r.status, msg); } return (await r.json()) as T; } /** List models this Space can actually run (for the picker). */ models() { return this.req<{ tier: string | null; models: AgentModel[] }>("/api/agent/models"); } /** Start a new session, or continue an existing one if sessionId is given. * `model` selects which model/tier drives a NEW session. * `workspaceId` links the agent to a user workspace sandbox. */ send(message: string, sessionId?: string, model?: string, workspaceId?: string) { const body: Record = { message }; if (sessionId) body.session_id = sessionId; if (model) body.model = model; if (workspaceId) body.workspace_id = workspaceId; // When linking to a workspace, the backend additionally needs the caller's // GitHub identity (JWT) for the ownership check. Send it in X-JWT so the // Authorization header stays reserved for the service/rotation token. const headers: Record = {}; if (workspaceId && this.settings.githubSessionId) { headers["X-JWT"] = this.settings.githubSessionId; } return this.req("/api/agent", { method: "POST", body: JSON.stringify(body), headers, }); } /** Stop the in-flight turn for a session. */ interrupt(sessionId: string) { return this.req<{ interrupted: boolean; status: AgentStatus }>( `/api/agent/${sessionId}/interrupt`, { method: "POST" }, ); } /** Poll the transcript; `since` is the cursor returned as `next` last time. * NOTE: Session ID is in the URL path (not a header) because GET requests can't * have bodies. Backend logs should redact session IDs to prevent leakage via * proxy/CDN access logs. A future improvement could use short-lived signed tokens. */ poll(sessionId: string, since: number) { return this.req(`/api/agent/${sessionId}?since=${since}`); } files(sessionId: string) { return this.req<{ session_id: string; files: AgentFile[] }>(`/api/agent/${sessionId}/files`); } /** Download an artifact. Plain can't carry the bearer, so we fetch * the bytes ourselves and trigger a download from an object URL. */ async download(sessionId: string, path: string): Promise { const r = await this.raw(`/api/agent/${sessionId}/file?path=${encodeURIComponent(path)}`); if (!r.ok) throw new ApiError(r.status, `download failed (HTTP ${r.status})`); const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = path.split("/").pop() || "download"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } }