offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user