import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from '@@/data-path'; import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs'; // One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. // // The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It // holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally // generated secret it writes to its own state file, so authenticating is a file read, not a credential this // sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts // (`readProxySecretFromDisk`), which does the same thing for the agent. // // Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this // one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle. // // This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat // surface, which already exists and already persists. const DEFAULT_TIMEOUT_MS = 120_000; /** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */ export class ProxyUnavailable extends Error {} /** * The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a * freshly installed machine has a window where the file exists without it. */ function readProxySecret(): string { try { const file = join(DATA_PATH, 'sidecar', 'claude-state.json'); if (!existsSync(file)) return ''; const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown }; return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : ''; } catch { return ''; } } type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number }; type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } }; /** * One user turn, one reply, as plain text. * * The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates * with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something * else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is * currently accepted too. Keeping the block costs ~14 tokens and removes the question.) */ export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise { const secret = readProxySecret(); if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment'); let res: Response; try { res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, { method: 'POST', headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model, max_tokens: maxTokens, system: [ { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }, { type: 'text', text: system }, ], messages: [{ role: 'user', content: prompt }], }), signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS), }); } catch (err) { if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long'); throw new ProxyUnavailable('the Claude proxy is not reachable'); } const body = (await res.json().catch(() => null)) as MessagesResponse | null; if (!res.ok) { const detail = body?.error?.message; // 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream // saying something specific about this request, which is worth passing through. if (res.status === 401 || res.status === 429) { throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`); } throw new Error(detail ?? `the model returned ${res.status}`); } const text = (body?.content ?? []) .filter((block) => block.type === 'text' && typeof block.text === 'string') .map((block) => block.text) .join('') .trim(); if (!text) throw new Error('the model returned an empty reply'); return text; }