Merge native Claude Code integration with explicit opt-in
# Conflicts: # src/agent/teacher-agent.ts # src/server/keys.ts
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
: []),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user