import * as routes from "./routes.js"; import type { ApiError, InTheWay, Branches, CreateSessionRequest, DaemonInfo, DirListing, GitRoot, NameBatch, Projects, RecentDirs, Session, StructuredApprovalDecision, StructuredConfigRequest, StructuredPromptRequest, Uuid, } from "./types.js"; /** A `/api/*` failure with the daemon's own error envelope kept intact, so a * caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and * render `unsavedWork` rather than re-parsing the message. */ export class ApiRequestError extends Error { readonly status: number; readonly code: string; readonly detail: string; readonly unsavedWork: InTheWay | null; constructor(res: Response, info?: ApiError["error"], text?: string) { const detail = info ? `${info.code}: ${info.message}` : (text ?? ""); super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); this.name = "ApiRequestError"; this.status = res.status; this.code = info?.code ?? ""; this.detail = info?.message ?? text ?? ""; this.unsavedWork = info?.unsaved_work ?? null; } } async function jsonOrError(res: Response): Promise { if (res.ok) { if (res.status === 204) return undefined as unknown as T; return (await res.json()) as T; } try { const body = (await res.json()) as ApiError; throw new ApiRequestError(res, body.error); } catch (e) { if (e instanceof ApiRequestError) throw e; throw new ApiRequestError(res, undefined, await res.text().catch(() => "")); } } export async function listSessions(): Promise { return jsonOrError(await fetch(routes.SESSIONS, { credentials: "same-origin" })); } export async function createSession(req: CreateSessionRequest): Promise { return jsonOrError( await fetch(routes.SESSIONS, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify(req), }), ); } /** Rename a session (#125). An empty name clears the custom one, so the card * falls back to the command. The daemon trims and caps what it stores and * broadcasts the update, so callers need not patch the store themselves. */ export async function renameSession(id: Uuid, name: string): Promise { return jsonOrError( await fetch(routes.session(id), { method: "PATCH", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ name }), }), ); } /** Kill a session. With `force` (#81) the daemon overrides its refusal over a * worktree holding unsaved work and leaves that worktree on disk. */ export async function killSession(id: Uuid, force = false): Promise { const url = force ? `${routes.session(id)}?${routes.FORCE_QUERY}=true` : routes.session(id); await jsonOrError( await fetch(url, { method: "DELETE", credentials: "same-origin", }), ); } /** Change one option advertised by a live ACP session. */ export async function setStructuredConfig( id: Uuid, req: StructuredConfigRequest, ): Promise { await jsonOrError( await fetch(routes.structuredConfig(id), { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify(req), }), ); } /** Report whether this client's pane is the one currently showing `id` — not * merely highlighted in a sidebar. The daemon uses this to clear an * `AwaitingInput` glow once the pane has been looked at long enough * (docs/SIDEBAR.md). Best-effort: a failure here only delays the glow * clearing, so callers don't need to surface it. */ export async function setSessionFocus(id: Uuid, focused: boolean): Promise { await jsonOrError( await fetch(routes.sessionFocus(id), { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ focused }), }), ); } /** List one directory's immediate subdirectories (NAP-329). `path` may be empty, * `~`, relative, or absolute — the daemon resolves it against its home dir. */ export async function listDir(path: string): Promise { const url = routes.withPathQuery(routes.DIRS, path); return jsonOrError(await fetch(url, { credentials: "same-origin" })); } /** Identity + spawn presets of the daemon this web app is connected to (#26). */ export async function getDaemonInfo(): Promise { return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" })); } /** Recently-used working dirs, newest-first and deduped by dir (#26). */ export async function recentDirs(): Promise { return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" })); } /** Every discovered git repository / worktree, newest-activity-first (#54). * Drives the spawn form's project picker. */ export async function projects(): Promise { return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" })); } /** Probe the enclosing git repo of `path` (#26). `root` is absent when the path * isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves * it against its home dir. */ export async function gitRoot(path: string): Promise { const url = routes.withPathQuery(routes.GITROOT, path); return jsonOrError(await fetch(url, { credentials: "same-origin" })); } /** A batch of fresh session handles for the spawn form's instant reroll (#53). * Rerolls consume the batch locally; only exhaustion goes back to the daemon. */ export async function fetchNames(count: number): Promise { const url = `${routes.NAMES}?${routes.COUNT_QUERY}=${encodeURIComponent(count)}`; return jsonOrError(await fetch(url, { credentials: "same-origin" })); } /** One repository's local branches, with worktree occupancy (#55). Empty for * a path outside any repo. Drives the spawn form's existing-branch picker. */ export async function fetchBranches(path: string): Promise { const url = routes.withPathQuery(routes.BRANCHES, path); return jsonOrError(await fetch(url, { credentials: "same-origin" })); } /** Send a prompt to a structured session's ACP turn (#138). `409 * turn_in_flight` when one is already active — the caller reflects that from * `ReducedState.turn` instead of racing this call. */ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise { await jsonOrError( await fetch(routes.structuredPrompt(id), { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt } satisfies StructuredPromptRequest), }), ); } export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> { return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/octet-stream" }, body: file, })); } /** Cancel a structured session's active ACP turn (#138). */ export async function cancelStructuredTurn(id: Uuid): Promise { await jsonOrError( await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }), ); } /** Answer a pending ACP permission request (#139). Always resolves once the * session itself is a live structured session — a nonce that is unknown or * already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two * clients racing the same approval never see this reject. */ export async function resolveStructuredApproval( id: Uuid, nonce: string, decision: StructuredApprovalDecision, ): Promise { await jsonOrError( await fetch(routes.structuredApproval(id, nonce), { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify(decision), }), ); } export { eventsWsUrl, structuredWsUrl } from "./routes.js";