Merge native Claude Code integration with explicit opt-in

# Conflicts:
#	src/agent/teacher-agent.ts
#	src/server/keys.ts
This commit is contained in:
amosblomqvist
2026-10-28 21:45:50 +02:00
64 changed files with 5655 additions and 102 deletions
+90
View File
@@ -0,0 +1,90 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
type BinaryLookup = {
env: Record<string, string | undefined>;
platform: NodeJS.Platform;
homeDir: string;
cwd?: string;
isExecutable: (path: string) => boolean;
};
type SearchSpec = {
paths: path.PlatformPath;
names: string[];
knownDirs: string[];
};
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
const paths = path.win32;
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
return {
paths,
// Direct spawn deliberately uses no shell. A .cmd-only npm install
// is not runnable and must not count as an available provider.
names: ["claude.exe"],
knownDirs: [
paths.join(homeDir, ".local", "bin"),
paths.join(homeDir, ".claude", "local"),
env.npm_config_prefix ?? paths.join(appData, "npm"),
],
};
}
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
const paths = path.posix;
const npmBin = env.npm_config_prefix
? paths.join(env.npm_config_prefix, "bin")
: paths.join(homeDir, ".npm-global", "bin");
return {
paths,
names: ["claude"],
knownDirs: [
paths.join(homeDir, ".local", "bin"),
paths.join(homeDir, ".claude", "local"),
npmBin,
"/opt/homebrew/bin",
"/usr/local/bin",
],
};
}
export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null {
const { paths, names, knownDirs } = platform === "win32"
? getWindowsSearchSpec(env, homeDir)
: getPosixSearchSpec(env, homeDir);
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
for (const name of names) {
for (const dir of [...pathDirs, ...knownDirs]) {
// Discovery is relative to the server cwd; probes/model work use
// different directories and must receive the same absolute identity.
const candidate = paths.resolve(cwd, dir, name);
if (isExecutable(candidate)) return candidate;
}
}
return null;
}
function isExecutableFile(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
export function getClaudeBinary(): string | null {
return resolveClaudeBinary({
env: process.env,
platform: process.platform,
homeDir: os.homedir(),
isExecutable: isExecutableFile,
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { Usage } from "@earendil-works/pi-ai";
export type CliUsage = Partial<Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">>;
export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string };
export type CliEvent =
| { type: "messageStart"; usage: CliUsage }
| { type: "blockStart"; index: number; block: CliBlock }
| { type: "textDelta"; index: number; text: string }
| { type: "thinkingDelta"; index: number; thinking: string }
| { type: "toolInputDelta"; index: number; partialJson: string }
| { type: "blockStop"; index: number }
| { type: "messageDelta"; stopReason: string | null; usage: CliUsage }
| { type: "messageStop" }
| { type: "assistantError"; error: string; text: string }
| { type: "resultError"; text: string }
| { type: "toolResults"; ids: string[] };
type RawUsage = {
input_tokens?: number | null;
output_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
};
type RawContentBlock = { type: string; id?: string; name?: string };
type RawDelta = {
type?: string;
text?: string;
thinking?: string;
partial_json?: string;
stop_reason?: string | null;
};
type RawStreamEvent = {
type: string;
index?: number;
message?: { usage?: RawUsage };
content_block?: RawContentBlock;
delta?: RawDelta;
usage?: RawUsage;
};
type RawCliLine = {
type?: string;
event?: RawStreamEvent;
error?: string | null;
message?: { content?: { type: string; text?: string; tool_use_id?: string }[] };
is_error?: boolean;
result?: string;
subtype?: string;
errors?: string[];
};
function toUsage(raw: RawUsage): CliUsage {
const usage: CliUsage = {};
if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens;
if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens;
if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens;
if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens;
return usage;
}
function toBlock(raw: RawContentBlock): CliBlock | null {
switch (raw.type) {
case "text":
return { type: "text" };
case "thinking":
return { type: "thinking" };
case "tool_use":
if (typeof raw.id !== "string" || typeof raw.name !== "string") return null;
return { type: "toolUse", id: raw.id, name: raw.name };
default:
return null;
}
}
function parseDelta(index: number, delta: RawDelta): CliEvent | null {
switch (delta.type) {
case "text_delta":
if (typeof delta.text !== "string") return null;
return { type: "textDelta", index, text: delta.text };
case "thinking_delta":
if (typeof delta.thinking !== "string") return null;
return { type: "thinkingDelta", index, thinking: delta.thinking };
case "input_json_delta":
if (typeof delta.partial_json !== "string") return null;
return { type: "toolInputDelta", index, partialJson: delta.partial_json };
default:
return null;
}
}
function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null {
switch (event.type) {
case "content_block_start": {
if (!event.content_block) return null;
const block = toBlock(event.content_block);
return block ? { type: "blockStart", index, block } : null;
}
case "content_block_delta":
return event.delta ? parseDelta(index, event.delta) : null;
case "content_block_stop":
return { type: "blockStop", index };
default:
return null;
}
}
function parseStreamEvent(event: RawStreamEvent): CliEvent | null {
switch (event.type) {
case "message_start":
return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) };
case "message_delta": {
const stopReason = event.delta?.stop_reason ?? null;
return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) };
}
case "message_stop":
return { type: "messageStop" };
default:
return event.index === undefined ? null : parseBlockEvent(event.index, event);
}
}
function getText(line: RawCliLine): string {
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : []));
return texts.join("\n");
}
function getResultErrorText(line: RawCliLine): string {
if (line.result) return line.result;
if (line.errors?.length) return line.errors.join("\n");
return `claude CLI failed: ${line.subtype ?? "unknown error"}`;
}
export function parseCliLine(text: string): CliEvent | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const line = parsed as RawCliLine;
switch (line.type) {
case "stream_event":
return line.event ? parseStreamEvent(line.event) : null;
case "assistant":
if (typeof line.error !== "string") return null;
return { type: "assistantError", error: line.error, text: getText(line) };
case "user": {
const blocks = Array.isArray(line.message?.content) ? line.message.content : [];
const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []);
return ids.length ? { type: "toolResults", ids } : null;
}
case "result":
if (line.is_error !== true) return null;
return { type: "resultError", text: getResultErrorText(line) };
default:
return null;
}
}
+8
View File
@@ -0,0 +1,8 @@
export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts";
export const CLAUDE_CODE_API = "claude-code-cli";
export enum ClaudeCodeError {
CliNotFound = "claude CLI not found",
NotSignedIn = "not signed in",
}
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
const INITIALIZE_TIMEOUT_MS = 5_000;
/** A best-effort, local-only CLI control query. Wire contract matches the
* official SDK0.3.259 source; this does not import or execute the SDK.
* Summary deliberately avoids the default full mode's token-count requests.
* CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */
export class ContextWindowProbe {
private readonly initializeId = randomUUID();
private summaryId: string | undefined;
private initialized = false;
private timer: NodeJS.Timeout | undefined;
private ready = false;
private closed = false;
constructor(
private readonly write: (line: string) => void,
private readonly sendPrompt: () => void,
private readonly onWindow: (tokens: number) => void,
) {}
start(): void {
this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS);
this.timer.unref();
this.request(this.initializeId, { subtype: "initialize" });
}
/** Consume control replies separately from model/tool output, including
* late/foreign replies while a native question tool is already waiting. */
handle(value: unknown): boolean {
if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false;
if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true;
const response = value.response as Record<string, unknown>;
if (response.request_id === this.initializeId) this.proceed(response.subtype === "success");
else if (this.summaryId && response.request_id === this.summaryId) {
this.summaryId = undefined;
if (response.subtype !== "success") return true;
const body = response.response as Record<string, unknown> | undefined;
const tokens = body?.maxTokens;
if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens);
}
return true;
}
/** Retry when the CLI has begun a model response: pre-first-turn metadata
* is not guaranteed by the protocol. Refresh also follows later responses
* within the same held process, without opening a second process. */
refresh(): void {
if (!this.initialized || this.closed) return;
this.summaryId = randomUUID();
this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" });
}
close(): void { this.closed = true; clearTimeout(this.timer); }
private proceed(query: boolean): void {
if (this.ready || this.closed) return;
this.ready = true;
clearTimeout(this.timer);
this.initialized = query;
if (query) this.refresh();
// Never wait for metadata to run the actual user request. No support,
// no response, or malformed metadata simply leaves the window unknown.
this.sendPrompt();
}
private request(id: string, request: Record<string, string>): void {
this.write(JSON.stringify({ type: "control_request", request_id: id, request }));
}
}
+38
View File
@@ -0,0 +1,38 @@
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { externalCliEnv } from "../../core/auth-env.ts";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
import { getClaudeBinary } from "./binary.ts";
export type ReadClaudeVersion = (binary: string) => Promise<string>;
/** No shell, auth-status command, model call, or credential-file inspection. */
export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => {
execFile(binary, ["--version"], {
cwd: tmpdir(), env: externalCliEnv(), windowsHide: true,
timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096,
}, (error, stdout) => error ? reject(error) : resolve(stdout));
});
export async function inspectClaudeInstallation(
findBinary: () => string | null = getClaudeBinary,
readVersion: ReadClaudeVersion = readClaudeVersion,
): Promise<ClaudeCodeStatus> {
const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION };
const binary = findBinary();
if (!binary) return { ...base, state: "not-found" };
try {
const output = await readVersion(binary);
// Expose only a version, never arbitrary CLI stdout/stderr or an absolute path.
const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim());
if (!match) return { ...base, state: "version-unchecked" };
const parts = match.slice(1).map(Number);
if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" };
const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number);
const differing = parts.findIndex((part, index) => part !== minimum[index]);
const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!;
return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" };
} catch {
return { ...base, state: "version-unchecked" };
}
}
+32
View File
@@ -0,0 +1,32 @@
import { spawn } from "node:child_process";
import { getClaudeBinary } from "./binary.ts";
import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts";
import type { RunClaude } from "./stream.ts";
export class ClaudeLaunchError extends Error {}
/** Injectable seams keep all verification offline; production uses the installed CLI. */
export function createClaudeLauncher({
findBinary = getClaudeBinary,
readVersion = readClaudeVersion,
spawnProcess = spawn,
}: {
findBinary?: () => string | null;
readVersion?: ReadClaudeVersion;
spawnProcess?: typeof spawn;
} = {}): RunClaude {
return async (args, options) => {
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
const binary = findBinary();
if (!binary) return null;
const status = await inspectClaudeInstallation(() => binary, readVersion);
if (status.state === "update-required") {
throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`);
}
if (status.state !== "meets-minimum") {
throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again.");
}
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" });
};
}
+43
View File
@@ -0,0 +1,43 @@
export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void };
// Tools usually call back within seconds, but a question tool holds its call until the learner
// answers. Past this, the process is killed and the answer starts a fresh one with a full replay.
// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own.
export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000;
/** Test seam (runner.ts's pdfModelOverride idiom). */
export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS };
// Each live process mapped to the timer that kills it if pi never calls back.
const liveProcesses = new Map<LiveProcess, NodeJS.Timeout>();
export function addLiveProcess(process: LiveProcess): void {
removeLiveProcess(process);
const timer = setTimeout(() => {
removeLiveProcess(process);
process.kill();
}, liveProcessIdleTimeoutMs.current);
timer.unref();
liveProcesses.set(process, timer);
}
function hasSameIds(ids: string[], otherIds: string[]): boolean {
return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
}
export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null {
if (!sessionId || toolCallIds.length === 0) return null;
for (const process of liveProcesses.keys()) {
if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process;
}
return null;
}
export function removeLiveProcess(process: LiveProcess): void {
clearTimeout(liveProcesses.get(process));
liveProcesses.delete(process);
}
+34
View File
@@ -0,0 +1,34 @@
import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai";
import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts";
const SHARED_MODEL_FIELDS = {
api: CLAUDE_CODE_API,
provider: CLAUDE_CODE_PROVIDER,
baseUrl: "",
input: ["text", "image"],
// Pi-required placeholders, not prices: the CLI's own auth/routing
// determines charges. Server pricing and the client catalog report unpriced.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: true,
// The CLI has no thinking-off switch; effort low is the floor.
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
contextWindow: 1_000_000,
maxTokens: 128_000,
} satisfies Omit<Model<Api>, "id" | "name">;
export const CLAUDE_CODE_MODELS: readonly Model<Api>[] = [
{ ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" },
{ ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" },
{ ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" },
];
export function createClaudeCodeProvider(streamSimple: StreamFunction<Api, SimpleStreamOptions>): Provider {
return createProvider({
id: CLAUDE_CODE_PROVIDER,
name: "Claude Code",
auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } },
models: CLAUDE_CODE_MODELS,
api: { stream: streamSimple, streamSimple },
});
}
+27
View File
@@ -0,0 +1,27 @@
import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */
export function conversationForReuse(messages: Message[]): unknown[] {
return messages.flatMap((message): unknown[] => {
switch (message.role) {
case "system": return [];
case "user": return [{ role: message.role, content: message.content }];
case "assistant": return [{ role: message.role, content: message.content }];
case "toolResult": return [{
role: message.role,
toolCallId: message.toolCallId,
toolName: message.toolName,
content: message.content,
isError: message.isError,
}];
}
});
}
export function configurationForReuse(model: Model<Api>, context: TranscriptContext, effort: string): unknown {
const systemPrompt = getCurrentSystemPrompt(context.messages);
const tools = getCurrentTools(context.messages);
// The wire is JSON: TypeBox's schema symbols are not CLI configuration.
return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown;
}
+695
View File
@@ -0,0 +1,695 @@
import { type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { isDeepStrictEqual } from "node:util";
import {
createAssistantMessageEventStream,
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
type Message,
type Model,
type SimpleStreamOptions,
type StopReason,
type StreamFunction,
type ThinkingLevel,
type ToolCall,
type ToolResultMessage,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
import { externalCliEnv } from "../../core/auth-env.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts";
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
import { ClaudeCodeError } from "./constants.ts";
import { ContextWindowProbe } from "./context-window.ts";
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
import { configurationForReuse, conversationForReuse } from "./reuse-context.ts";
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
type ClaudeChild = ChildProcessWithoutNullStreams | null;
export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise<ClaudeChild>;
// A held MCP call lasts until pi calls back with the tool result, which for a question tool is
// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never
// times out first.
const MCP_TOOL_TIMEOUT_MS = 30 * 60_000;
const PRIVATE_FILE_MODE = 0o600;
const EFFORTS: Record<ThinkingLevel, AnthropicEffort> = {
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
max: "max",
};
const STOP_REASONS: Record<string, StopReason> = {
end_turn: "stop",
pause_turn: "stop",
stop_sequence: "stop",
max_tokens: "length",
tool_use: "toolUse",
refusal: "error",
};
const REFUSAL_MESSAGE = "The model refused to complete the request";
const ABORT_MESSAGE = "Request was aborted";
const START_FAILED_MESSAGE = "claude CLI failed to start";
const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason";
const NOT_SIGNED_IN_ERROR = "authentication_failed";
export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort {
return level ? EFFORTS[level] : "medium";
}
type ClaudeArgsInput = {
model: string;
effort: AnthropicEffort;
systemPromptFile: string;
mcpConfigFile: string;
allowedTools: string[];
};
export function toClaudeArgs(input: ClaudeArgsInput): string[] {
return [
"-p",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--model",
input.model,
"--effort",
input.effort,
"--system-prompt-file",
input.systemPromptFile,
"--tools",
"",
"--strict-mcp-config",
"--mcp-config",
input.mcpConfigFile,
"--setting-sources",
"user",
"--settings",
JSON.stringify({ disableAllHooks: true }),
"--disable-slash-commands",
"--no-session-persistence",
"--permission-prompts",
"none",
...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []),
];
}
function createOutput(model: Model<Api>): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "pending",
timestamp: Date.now(),
};
}
function getAssistantErrorMessage(error: string, text: string): string {
return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error;
}
class ClaudeTurn {
private readonly output: AssistantMessage & Pick<LhMessage, "cliContextWindow">;
private readonly contentIndexes = new Map<number, number>();
private readonly partialJson = new Map<number, string>();
private readonly toolBlocks = new Map<number, ToolCall>();
private readonly intents: ToolCall[] = [];
started = false;
finished = false;
constructor(
private readonly stream: AssistantMessageEventStream,
model: Model<Api>,
) {
this.output = createOutput(model);
}
handle(event: CliEvent): void {
if (this.finished) return;
switch (event.type) {
case "messageStart":
this.updateUsage(event.usage);
this.start();
return;
case "blockStart":
this.startBlock(event.index, event.block);
return;
case "textDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { contentIndex, block } = found;
if (block.type !== "text") return;
block.text += event.text;
this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output });
return;
}
case "thinkingDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { contentIndex, block } = found;
if (block.type !== "thinking") return;
block.thinking += event.thinking;
this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output });
return;
}
case "toolInputDelta": {
const found = this.getBlock(event.index);
if (!found) return;
const { block } = found;
if (block.type !== "toolCall") return;
const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson;
this.partialJson.set(event.index, partialJson);
block.arguments = parseStreamingJson(partialJson);
return;
}
case "blockStop":
this.stopBlock(event.index);
return;
case "messageDelta":
this.updateUsage(event.usage);
if (event.stopReason) this.setStopReason(event.stopReason);
return;
case "messageStop":
// Tool intent is not execution admission. The process publishes only
// calls that actually reach the authenticated MCP bridge.
if (this.output.stopReason !== "toolUse") this.finish();
return;
case "assistantError":
this.fail(getAssistantErrorMessage(event.error, event.text), "error");
return;
case "resultError":
this.fail(event.text, "error");
return;
}
}
get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; }
getIntents(): ToolCall[] { return this.intents; }
getMessage(): AssistantMessage { return this.output; }
setContextWindow(tokens: number | undefined): void {
if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens;
}
admit(calls: ToolCall[]): void {
this.start();
for (const call of calls) {
const contentIndex = this.output.content.length;
this.output.content.push(call);
this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output });
}
this.output.stopReason = "toolUse";
this.finish();
}
fail(message: string, reason: "aborted" | "error"): void {
if (this.finished) return;
this.finished = true;
this.output.stopReason = reason;
this.output.errorMessage = message;
this.stream.push({ type: "error", reason, error: this.output });
this.stream.end();
}
private start(): void {
if (this.started) return;
this.started = true;
this.stream.push({ type: "start", partial: this.output });
}
private startBlock(index: number, block: CliBlock): void {
const contentIndex = this.output.content.length;
this.contentIndexes.set(index, contentIndex);
switch (block.type) {
case "text":
this.output.content.push({ type: "text", text: "" });
this.stream.push({ type: "text_start", contentIndex, partial: this.output });
return;
case "thinking":
this.output.content.push({ type: "thinking", thinking: "" });
this.stream.push({ type: "thinking_start", contentIndex, partial: this.output });
return;
case "toolUse":
if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) {
this.fail("claude CLI emitted an unsupported native tool call", "error");
return;
}
this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} });
return;
}
}
private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null {
const tool = this.toolBlocks.get(index);
if (tool) return { contentIndex: -1, block: tool };
const contentIndex = this.contentIndexes.get(index);
if (contentIndex === undefined) return null;
const block = this.output.content[contentIndex];
return block ? { contentIndex, block } : null;
}
private stopBlock(index: number): void {
const found = this.getBlock(index);
if (!found) return;
const { contentIndex, block } = found;
this.contentIndexes.delete(index);
switch (block.type) {
case "text":
this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output });
return;
case "thinking":
this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output });
return;
case "toolCall":
Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? ""));
this.partialJson.delete(index);
this.toolBlocks.delete(index);
this.intents.push(block);
return;
}
}
private updateUsage(usage: CliUsage): void {
Object.assign(this.output.usage, usage);
const { input, output, cacheRead, cacheWrite } = this.output.usage;
this.output.usage.totalTokens = input + output + cacheRead + cacheWrite;
}
private setStopReason(stopReason: string): void {
const mapped = STOP_REASONS[stopReason];
if (!mapped) {
this.output.stopReason = "error";
this.output.errorMessage = `Unhandled stop reason: ${stopReason}`;
return;
}
this.output.stopReason = mapped;
}
private finish(): void {
const { stopReason } = this.output;
if (stopReason === "error") {
this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error");
return;
}
if (stopReason === "pending" || stopReason === "aborted") {
this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error");
return;
}
this.finished = true;
this.stream.push({ type: "done", reason: stopReason, message: this.output });
this.stream.end();
}
}
type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string };
async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise<TurnFiles> {
const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-"));
let privateDir: string | undefined;
try {
privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-"));
const systemPromptFile = join(privateDir, "system-prompt.md");
const mcpConfigFile = join(privateDir, "mcp.json");
await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE });
await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE });
return { cwd, privateDir, systemPromptFile, mcpConfigFile };
} catch (error) {
await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]);
throw error;
}
}
async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise<void> {
await Promise.all([
bridge.close(),
rm(files.cwd, { recursive: true, force: true }),
rm(files.privateDir, { recursive: true, force: true }),
]);
}
class ClaudeProcess implements LiveProcess {
toolCallIds: string[] = [];
private stderr = "";
private closed = false;
private stopping = false;
private killTimer: NodeJS.Timeout | undefined;
private signal: AbortSignal | undefined;
private sourceCalls: ToolCall[] = [];
private sourceComplete = false;
private admitted: PendingToolCall[] = [];
private readonly cancelledIds = new Set<string>();
private pendingError: string | undefined;
private contextWindow: number | undefined;
private contextProbe: ContextWindowProbe | undefined;
constructor(
private readonly child: ChildProcessWithoutNullStreams,
private readonly bridge: ToolBridge,
private readonly files: TurnFiles,
private turn: ClaudeTurn,
readonly owner: symbol,
readonly sessionId: string | undefined,
private readonly configuration: unknown,
private conversation: unknown[],
) {}
start(stdinLine: string, signal: AbortSignal | undefined): void {
this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`));
this.child.once("error", (error) => {
console.error("claude CLI process failed:", error);
this.turn.fail(START_FAILED_MESSAGE, "error");
this.handleClose(START_FAILED_MESSAGE);
});
this.child.stderr.setEncoding("utf8");
this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); });
this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
this.contextProbe = new ContextWindowProbe(
(line) => this.child.stdin.write(`${line}\n`),
() => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); },
(tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); },
);
createInterface({ input: this.child.stdout }).on("line", (line) => {
try { this.handleLine(line); }
catch { this.fail("claude CLI emitted malformed stream output"); }
});
this.setSignal(signal);
if (!this.stopping) this.contextProbe.start();
}
canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
const conversation = conversationForReuse(messages);
return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId
&& isDeepStrictEqual(configuration, this.configuration)
&& isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation)
&& conversation.length === this.conversation.length + this.admitted.length
&& results.length === this.admitted.length
&& new Set(results.map((result) => result.toolCallId)).size === results.length
&& this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name));
}
continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void {
removeLiveProcess(this);
const admitted = this.admitted;
this.admitted = [];
this.conversation = structuredClone(conversationForReuse(messages));
this.turn = turn;
this.turn.setContextWindow(this.contextWindow);
this.sourceComplete = this.sourceCalls.length > 0;
this.setSignal(signal);
if (this.stopping) return;
for (const call of admitted) {
const result = results.find((candidate) => candidate.toolCallId === call.id)!;
this.bridge.resolveToolCall(call, result);
}
this.flushAdmissions();
}
kill(): void {
removeLiveProcess(this);
if (this.stopping || this.closed) return;
this.stopping = true;
this.contextProbe?.close();
this.child.kill();
this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000);
this.killTimer.unref();
}
fail(message: string): void {
// A parallel request can fail after the admitted slice was handed to
// Pi. Retain that failure for its continuation instead of silently
// replaying and hiding the compatibility error.
if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
this.turn.fail(message, "error");
this.kill();
if (this.pendingError) addLiveProcess(this);
}
consumeFailure(owner: symbol, turn: ClaudeTurn): boolean {
if (owner !== this.owner || !this.pendingError) return false;
const message = this.pendingError;
this.pendingError = undefined;
this.kill();
turn.fail(message, "error");
return true;
}
cancelCall(call: PendingToolCall): void {
this.cancelledIds.add(call.id);
// Already admitted work belongs to Pi and cannot be undone. A queued
// request, however, must never become executable after cancellation.
this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id);
}
/** Only authenticated MCP requests, never stdout intent alone, admit side effects. */
flushAdmissions(): void {
if (this.stopping || this.turn.finished || !this.sourceComplete) return;
const pending = this.bridge.getPendingToolCalls();
if (pending.length === 0) return;
const calls: ToolCall[] = [];
for (const call of pending) {
const intent = this.sourceCalls.find((candidate) => candidate.id === call.id);
if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) {
this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent");
return;
}
calls.push(intent);
}
this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id));
this.admitted = pending;
this.toolCallIds = pending.map((call) => call.id);
this.turn.admit(calls);
this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()])));
if (this.sessionId) addLiveProcess(this);
else this.kill(); // No trusted scope: return the batch, then use fresh replay.
}
private readonly abort = () => {
this.turn.fail(ABORT_MESSAGE, "aborted");
this.kill();
};
private setSignal(signal: AbortSignal | undefined): void {
this.signal?.removeEventListener("abort", this.abort);
this.signal = signal;
signal?.addEventListener("abort", this.abort, { once: true });
if (signal?.aborted) this.abort();
}
private handleLine(line: string): void {
if (this.stopping || !line.trim()) return;
if (this.contextProbe?.handle(JSON.parse(line))) return;
const event = parseCliLine(line);
if (!event) return;
if (event.type === "toolResults") {
if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) {
this.fail("claude CLI refused a harness tool call before MCP admission");
}
return;
}
// Moving on before held calls were answered means the CLI did not
// honor this tool wait. Never execute further stdout-only intentions.
if (this.turn.finished) {
this.fail("claude CLI moved on while harness tool calls were waiting for results");
return;
}
if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) {
this.fail("claude CLI moved on without admitting its harness tool calls");
return;
}
if (event.type === "messageStart") this.contextProbe?.refresh();
this.turn.handle(event);
if (this.turn.finished) { this.kill(); return; }
if (event.type === "messageStop" && this.turn.waitingForAdmission) {
const intents = this.turn.getIntents();
if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) {
this.fail("claude CLI emitted an invalid tool batch");
return;
}
this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id));
this.sourceComplete = true;
this.flushAdmissions();
}
}
private handleClose(message: string): void {
if (this.closed) return;
this.closed = true;
this.contextProbe?.close();
clearTimeout(this.killTimer);
removeLiveProcess(this);
if (this.pendingError) addLiveProcess(this);
this.signal?.removeEventListener("abort", this.abort);
if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim());
this.turn.fail(message, "error");
removeTurnResources(this.bridge, this.files).catch((error: unknown) => {
console.error("claude CLI cleanup failed:", error);
});
}
}
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
const assistant = messages[assistantIndex];
if (assistant?.role !== "assistant") return false;
const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []);
const process = getLiveProcess(ids, owner, sessionId);
if (!(process instanceof ClaudeProcess)) return false;
if (process.consumeFailure(owner, turn)) return true;
const trailing = messages.slice(assistantIndex + 1);
const results = trailing.filter((message) => message.role === "toolResult");
if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) {
process.kill();
return false;
}
process.continueTurn(turn, messages, results, signal);
return true;
}
function toClaudeEnv(): NodeJS.ProcessEnv {
// Preserve native CLI auth/routing, including launch-environment credentials.
// Claude Code chooses the account and billing method, not LearnHarness.
return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
}
async function startProcess(
runClaude: RunClaude,
turn: ClaudeTurn,
model: Model<Api>,
context: TranscriptContext,
options: SimpleStreamOptions | undefined,
owner: symbol,
configuration: unknown,
env: NodeJS.ProcessEnv,
): Promise<void> {
if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; }
const input = toClaudeInput(context.messages);
let cliProcess: ClaudeProcess | undefined;
const bridge = await createToolBridge(input.tools, {
onCall: () => cliProcess?.flushAdmissions(),
onError: (error) => cliProcess?.fail(error.message),
onCancel: (call) => cliProcess?.cancelCall(call),
});
let files: TurnFiles;
try {
files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig);
} catch (error) {
await bridge.close();
throw error;
}
if (options?.signal?.aborted) {
turn.fail(ABORT_MESSAGE, "aborted");
await removeTurnResources(bridge, files);
return;
}
let child: ChildProcessWithoutNullStreams | null;
try {
const args = toClaudeArgs({
model: model.id,
effort: toEffort(options?.reasoning),
systemPromptFile: files.systemPromptFile,
mcpConfigFile: files.mcpConfigFile,
allowedTools: bridge.allowedTools,
});
child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
} catch (error) {
await removeTurnResources(bridge, files);
throw error;
}
if (!child) {
turn.fail(ClaudeCodeError.CliNotFound, "error");
await removeTurnResources(bridge, files);
return;
}
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages)));
cliProcess.start(toStreamJsonLine(input.userContent), options?.signal);
}
export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction<Api, SimpleStreamOptions> {
const owner = Symbol("claude-code stream owner");
return (model, context, options) => {
const stream = createAssistantMessageEventStream();
const turn = new ClaudeTurn(stream, model);
const env = toClaudeEnv();
// An existing child must not retain stale authentication/routing after env changes.
const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env };
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
console.error("claude CLI turn failed:", error);
turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
});
return stream;
};
}
export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());
+234
View File
@@ -0,0 +1,234 @@
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import * as http from "node:http";
import type { AddressInfo } from "node:net";
import type { ImageContent, JsonObject, TextContent, Tool, ToolResultMessage } from "@earendil-works/pi-ai";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
type CallToolResult,
type Tool as McpTool,
} from "@modelcontextprotocol/sdk/types.js";
export const BRIDGE_SERVER_NAME = "learnharness";
const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`;
const LOOPBACK_HOST = "127.0.0.1";
const TOKEN_BYTES = 32;
export type PendingToolCall = { id: string; name: string; arguments: JsonObject };
type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void };
export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId";
export type ToolBridge = {
mcpConfig: string;
allowedTools: string[];
waitForToolCalls(count: number): Promise<PendingToolCall[]>;
resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void;
getPendingToolCalls(): PendingToolCall[];
close(): Promise<void>;
};
type ToolCallWaiter = {
count: number;
resolve: (calls: PendingToolCall[]) => void;
reject: (error: Error) => void;
};
export function toolNameFromMcp(name: string): string {
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
}
function toMcpContent(block: TextContent | ImageContent): CallToolResult["content"][number] {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "image":
return { type: "image", data: block.data, mimeType: block.mimeType };
}
}
function toCallToolResult(result: ToolResultMessage): CallToolResult {
return { content: result.content.map(toMcpContent), isError: result.isError };
}
function toMcpTool(tool: Tool): McpTool {
return { name: tool.name, description: tool.description, inputSchema: tool.parameters as McpTool["inputSchema"] };
}
class ToolCallQueue {
private readonly results = new Map<PendingToolCall, (result: CallToolResult) => void>();
private waiters: ToolCallWaiter[] = [];
add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise<CallToolResult> {
const result = new Promise<CallToolResult>((resolve) => {
const cancel = () => {
const settle = this.results.get(call);
if (!settle) return;
settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true });
onCancel?.(call);
};
this.results.set(call, (result) => {
this.results.delete(call);
signal.removeEventListener("abort", cancel);
resolve(result);
});
signal.addEventListener("abort", cancel, { once: true });
if (signal.aborted) cancel();
});
this.wakeWaiters();
return result;
}
waitFor(count: number): Promise<PendingToolCall[]> {
return new Promise((resolve, reject) => {
this.waiters.push({ count, resolve, reject });
this.wakeWaiters();
});
}
resolve(call: PendingToolCall, result: ToolResultMessage): void {
this.results.get(call)?.(toCallToolResult(result));
}
list(): PendingToolCall[] {
return [...this.results.keys()];
}
clear(): void {
for (const settle of this.results.values()) {
settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true });
}
for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed"));
this.waiters = [];
}
private wakeWaiters(): void {
const calls = this.list();
const ready = this.waiters.filter((waiter) => calls.length >= waiter.count);
this.waiters = this.waiters.filter((waiter) => calls.length < waiter.count);
for (const waiter of ready) waiter.resolve(calls);
}
}
// Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry
// JSON Schema that must reach the CLI unchanged.
function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: BridgeCallbacks): Server {
const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) }));
const seenIds = new Set<string>();
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
const id = request.params._meta?.["claudecode/toolUseId"];
if (typeof id !== "string" || !id.trim() || seenIds.has(id)) {
const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR);
callbacks?.onError(error);
throw error;
}
if (!tools.some((tool) => tool.name === request.params.name)) {
const error = new McpError(ErrorCode.InvalidParams, "claude CLI requested an undeclared harness tool");
callbacks?.onError(error);
throw error;
}
seenIds.add(id);
const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel);
callbacks?.onCall();
return result;
});
return server;
}
function isAuthorized(header: string | undefined, expected: Buffer): boolean {
if (header === undefined) return false;
const actual = Buffer.from(header);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function createHttpServer(transport: StreamableHTTPServerTransport, authorization: string): http.Server {
const expected = Buffer.from(authorization);
return http.createServer((req, res) => {
if (!isAuthorized(req.headers.authorization, expected)) {
res.writeHead(401).end();
return;
}
transport.handleRequest(req, res).catch((error: unknown) => {
console.error("claude-code tool bridge request failed", error);
if (!res.headersSent) res.writeHead(500);
res.end();
});
});
}
function listen(server: http.Server): Promise<AddressInfo> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, LOOPBACK_HOST, () => resolve(server.address() as AddressInfo));
});
}
function closeHttpServer(server: http.Server): Promise<void> {
server.closeAllConnections();
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
function toMcpConfig(port: number, authorization: string): string {
const server = { type: "http", url: `http://${LOOPBACK_HOST}:${port}/mcp`, headers: { Authorization: authorization } };
return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } });
}
export async function createToolBridge(tools: Tool[], callbacks?: BridgeCallbacks): Promise<ToolBridge> {
const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`;
const queue = new ToolCallQueue();
const mcpServer = createMcpServer(tools, queue, callbacks);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await mcpServer.connect(transport);
const httpServer = createHttpServer(transport, authorization);
let port: number;
try {
({ port } = await listen(httpServer));
} catch (error) {
await mcpServer.close();
throw error;
}
return {
mcpConfig: toMcpConfig(port, authorization),
allowedTools: tools.map((tool) => `${MCP_TOOL_PREFIX}${tool.name}`),
waitForToolCalls: (count) => queue.waitFor(count),
resolveToolCall: (call, result) => queue.resolve(call, result),
getPendingToolCalls: () => queue.list(),
close: async () => {
queue.clear();
await mcpServer.close();
await closeHttpServer(httpServer);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai";
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
export type ClaudeContentBlock =
| { type: "text"; text: string }
| { type: "image"; source: { type: "base64"; media_type: string; data: string } };
export type ClaudeInput = {
systemPrompt: string;
tools: Tool[];
userContent: ClaudeContentBlock[];
};
type ReplayPart = string | ImageContent;
function toImageBlock(image: ImageContent): ClaudeContentBlock {
return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
}
function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] {
if (typeof content === "string") return [`${label}\n${content}`];
const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
const images = content.filter((block) => block.type === "image");
return [text ? `${label}\n${text}` : label, ...images];
}
function getReplayParts(message: Message): ReplayPart[] {
switch (message.role) {
case "system":
return [];
case "user":
return getLabelledParts("[user]", message.content);
case "assistant":
return message.content.flatMap((block) => {
if (block.type === "text") return [`[assistant]\n${block.text}`];
if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`];
return [];
});
case "toolResult": {
const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`;
return getLabelledParts(label, message.content);
}
}
}
function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
const blocks: ClaudeContentBlock[] = [];
let pendingText: string[] = [];
const flushText = () => {
if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") });
pendingText = [];
};
for (const part of parts) {
if (typeof part === "string") {
pendingText.push(part);
} else {
flushText();
blocks.push(toImageBlock(part));
}
}
flushText();
return blocks;
}
function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] {
if (typeof content === "string") return [{ type: "text", text: content }];
return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block)));
}
function toUserContent(conversation: Message[]): ClaudeContentBlock[] {
const [first] = conversation;
if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content);
return toContentBlocks(conversation.flatMap(getReplayParts));
}
export function toClaudeInput(messages: Message[]): ClaudeInput {
const conversation = messages.filter((message) => message.role !== "system");
return {
systemPrompt: getCurrentSystemPrompt(messages),
tools: getCurrentTools(messages),
userContent: toUserContent(conversation),
};
}
export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
return JSON.stringify({ type: "user", message: { role: "user", content } });
}
+3
View File
@@ -17,6 +17,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
import { chatGPTModel } from "./chatgpt-bridge.ts";
import { chatGPTStream } from "./chatgpt-stream.ts";
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
import { createClaudeCodeProvider } from "./claude-code/provider.ts";
import { streamClaudeCode } from "./claude-code/stream.ts";
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
import { nativeSubscriptionStream } from "./subscription-stream.ts";
@@ -81,6 +83,7 @@ export function piModels() {
if (!collection) {
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
}
providerSource?.sync(collection);
return collection;
+2 -1
View File
@@ -18,6 +18,7 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
import { sendToUserTool } from "./tools/send-to-user.ts";
import { createReadTool, readTool } from "./tools/read.ts";
import { CLAUDE_CODE_PROVIDER } from "./claude-code/constants.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
@@ -205,7 +206,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
: tool),
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
...(opts.tools === undefined && (engine.model.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude"))
? [withArgumentHeal(sendToUserTool)]
: []),
],
+24
View File
@@ -0,0 +1,24 @@
/** Shared provenance only: no credential-store reads in the agent layer. */
interface AuthBoot {
/** Names owned by the original launch environment. */
realEnv: Set<string>;
/** Names reflected into process.env by LearnHarness's key store. */
fillGap: Set<string>;
}
const BOOT_KEY = Symbol.for("lh.authBoot");
export function authEnvRecord(): AuthBoot {
const g = globalThis as Record<symbol, AuthBoot | undefined>;
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
return g[BOOT_KEY];
}
/** External CLIs own their authentication. Never pass LearnHarness-only injections. */
export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const result = { ...env };
const { realEnv, fillGap } = authEnvRecord();
for (const name of fillGap) {
if (!realEnv.has(name)) delete result[name];
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
export const CLAUDE_CODE_PROVIDER = "claude-code";
/** Installation/version only. Never an authentication, quota, or billing verdict. */
export interface ClaudeCodeStatus {
state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked";
version: string | null;
minimumVersion: string;
}
// --permission-prompts none was added in 2.1.259. This is not an official
// minimum-version/stability contract for claudecode/toolUseId; admission fails closed.
export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259";
+5 -3
View File
@@ -21,9 +21,11 @@ export interface ModelCatalogEntry {
* thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking
* picker lists exactly these; anything else is a lie. */
supportedLevels: ThinkingLevel[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
/** Null when LearnHarness cannot estimate prices for this connection. */
cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null;
/** Null for native CLI aliases whose effective limits are session-specific. */
contextWindow: number | null;
maxTokens: number | null;
}
/** The conversation agents' resolved default (BYOK, owner 2026-09-22): the
+1
View File
@@ -56,6 +56,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
export * from "./cost-summary.ts";
export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
+23 -3
View File
@@ -36,6 +36,7 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { QUESTION_TOOLS } from "./pending.ts";
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
import {
CONCLUDE_ACK_TEXT,
HANDOFF_TEXT,
@@ -90,7 +91,26 @@ declare module "@earendil-works/pi-agent-core" {
}
/** A record message: a native pi message (or our marker) plus the record id. */
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & {
id: string;
/** CLI-reported effective context budget, not a catalog model maximum.
* App-only metadata: never transmitted as provider/model message content. */
cliContextWindow?: number;
};
/** Latest response only: never borrow an older process/model's window when
* a fresh response has no metadata. Compaction invalidates the old report. */
export function cliContextWindowOf(messages: readonly LhMessage[]): { model: string; tokens: number } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role === "custom" && message.customType === "compaction") return null;
if (message.role !== "assistant") continue;
const tokens = message.cliContextWindow;
return message.provider === CLAUDE_CODE_PROVIDER && typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0
? { model: `${message.provider}:${message.model}`, tokens } : null;
}
return null;
}
/** A record message list is assignable to the agent's context — that IS
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
@@ -617,8 +637,8 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
});
continue;
}
// Strip the record id: the wire carries the native shape only.
const { id: _id, ...native } = m as LhMessage;
// Strip app-only id/CLI context metadata: the wire carries the native shape only.
const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage;
out.push(native as Message);
// Repair unanswered calls from completed generations, not calls in
// failed generations. Pi filters error/aborted assistants itself;
+12 -2
View File
@@ -29,6 +29,7 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts";
@@ -156,10 +157,11 @@ export interface ProviderKeyMissing {
/** The refusal wording's invariant tail — the matcher below keys on it
* (the label prefix varies by provider). */
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers.";
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
return {
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
code: PROVIDER_KEY_MISSING_CODE,
provider,
slots,
@@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr
* scary-error) — the envelope's home module owns the string so the
* writer and the matcher can never drift. */
export function isProviderKeyRefusal(error: string | undefined): boolean {
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE);
}
/** The typed throw for in-process seams (extraction pipeline) — the
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
@@ -368,6 +370,8 @@ export interface UserSettings {
version: number;
general?: GeneralSettings;
dictation?: DictationSettings;
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
claudeCode?: { enabled?: boolean };
agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
@@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
? { language: (raw as DictationSettings).language }
: {};
}
if (r.claudeCode !== undefined) {
const raw = r.claudeCode;
out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw)
&& typeof (raw as Record<string, unknown>).enabled === "boolean"
? { enabled: (raw as { enabled: boolean }).enabled } : {};
}
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
}
+8 -1
View File
@@ -7,7 +7,7 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
@@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import { providerKeyPresent } from "./keys.ts";
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import {
extractionEnabled,
graphToolsEnabled,
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
* for write-ahead persistence, turn liveness, and the turn services.
*/
/** Fresh check only at new background-run admission; never cancel a run
* already in progress when the opt-in is switched off. */
function connectionBackgroundAllowed(provider: string): boolean {
return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider));
}
function settlementErrorText(error: unknown): string {
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
}
+13
View File
@@ -0,0 +1,13 @@
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { settingsStore } from "../store/settings.ts";
/** Fresh admission state, not a process-wide cache. Existing runs retain
* their engines; only new chat/job/extraction/compaction admissions consult it. */
export function claudeCodeEnabled(): boolean {
return settingsStore.readSettings()?.claudeCode?.enabled === true;
}
/** Installation/opt-in availability only, never authenticated readiness. */
export function claudeCodeAvailable(): boolean {
return claudeCodeEnabled() && getClaudeBinary() !== null;
}
+30
View File
@@ -0,0 +1,30 @@
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
import { initAuthEnv } from "./keys.ts";
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
import { claudeCodeEnabled } from "./claude-code-config.ts";
/** The Settings status seam: initialize provenance before any child env is copied. */
export async function claudeCodeInstallationStatus() {
initAuthEnv();
return inspectClaudeInstallation();
}
/** Same-origin browser intent, not user authentication. No CORS grants.
* A public/shared server still requires its own access-control boundary. */
export async function claudeCodeInstallationResponse(
request: Request,
inspect = claudeCodeInstallationStatus,
enabled = claudeCodeEnabled,
): Promise<Response> {
const origin = subscriptionRequestOrigin(request);
const supplied = request.headers.get("origin");
const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
if (request.method !== "GET" || !origin
|| request.headers.get("x-learnharness-claude-code") !== "1"
|| request.headers.get("sec-fetch-site") === "cross-site"
|| (supplied !== null && supplied !== origin)) {
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
}
if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
return Response.json(await inspect(), { headers });
}
+1
View File
@@ -45,3 +45,4 @@ export * from "./observability.ts";
export * from "./model-catalog.ts";
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts";
export { claudeCodeInstallationResponse } from "./claude-code.ts";
+8 -17
View File
@@ -55,24 +55,12 @@ import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { piModels } from "../agent/models.ts";
import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
// The once-per-process boot record — globalThis anchor (HMR re-imports the
// module; the snapshot must not re-run and misread fill-gapped vars as real
// env). Same pattern as globalThis.__lhExtractionRuns.
interface AuthBoot {
/** Vars that were in the REAL env at first init — env precedence's
* untouchable set. Fill-gapped vars are everything else we set. */
realEnv: Set<string>;
/** Vars WE put into process.env (the file's reflection) — deletable. */
fillGap: Set<string>;
}
const BOOT_KEY = Symbol.for("lh.authBoot");
function boot(): AuthBoot {
const g = globalThis as Record<symbol, AuthBoot | undefined>;
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
return g[BOOT_KEY];
}
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
// without importing this store-backed module into the agent layer.
import { authEnvRecord as boot } from "../core/auth-env.ts";
/** Is `id` a WRITABLE key name — curated catalog or model-provider env
* var (provider-keys interlude: PUT/DELETE accept both). */
@@ -206,6 +194,9 @@ export function providerKeyPresent(provider: string): boolean {
piModels(); // Synchronize hand-edited custom configuration before admission.
const custom = customProviderConfigured(provider);
if (custom !== undefined) return custom;
// Explicit opt-in + installation only, never authentication readiness.
// Consulted at admission, not inside already-running agent loops.
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
+11 -6
View File
@@ -32,6 +32,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
]);
}
function connectionModel(provider: string, id: string): Model<Api> | undefined {
if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined;
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
}
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
@@ -49,6 +50,8 @@ import type {
} from "../core/contracts/models.ts";
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
@@ -143,6 +146,7 @@ export function catalogProviders(): CatalogProvider[] {
keyPresent: keyValue(p.envVar) !== undefined,
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
...customProviderRows(),
]).sort((a, b) => a.id.localeCompare(b.id));
@@ -164,21 +168,22 @@ export function toModelDto(model: Model<Api>): ModelDto {
reasoning: model.reasoning,
input: [...model.input],
supportedLevels: getSupportedThinkingLevels(model),
cost: {
cost: model.provider === CLAUDE_CODE_PROVIDER ? null : {
input: model.cost.input,
output: model.cost.output,
cacheRead: model.cost.cacheRead,
cacheWrite: model.cost.cacheWrite,
},
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow,
maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens,
};
}
/** The live-limits index (context-meter amendment, owner 2026-09-23):
* provider:id → contextWindow from pi's catalog, over the SAME keyed
* providers the picker federates. The one source for every context
* meter's denominator — the stats-bar join holds /api/models' copy,
* providers the picker federates. Native CLI aliases are excluded:
* only a session-specific CLI report can provide their denominator.
* For other models the stats-bar join holds /api/models' copy,
* and the activity feed (observability.ts) resolves job limits
* server-side through this. No hardcoded fallback exists anywhere:
* a model missing here reads limit-null (usage-only, honest render). */
@@ -186,7 +191,7 @@ export function catalogContextLimits(): Map<string, number> {
const keyed = keyedProviderIds();
const index = new Map<string, number>();
for (const provider of piModels().getProviders()) {
if (!keyed.has(provider.id)) continue;
if (!keyed.has(provider.id) || provider.id === CLAUDE_CODE_PROVIDER) continue;
for (const model of piModels().getModels(provider.id)) {
index.set(`${provider.id}:${model.id}`, model.contextWindow);
}
+4 -1
View File
@@ -4,12 +4,15 @@
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
function catalogModel(id: string) {
const colon = id.indexOf(":");
if (colon < 0 || isSubscriptionModel(id)) return undefined;
// Native CLI routing determines billing. Adapter zero-rate placeholders
// must never become an apparent $0 estimate or subscription classification.
if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}
+10
View File
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
return { ok: true, settings };
}
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
if (raw.enabled === undefined) return { ok: true, settings: {} };
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
return { ok: true, settings: { enabled: raw.enabled } };
}
/** agent.models: only the six known background roles; each override's
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
dictation: parseDictationSettingsBody,
claudeCode: parseClaudeCodeSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;