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
+6
View File
@@ -0,0 +1,6 @@
import { claudeCodeInstallationResponse } from "@/src/server";
/** Installation/version only; never reads auth status or makes a model request. */
export async function GET(request: Request) {
return claudeCodeInstallationResponse(request);
}
+5
View File
@@ -38,6 +38,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
import {
isProviderKeyRefusal,
firstSourceTextParts,
cliContextWindowOf,
messageAnchorId,
textPartMessageId,
kbdEventForAction as kbdEventForChat,
@@ -1691,6 +1692,7 @@ export function Chat({
phase={phases.at(-1) ?? "probe"}
turns={turns}
contextTokens={contextTokens}
cliContextWindow={cliContextWindowOf(nativeMessages)}
pricing={pricing}
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
@@ -1917,6 +1919,7 @@ function DockOverlay({
phase,
turns,
contextTokens,
cliContextWindow,
pricing,
composer,
onSubmitAnswer,
@@ -1953,6 +1956,7 @@ function DockOverlay({
/** The session's usage log — the stats bar beneath the composer folds it. */
turns: TurnUsage[];
contextTokens: number | null;
cliContextWindow: ReturnType<typeof cliContextWindowOf>;
pricing?: SessionPricing;
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
@@ -2408,6 +2412,7 @@ function DockOverlay({
<StatsBar
turns={turns}
contextTokens={contextTokens}
cliContextWindow={cliContextWindow}
pricing={pricing}
dictationSlot={dictationSlot}
chatId={chatId}
+1 -1
View File
@@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined;
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
if (section === "keys" || section === "claudeCode") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
fetchCatalog();
} else if (section === "models") fetchCatalog(true);
+5 -8
View File
@@ -13,15 +13,12 @@ import { useModelCatalog } from "@/components/model-catalog";
export { useModelCatalog } from "@/components/model-catalog";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
/** provider:id → contextWindow over the cached catalog — the client-side
* twin of the server's catalogContextLimits join (context-meter
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
* source — no hardcoded fallback exists anywhere, so a model missing
* from the index reads limit-null and the meter renders usage-only,
* the honest-render precedent). Consumed by the stats bar: the same
* cached /api/models fetch the picker rides feeds the denominator. */
/** provider:id → known contextWindow over the cached catalog, mirroring
* the server's catalogContextLimits join. CLI aliases have no global
* denominator: the stats bar uses that session's actual CLI report instead.
* Missing limits always render usage-only, without a hardcoded fallback. */
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow]));
return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const]));
}
/**
+1 -1
View File
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
// dispatches it after save AND delete — deletions used to be invisible to
// the zero-keys pill and the setup dialog's auto-advance.
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation";
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode";
/** Writers call this after the PUT resolves ok — never before (a failed
* write notifies nothing; the record is unchanged). */
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { Button } from "@/components/ui/button";
import { Toggle } from "./controls";
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
switch (status?.state) {
case "not-found": return "Not found";
case "meets-minimum": return `Version ${status.version} · meets minimum`;
case "update-required": return `Version ${status.version} · update required`;
default: return "Version could not be checked";
}
}
export function ClaudeCodeInstallationStatus() {
const [enabled, setEnabled] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [busy, setBusy] = useState(false);
const request = useRef<AbortController | null>(null);
const settingsRead = useRef<AbortController | null>(null);
const settingsWrite = useRef<AbortController | null>(null);
const read = useCallback(async () => {
settingsRead.current?.abort();
const controller = new AbortController();
settingsRead.current = controller;
setLoaded(false);
try {
const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error("settings unavailable");
const body = await response.json() as { settings: UserSettings | null };
if (!controller.signal.aborted) {
setEnabled(body.settings?.claudeCode?.enabled === true);
setLoaded(true);
setError(null);
}
} catch {
if (!controller.signal.aborted) setError("Could not load Claude Code settings.");
}
}, []);
useEffect(() => {
void read();
return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); };
}, [read]);
useSettingsSaved((section) => { if (section === "claudeCode") void read(); });
const save = async (next: boolean) => {
const controller = new AbortController();
settingsWrite.current = controller;
settingsRead.current?.abort();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: next }), signal: controller.signal });
if (!response.ok) throw new Error("settings write failed");
const body = await response.json() as { settings: UserSettings };
if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response");
if (!controller.signal.aborted) {
setEnabled(body.settings.claudeCode.enabled);
notifySettingsSaved("claudeCode");
}
} catch {
if (!controller.signal.aborted) setError("Could not save Claude Code settings.");
} finally {
if (!controller.signal.aborted) setSaving(false);
}
};
const refresh = useCallback(async () => {
if (!enabled) return;
request.current?.abort();
const controller = new AbortController();
request.current = controller;
setBusy(true);
try {
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } });
if (!response.ok) throw new Error("status unavailable");
const value = await response.json() as ClaudeCodeStatus;
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
|| value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION
|| (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status");
if (!controller.signal.aborted) setStatus(value);
} catch {
if (!controller.signal.aborted) setStatus(null);
} finally {
if (!controller.signal.aborted) setBusy(false);
}
}, [enabled]);
useEffect(() => {
if (enabled) void refresh();
else { request.current?.abort(); setStatus(null); setBusy(false); }
return () => request.current?.abort();
}, [enabled, refresh]);
return <div className="space-y-2" data-testid="claude-code-installation">
<div className="flex items-center gap-3">
<span className="font-prose text-(length:--lh-prose-size) text-foreground">Claude Code</span>
<Toggle label="Claude Code" checked={enabled} disabled={!loaded || saving} onChange={(next) => void save(next)} />
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{enabled && <>
<div className="flex flex-wrap items-center gap-3">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" disabled={busy} onClick={() => void refresh()} data-testid="claude-code-check-again">Check again</Button>
</div>
<p className="text-xs leading-relaxed text-muted-foreground"><strong className="font-medium">Install and authenticate Claude Code in your terminal, then select a Claude Code model here.</strong> LearnHarness starts it when needed—no background process required.</p>
<p className="text-xs leading-relaxed text-muted-foreground">Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Setup ↗</a></p>
<p className="text-xs leading-relaxed text-muted-foreground">Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.</p>
</>}
</div>;
}
+3 -2
View File
@@ -4,11 +4,12 @@ import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
{children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
+5
View File
@@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
@@ -471,6 +472,10 @@ export function KeysSection({
<SubscriptionConnections />
</SettingsGroup>
<SettingsGroup label="External CLI" testid="group-external-cli">
<ClaudeCodeInstallationStatus />
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
<KeyTableRow
+5 -4
View File
@@ -21,11 +21,12 @@
import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore, subscriptionLabel } from "@/src/core";
import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
import { SearchMenuList } from "./search-menu-list";
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
if (!cost) return "Unpriced";
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
return `${fmt(cost.input)}/${fmt(cost.output)}`;
}
@@ -72,13 +73,13 @@ export function ModelMenuList({
renderItem={(m) => <>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
<span className="shrink-0 tabular-nums text-muted-foreground" title={m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable"}>
{m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
</span>
</>}
/>
+24 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
@@ -10,6 +10,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
}
/** CLI aliases have no global catalog limit: only this session's matching
* response can supply a denominator. Other providers keep their catalog join. */
export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap<string, number> | undefined,
cliWindow: ReturnType<typeof cliContextWindowOf> = null): number | null {
if (!model) return null;
if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null;
return limits?.get(model) ?? null;
}
/**
* The below-composer stats bar (wave 4 observability): the session's
* money/context footprint as one quiet row beneath the composer —
@@ -24,12 +33,14 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
* Spending arrives already priced by the server through Pi's catalog and
* calculator. Missing cost stays "—", never fake zero. Context is a separate
* completed-conversation measurement, never a money-log/summarizer fold or
* transcript estimate. Its catalog denominator follows the selected model;
* model changes preserve the numerator. Successful compaction clears it to ?.
* transcript estimate. Its denominator follows the selected model, from the
* catalog or the session's CLI report; model changes preserve the numerator.
* Successful compaction clears the numerator and any old CLI report.
*/
export function StatsBar({
turns,
contextTokens = null,
cliContextWindow = null,
pricing,
dictationSlot,
chatId,
@@ -38,6 +49,7 @@ export function StatsBar({
}: {
turns: TurnUsage[];
contextTokens?: number | null;
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
pricing?: SessionPricing;
/** The bound session (per-session models, owner 2026-09-28): present
* on the in-session bar — the model control binds THE SESSION'S own
@@ -86,18 +98,19 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// Catalog-only denominator; the picker reports its effective selection.
// Missing/pending catalog means usage-only, never a hardcoded maximum.
// The picker reports its effective selection. CLI windows are session-
// scoped; unknown metadata always means usage-only, never a guessed maximum.
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const limit = contextLimitForModel(selectedModel, limits, cliContextWindow);
const contextLabel = formatContextMeasurement(contextTokens, limit);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers);
return (
<div
@@ -151,6 +164,9 @@ export function StatsBar({
{contextLabel}
</span>
</div>
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
<p>Window reported by Claude Code; may be lower for auto-compaction.</p>
)}
<div className="flex justify-between gap-2">
<span>{subscription ? "Usage" : "Total cost"}</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
@@ -178,7 +194,7 @@ export function StatsBar({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
</span>
</li>
);
+906 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,6 +26,7 @@
"dependencies": {
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@modelcontextprotocol/sdk": "1.32.1",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
+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;
+4 -2
View File
@@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep
assert.doesNotMatch(html, />Model providers<\/button>/);
});
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => {
globalThis.fetch = async (url) => {
if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" });
assert.equal(url, "/api/keys");
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
};
fixture(); await mount(<KeysSection />);
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]);
assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]'));
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
+70
View File
@@ -0,0 +1,70 @@
/**
* Claude Code binary lookup (src/agent/claude-code/binary.ts).
*
* The packaged desktop app often starts with a minimal PATH, so lookup falls
* back to the standard install locations. A fake isExecutable set stands in
* for the filesystem.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
const HOME = "/home/u";
function resolveWith(executables: string[], env: Record<string, string> = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) {
const found = new Set(executables);
return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) });
}
test("prefers PATH hit", () => {
const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" });
assert.equal(binary, "/usr/bin/claude");
});
test("falls back to ~/.local/bin when PATH misses", () => {
const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" });
assert.equal(binary, `${HOME}/.local/bin/claude`);
});
test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => {
const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"];
for (let i = 0; i < all.length; i++) {
assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]);
}
});
test("npm global bin follows npm_config_prefix when set", () => {
const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" });
assert.equal(binary, "/opt/npm/bin/claude");
});
test("windows finds native claude.exe and does not advertise unsupported .cmd-only installs", () => {
const winHome = "C:\\Users\\u";
const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), null);
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
assert.equal(
resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome),
null,
);
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
});
test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spawn without a shell", () => {
const winHome = "C:\\Users\\u";
const env = { PATH: "C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
const executables = ["C:\\bin\\claude.cmd", `${winHome}\\.local\\bin\\claude.exe`];
assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
});
test("relative Windows PATH entries are fixed to an absolute discovery identity", () => {
assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server",
isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe",
}), "C:\\server\\bin\\claude.exe");
});
test("returns null when every location misses", () => {
assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null);
});
+46
View File
@@ -0,0 +1,46 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { StatsBar } from "../components/stats-bar.tsx";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { toModelDto } from "../src/server/model-catalog.ts";
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; globalThis.fetch = originalFetch; });
test("compact CLI context is numeric only; effective-window explanation appears only in expanded session metadata", async () => {
const catalog: ModelCatalogResponse = { models: CLAUDE_CODE_MODELS.map(toModelDto), providers: [],
slots: { strong: null, cheap: null, vision: null }, defaults: null,
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null } };
globalThis.fetch = (async (url) => {
if (url === "/api/models") return Response.json(catalog);
if (url === "/api/settings") return Response.json({ settings: { models: {} } });
if (url === "/api/chats/cli-session") return Response.json({ settings: { model: { provider: "claude-code", id: "opus" } } });
throw new Error(`unexpected request ${url}`);
}) as typeof fetch;
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] };
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[turn]} contextTokens={42000} cliContextWindow={window} />);
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
assert(!document.body.textContent?.includes("auto-compaction"));
assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI");
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
await act(async () => summary.click());
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./);
assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI");
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
await act(async () => render(null));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
await act(async () => render({ model: "claude-code:sonnet", tokens: 1000000 }));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k", "another alias cannot supply this session's denominator");
});
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ContextWindowProbe } from "../src/agent/claude-code/context-window.ts";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { asAgentMessages, cliContextWindowOf, toLlmMessages, type LhMessage } from "../src/core/native-messages.ts";
import { catalogLimits } from "../components/model-picker.tsx";
import { contextLimitForModel, formatContextMeasurement } from "../components/stats-bar.tsx";
import { toModelDto } from "../src/server/model-catalog.ts";
function setup() {
const writes: { type: string; request_id: string; request: Record<string, string> }[] = [];
const windows: number[] = [];
let prompts = 0;
const probe = new ContextWindowProbe((line) => writes.push(JSON.parse(line)), () => prompts++, (tokens) => windows.push(tokens));
probe.start();
const reply = (index: number, body: unknown, subtype = "success") => probe.handle({ type: "control_response", response: {
request_id: writes[index]!.request_id, subtype, response: body,
} });
return { probe, writes, windows, reply, prompts: () => prompts };
}
test("control initialization precedes a summary-only query; neither needs user input or a model request", () => {
const p = setup();
assert.deepEqual(p.writes[0]!.request, { subtype: "initialize" }); assert.equal(p.prompts(), 0);
p.reply(0, {});
assert.deepEqual(p.writes[1]!.request, { subtype: "get_context_usage", detail: "summary" });
assert.equal(p.prompts(), 1, "conversation does not wait for metadata");
assert.notEqual(p.writes[0]!.request_id, p.writes[1]!.request_id);
p.probe.handle({ type: "control_response", response: { request_id: "foreign", subtype: "success", response: { maxTokens: 999 } } });
assert.deepEqual(p.windows, []);
p.reply(1, { maxTokens: 200000, rawMaxTokens: 1000000 });
assert.deepEqual(p.windows, [200000], "effective maxTokens, not an assumed nominal window");
p.reply(0, {}); p.reply(1, { maxTokens: 1 });
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, [200000]);
p.probe.close();
});
test("metadata unavailable before first response can be queried again locally; stale/closed replies are ignored", () => {
const p = setup(); p.reply(0, {}); p.reply(1, {}, "error");
assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, []);
p.probe.refresh();
assert.deepEqual(p.writes[2]!.request, { subtype: "get_context_usage", detail: "summary" });
p.reply(1, { maxTokens: 1 }); assert.deepEqual(p.windows, []);
p.reply(2, { maxTokens: 200000 }); assert.deepEqual(p.windows, [200000]);
p.probe.refresh(); p.probe.close(); p.reply(3, { maxTokens: 999999 });
assert.deepEqual(p.windows, [200000]); assert.equal(p.prompts(), 1);
});
for (const tokens of [null, undefined, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "200000"]) test(`invalid summary budget ${tokens} remains unknown without failing the prompt`, () => {
const p = setup(); p.reply(0, {}); p.reply(1, { maxTokens: tokens });
assert.deepEqual(p.windows, []); assert.equal(p.prompts(), 1); p.probe.close();
});
test("initialization errors/timeouts fall back to ordinary prompt delivery; close cancels the timer", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const error = setup(); error.reply(0, undefined, "error");
assert.equal(error.prompts(), 1); assert.equal(error.writes.length, 1); error.probe.close();
const timeout = setup(); t.mock.timers.tick(5000);
assert.equal(timeout.prompts(), 1); assert.equal(timeout.writes.length, 1);
timeout.reply(0, {}); assert.equal(timeout.writes.length, 1); timeout.probe.close();
const closed = setup(); closed.probe.close(); t.mock.timers.tick(5000);
assert.equal(closed.prompts(), 0);
});
function assistant(model = "opus", tokens: number | undefined = 200000): LhMessage & { role: "assistant" } {
return { id: "cli-assistant", role: "assistant", api: "claude-code-cli", provider: "claude-code", model,
content: [], timestamp: 0, stopReason: "stop", cliContextWindow: tokens,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
test("per-session metadata survives JSON persistence, never borrows older reports, and cannot override other model selections", () => {
const first = assistant();
const window = cliContextWindowOf(JSON.parse(JSON.stringify([first])));
assert.deepEqual(window, { model: "claude-code:opus", tokens: 200000 });
assert(!("cliContextWindow" in toLlmMessages(asAgentMessages([first]))[0]!));
assert.equal(first.cliContextWindow, 200000, "provider projection never mutates the stored report");
const catalog = new Map([["anthropic:fixture", 100000], ["claude-code:opus", 1000000]]);
assert.equal(contextLimitForModel("claude-code:opus", catalog, window), 200000);
assert.equal(contextLimitForModel("claude-code:sonnet", catalog, window), null);
assert.equal(contextLimitForModel("claude-code:opus", catalog, null), null, "no placeholder fallback");
assert.equal(contextLimitForModel("anthropic:fixture", catalog, window), 100000);
assert.equal(contextLimitForModel(undefined, catalog, window), null);
assert.equal(formatContextMeasurement(42000, 200000), "42k / 200k");
assert.equal(formatContextMeasurement(42000, null), "42k");
const unreported = assistant("opus"); delete unreported.cliContextWindow;
assert.equal(cliContextWindowOf([first, unreported]), null);
const other = assistant("api"); other.provider = "anthropic";
assert.equal(cliContextWindowOf([first, other]), null);
const compacted: LhMessage = { id: "compact", role: "custom", customType: "compaction", content: "summary", display: false, details: {}, timestamp: 1 };
assert.equal(cliContextWindowOf([first, compacted]), null);
assert.equal(cliContextWindowOf([]), null);
});
test("CLI catalog limits remain unknown and cannot create a global denominator", () => {
const models = CLAUDE_CODE_MODELS.map(toModelDto);
assert(models.every((model) => model.contextWindow === null && model.maxTokens === null));
assert.equal(catalogLimits({ models }).size, 0);
});
+319
View File
@@ -0,0 +1,319 @@
/**
* Claude Code provider driven through the real chat handler: a learner-facing
* question ends the turn while the CLI process waits on the held MCP call, and
* the learner's answer arrives later as that call's tool result.
*
* The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, one
* scenario per spawn, so the spawn count shows whether the answer continued
* the waiting process or started a fresh one with a replay.
*/
import assert from "node:assert/strict";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import test, { after, type TestContext } from "node:test";
import type { RunClaude } from "../src/agent/claude-code/stream.ts";
import type { PiEngine } from "../src/agent/models.ts";
// Opt-in writes must not race another test process's settings record.
const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-"));
process.env.LH_DATA_DIR = testData;
after(() => rmSync(testData, { recursive: true, force: true }));
const { PATHS, SETTINGS_FILE } = await import("../config.ts");
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts");
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts");
const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts");
const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts");
const { handleChatRequest } = await import("../src/server/chat-handler.ts");
const { isTurnRunning } = await import("../src/server/turn-liveness.ts");
const { runningTurn } = await import("../src/server/turn-runs.ts");
const { chatStore } = await import("../src/store/chats.ts");
const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url));
const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url));
const TURN_TIMEOUT_MS = 8000;
const ASK_ID = "toolu_harness_ask";
const ASK_INPUT = { question: "What do you already know about sockets?" };
const ANSWER_TEXT = "Only that they carry bytes between programs";
type FakeRun = { runClaude: RunClaude; children: ChildProcessWithoutNullStreams[]; stdinFiles: string[] };
function streamEvent(event: object): string {
return JSON.stringify({ type: "stream_event", event });
}
function toAskScenario(): string[] {
return [
streamEvent({ type: "message_start", message: { usage: {} } }),
streamEvent({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: ASK_ID, name: "mcp__learnharness__ask_user_question", input: {} },
}),
streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(ASK_INPUT) } }),
streamEvent({ type: "content_block_stop", index: 0 }),
streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }),
streamEvent({ type: "message_stop" }),
JSON.stringify({ fake: "call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
JSON.stringify({ fake: "reply" }),
];
}
function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun {
const settings = readSettings();
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } });
t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); });
const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
// Satisfy the real presence gate; this marker is NEVER executed. All work
// below goes through the injected Node fake-CLI seam, not an installed CLI.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`;
t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; });
const children: ChildProcessWithoutNullStreams[] = [];
const stdinFiles: string[] = [];
const runClaude: RunClaude = (args, options) => {
const scenario = scenarios[children.length];
assert.ok(scenario, `unexpected spawn #${children.length + 1}`);
const scenarioFile = join(dir, `scenario-${children.length}.jsonl`);
const stdinFile = join(dir, `stdin-${children.length}.jsonl`);
writeFileSync(scenarioFile, `${scenario.join("\n")}\n`);
const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], {
cwd: options.cwd,
env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile },
stdio: ["pipe", "pipe", "pipe", "ipc"],
}) as ChildProcessWithoutNullStreams;
t.after(() => child.kill("SIGKILL"));
children.push(child);
stdinFiles.push(stdinFile);
return child;
};
return { runClaude, children, stdinFiles };
}
function createClaudeCodeChat(t: TestContext): string {
const chat = chatStore.createChat("Claude Code: question answered later");
chatStore.saveSettings(chat.id, { model: { provider: "claude-code", id: "sonnet" } });
t.after(() => unlinkSync(join(PATHS.chats, `${chat.id}.json`)));
return chat.id;
}
function createAgentFactory(fakeRun: FakeRun): Parameters<typeof handleChatRequest>[1] {
const model = CLAUDE_CODE_MODELS.find((candidate) => candidate.id === "sonnet");
assert.ok(model);
const engine: PiEngine = { model, streamFn: createClaudeCodeStreamFunction(fakeRun.runClaude) };
return (options) => createTeacherAgent(undefined, { engine, chatId: options.chatId, messages: options.messages });
}
async function runChatTurn(chatId: string, input: object, agentFactory: Parameters<typeof handleChatRequest>[1]): Promise<void> {
const request = new Request("http://test/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input }),
});
const ack = await handleChatRequest(request, agentFactory);
assert.equal(ack.status, 200, await ack.clone().text());
const handle = runningTurn(chatId);
if (handle) await new Response(handle.subscribe()).text();
const deadline = Date.now() + TURN_TIMEOUT_MS;
while (isTurnRunning(chatId)) {
assert.ok(Date.now() < deadline, "turn did not settle");
await sleep(10);
}
}
function toAnswerInput(): object {
return {
type: "answer",
toolCallId: ASK_ID,
answer: { kind: "ask", questionText: ASK_INPUT.question, dontKnow: false, answers: [], text: ANSWER_TEXT, answeredAt: new Date().toISOString() },
};
}
function getLastAssistantText(chatId: string): string {
const messages = chatStore.getChat(chatId)?.messages ?? [];
const assistant = messages.findLast((message) => message.role === "assistant");
assert.ok(assistant?.role === "assistant");
return assistant.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
}
async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
}
test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => {
const fakeRun = createFakeRun(t, [toAskScenario()]);
const chatId = createClaudeCodeChat(t);
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
const before = chatStore.getChat(chatId)!.messages;
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent"));
assert.equal(response.status, 503);
assert.match((await response.json()).error, /Enable and set it up in Settings/);
assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0);
assert.deepEqual(chatStore.getChat(chatId)!.messages, before);
assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" });
});
test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => {
const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")];
const fakeRun = createFakeRun(t, [scenario]);
const run = fakeRun.runClaude;
let ready!: (child: ChildProcessWithoutNullStreams) => void;
let failed!: (error: unknown) => void;
const checkpoint = new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => { ready = resolve; failed = reject; });
fakeRun.runClaude = async (args, options) => {
const child = await run(args, options); assert(child);
void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed);
return child;
};
const chatId = createClaudeCodeChat(t);
const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun));
assert.equal(response.status, 200);
const child = await checkpoint;
const handle = runningTurn(chatId); assert(handle);
const finished = new Response(handle.subscribe()).text();
writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } });
assert.equal(child.signalCode, null, "opt-out must not kill an already running process");
child.send("continue"); await finished;
assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally");
assert.equal(readSettings()?.claudeCode?.enabled, false);
await waitForExit(child);
});
test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => {
const fakeRun = createFakeRun(t, [toAskScenario()]);
const run = fakeRun.runClaude;
fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } });
const agentFactory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
const [waiting] = fakeRun.children;
assert.ok(waiting);
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
await runChatTurn(chatId, toAnswerInput(), agentFactory);
assert.equal(fakeRun.children.length, 1);
assert.deepEqual(cliContextWindowOf(chatStore.getChat(chatId)!.messages), { model: "claude-code:sonnet", tokens: 200000 });
assert.deepEqual(cliContextWindowOf(JSON.parse(readFileSync(join(PATHS.chats, `${chatId}.json`), "utf8")).messages), { model: "claude-code:sonnet", tokens: 200000 });
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId));
await waitForExit(waiting);
});
test("a question pauses later calls from the same CLI response until the learner answers", async (t) => {
const secondId = "toolu_second_question";
const secondQuestion = "What do you want to learn next?";
const scenario = toAskScenario();
scenario.splice(4, 0,
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: secondId, name: "mcp__learnharness__ask_user_question", input: {} } }),
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question: secondQuestion }) } }),
streamEvent({ type: "content_block_stop", index: 1 }),
);
scenario.splice(scenario.length - 1, 0, JSON.stringify({ fake: "call", id: secondId, name: "ask_user_question", arguments: { question: secondQuestion } }));
const fakeRun = createFakeRun(t, [scenario]);
const factory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(secondId), false, "unadmitted second question must not be posed or persisted");
await runChatTurn(chatId, toAnswerInput(), factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, secondId);
await runChatTurn(chatId, {
type: "answer", toolCallId: secondId,
answer: { kind: "ask", questionText: secondQuestion, dontKnow: false, answers: [], text: "HTTP next", answeredAt: new Date().toISOString() },
}, factory);
assert.equal(fakeRun.children.length, 1);
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
assert.ok(getLastAssistantText(chatId).includes("HTTP next"));
await waitForExit(fakeRun.children[0]!);
});
test("an individually cancelled MCP question queued behind a learner wait never executes", async (t) => {
const cancelledId = "toolu_cancelled_question";
const question = "This cancelled question must never be posed";
const scenario = toAskScenario();
scenario.splice(4, 0,
streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: cancelledId, name: "mcp__learnharness__ask_user_question", input: {} } }),
streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question }) } }),
streamEvent({ type: "content_block_stop", index: 1 }),
);
scenario.splice(scenario.length - 2, 2,
JSON.stringify({ fake: "start-call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }),
JSON.stringify({ fake: "wait-for-message" }),
JSON.stringify({ fake: "start-call", id: cancelledId, name: "ask_user_question", arguments: { question } }),
JSON.stringify({ fake: "cancel-call", id: cancelledId }),
JSON.stringify({ fake: "checkpoint" }),
JSON.stringify({ fake: "reply" }),
);
const fakeRun = createFakeRun(t, [scenario]);
const factory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID);
const child = fakeRun.children[0]!;
const cancelledOnServer = once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) });
child.send("queue and cancel B");
assert.deepEqual((await cancelledOnServer)[0], { fake: "checkpoint" });
await runChatTurn(chatId, toAnswerInput(), factory);
assert.equal(fakeRun.children.length, 1);
assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? []), null);
assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(cancelledId), false, "cancelled B must never reach native tool execution or persistence");
assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT));
await waitForExit(child);
});
test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => {
liveProcessIdleTimeoutMs.current = 300;
t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS));
const fakeRun = createFakeRun(t, [toAskScenario(), readFileSync(TEXT_FIXTURE, "utf8").split("\n").filter(Boolean)]);
const agentFactory = createAgentFactory(fakeRun);
const chatId = createClaudeCodeChat(t);
await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory);
const [waiting] = fakeRun.children;
assert.ok(waiting);
assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call");
await waitForExit(waiting);
await runChatTurn(chatId, toAnswerInput(), agentFactory);
assert.equal(fakeRun.children.length, 2);
assert.equal(getLastAssistantText(chatId), "hello there");
const replay = readFileSync(fakeRun.stdinFiles[1] ?? "", "utf8");
assert.ok(replay.includes("[tool call ask_user_question]"), replay);
assert.ok(replay.includes(ASK_INPUT.question), replay);
assert.ok(replay.includes("[tool result ask_user_question]"), replay);
assert.ok(replay.includes(ANSWER_TEXT), replay);
});
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process";
import { test } from "node:test";
import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const binary = "/fake/claude";
const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } };
test("missing installation does not execute a command", async () => {
assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), {
state: "not-found", version: null, minimumVersion: "2.1.259",
});
});
for (const [output, state, version] of [
["2.1.258 (Claude Code)\n", "update-required", "2.1.258"],
["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"],
["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"],
["2.2.0", "meets-minimum", "2.2.0"],
["2.0.999", "update-required", "2.0.999"],
["3.0.0", "meets-minimum", "3.0.0"],
["unexpected output containing private details", "version-unchecked", null],
["2.1.310-preview", "version-unchecked", null],
["99999999999999999999999999.1.1", "version-unchecked", null],
] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => {
const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; });
assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" });
});
test("probe failures expose neither raw errors nor binary paths", async () => {
const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); });
assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" });
});
for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => {
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; },
spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn,
});
await assert.rejects(launch(["-p"], options) as Promise<unknown>, ClaudeLaunchError);
});
test("launcher uses the verified executable directly and preserves model args and native env", async () => {
const child = {} as ChildProcessWithoutNullStreams;
let calls = 0;
const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)",
spawnProcess: ((path: string, args: string[], opts: unknown) => {
calls++;
assert.equal(path, binary);
assert.deepEqual(args, ["-p", "--tools", ""]);
assert.deepEqual(opts, { ...options, stdio: "pipe" });
return child;
}) as typeof spawn,
});
assert.equal(await launch(["-p", "--tools", ""], options), child);
assert.equal(calls, 1);
});
test("aborting during a version check prevents model process creation", async () => {
const controller = new AbortController();
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { controller.abort(); return "2.1.310"; },
spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn,
});
await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise<unknown>, /aborted/);
});
test("missing executable is not mistaken for an unchecked version", async () => {
const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") });
assert.equal(await launch([], options), null);
});
+98
View File
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import test, { after, type TestContext } from "node:test";
const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-"));
process.env.LH_DATA_DIR = testData;
after(() => rmSync(testData, { recursive: true, force: true }));
const { SETTINGS_FILE } = await import("../config.ts");
const { PUT } = await import("../app/api/settings/[section]/route.ts");
const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts");
const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts");
const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts");
const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts");
const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts");
const { providerKeyPresent } = await import("../src/server/keys.ts");
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { chatStore } = await import("../src/store/chats.ts");
const { jobStore } = await import("../src/store/jobs.ts");
const { createJobRunner } = await import("../src/agent/subagents/runner.ts");
const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts");
function setup(t: TestContext) {
const previous = readSettings();
const path = process.env.PATH;
const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-"));
// Never executed: only installation discovery observes this marker.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
process.env.PATH = `${dir}${delimiter}${path ?? ""}`;
t.after(() => {
if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true });
if (path === undefined) delete process.env.PATH; else process.env.PATH = path;
rmSync(dir, { recursive: true, force: true });
});
writeSettings({ version: 1 });
}
async function save(body: unknown) {
return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
{ params: Promise.resolve({ section: "claudeCode" }) });
}
test("opt-in parses strictly and malformed stored values never enable the CLI", () => {
assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} });
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } });
assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } });
for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false);
for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) {
assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true);
}
assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined);
});
test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => {
setup(t);
const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } };
writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } });
assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false);
assert.equal(providerKeyPresent("claude-code"), false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.equal((await save({ enabled: true })).status, 200);
assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true);
assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3);
assert.equal((await save({ enabled: false })).status, 200);
assert.equal(claudeCodeAvailable(), false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.deepEqual(readSettings()?.models, models);
assert.deepEqual(readSettings()?.graph, { enabled: false });
assert.deepEqual(readSettings()?.futureSection, { keep: 1 });
assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed");
assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false);
assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false);
});
test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => {
setup(t);
const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } });
const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute"));
assert.equal(response.status, 403);
});
test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => {
setup(t);
const chat = chatStore.createChat("CLI opt-in job fixture");
t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); });
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" });
let calls = 0;
const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {},
engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }),
providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]),
});
await runner.runJob(job.id, { question: "Synthetic fixture only" });
assert.equal(calls, 0);
const refused = jobStore.getJob(job.id)!;
assert.equal(refused.status, "error");
assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/);
assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys"));
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Claude Code provider catalog (src/agent/claude-code/provider.ts).
*
* Registered on a fresh pi model collection with a stub stream function, so
* only the catalog and auth surface are under test here.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { clampThinkingLevel, createAssistantMessageEventStream, createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { CLAUDE_CODE_PROVIDER } from "../src/agent/claude-code/constants.ts";
import { createClaudeCodeProvider } from "../src/agent/claude-code/provider.ts";
function createCollection() {
const collection = createModels();
collection.setProvider(createClaudeCodeProvider(() => createAssistantMessageEventStream()));
return collection;
}
test("lists exactly the opus, sonnet and haiku aliases", () => {
const models = createCollection().getModels(CLAUDE_CODE_PROVIDER);
assert.deepEqual(
models.map((m) => m.id),
["opus", "sonnet", "haiku"],
);
assert.deepEqual(
models.map((m) => m.name),
["Claude Opus", "Claude Sonnet", "Claude Haiku"],
);
});
test("supports low through max thinking and clamps off to low", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(getSupportedThinkingLevels(model), ["low", "medium", "high", "xhigh", "max"]);
assert.equal(clampThinkingLevel(model, "off"), "low");
assert.equal(clampThinkingLevel(model, "minimal"), "low");
}
});
test("accepts text and image input", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(model.input, ["text", "image"]);
}
});
test("every cost field is zero", () => {
for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) {
assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
}
});
test("auth resolves as configured without any key", async () => {
const collection = createCollection();
assert.ok(await collection.checkAuth(CLAUDE_CODE_PROVIDER));
assert.equal((await collection.getAvailable(CLAUDE_CODE_PROVIDER)).length, 3);
});
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts";
const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true);
function request(headers: Record<string, string> = {}) {
return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } });
}
const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" };
for (const headers of [
{ origin: "https://attacker.invalid" },
{ "sec-fetch-site": "cross-site" },
{ origin: "http://localhost:3000", "sec-fetch-site": "cross-site" },
{ origin: "null" },
{ "x-learnharness-claude-code": "" },
] as Record<string, string>[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 403); assert.equal(probes, 0);
assert.equal(response.headers.get("access-control-allow-origin"), null);
});
test("disabled opt-in never starts even a version probe", async () => {
let probes = 0;
const response = await respond(request(), async () => { probes++; return status; }, () => false);
assert.equal(response.status, 403); assert.equal(probes, 0);
});
test("ordinary image/subresource GET without intent header cannot start a probe", async () => {
const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe"));
assert.equal(response.status, 403);
});
for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record<string, string>[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 200); assert.equal(probes, 1);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), status);
});
test("a forged forwarded origin cannot authorize a foreign Origin header", async () => {
const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe"));
assert.equal(response.status, 403);
});
+141
View File
@@ -0,0 +1,141 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
import { ConnectionRow } from "../components/settings/connection-controls.tsx";
import { useModelCatalog } from "../components/model-catalog.ts";
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" });
const button = () => document.querySelector<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
const toggle = () => document.querySelector<HTMLButtonElement>('[role="switch"]')!;
const label = () => document.querySelector('[role="status"]')?.textContent;
function fixture(probe: (init?: RequestInit) => Promise<Response>, enabled = true, failSave = false) {
const calls: string[] = [];
let on = enabled;
globalThis.fetch = (async (url, init) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
if (url === "/api/settings/claudeCode") {
assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json");
if (failSave) return new Response(null, { status: 500 });
on = JSON.parse(String(init.body)).enabled;
return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } });
}
if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] });
assert.equal(url, "/api/claude-code");
assert.equal(init?.cache, "no-store");
assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1");
return probe(init);
}) as typeof fetch;
return calls;
}
async function mount(extra?: React.ReactNode) {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<><ClaudeCodeInstallationStatus />{extra}</>));
}
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("initial render is collapsed and off, with no setup prose or version probe action", () => {
const html = renderToStaticMarkup(<ClaudeCodeInstallationStatus />);
assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/);
assert.equal((html.match(/<button/g) ?? []).length, 1);
assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗"));
});
test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => {
const calls = fixture(async () => Response.json(status("not-found")), false);
await mount();
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
assert.deepEqual(calls, ["GET /api/settings"]);
await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found");
assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code"));
assert.notEqual(toggle().parentElement, button().parentElement);
assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement);
assert.match(document.body.textContent ?? "", /no background process required/);
assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/);
assert.match(document.body.textContent ?? "", /Claude Code controls billing/);
assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./);
assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate"));
assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup");
await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined);
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1);
});
test("recheck matches Connect styling; Manage remains ghost", async () => {
fixture(async () => Response.json(status("not-found"))); await mount();
const row = (action: string) => parseHTML(renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />)).document.querySelector("button")!;
assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class"));
assert.equal(button().getAttribute("data-variant"), "outline");
assert.equal(row("Manage").getAttribute("data-variant"), "ghost");
});
for (const [value, text] of [
[status("not-found"), "Not found"],
[status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"],
[status("update-required", "2.1.258"), "Version 2.1.258 · update required"],
[status("version-unchecked"), "Version could not be checked"],
] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text));
test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => {
let probes = 0;
const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310")));
await mount(); assert.equal(label(), "Not found");
await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum");
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2);
});
test("failed opt-in save stays collapsed/off and never probes", async () => {
const calls = fixture(async () => assert.fail("must not probe"), false, true);
await mount(); await act(async () => toggle().click());
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/);
assert(!calls.includes("GET /api/claude-code"));
});
test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => {
let signal: AbortSignal | null | undefined;
let resolve!: (response: Response) => void;
fixture((init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); });
await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled);
await act(async () => toggle().click()); assert(signal?.aborted); assert(!button());
await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined);
});
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
let calls = 0;
fixture(async () => {
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
if (failure === "network") throw new Error("offline");
return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" });
});
await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum");
await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled);
});
function CatalogReader() {
const { catalog } = useModelCatalog();
return <span data-testid="cli-catalog-models">{catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}</span>;
}
test("persisted toggle updates invalidate shared model availability in both directions", async () => {
fixture(async () => Response.json(status("not-found")), false);
await mount(<CatalogReader />);
const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent;
assert.equal(models(), "");
await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet");
await act(async () => toggle().click()); assert.equal(models(), "");
});
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
/**
* Claude Code MCP tool bridge (src/agent/claude-code/tool-bridge.ts).
*
* A real MCP SDK client talks to the bridge over loopback HTTP, the same way
* the claude CLI does, so listing, auth, pending calls and their results
* are all exercised end to end.
*/
import assert from "node:assert/strict";
import * as net from "node:net";
import * as os from "node:os";
import test from "node:test";
import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Type } from "typebox";
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type ToolBridge } from "../src/agent/claude-code/tool-bridge.ts";
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) };
const SCREENSHOT_TOOL: Tool = { name: "screenshot", description: "Capture the screen", parameters: Type.Object({}) };
type BridgeServerConfig = { type: string; url: string; headers: { Authorization: string } };
function getServerConfig(bridge: ToolBridge): BridgeServerConfig {
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, BridgeServerConfig> };
const server = config.mcpServers[BRIDGE_SERVER_NAME];
assert.ok(server);
return server;
}
async function connectClient(bridge: ToolBridge, authorization = getServerConfig(bridge).headers.Authorization): Promise<Client> {
const transport = new StreamableHTTPClientTransport(new URL(getServerConfig(bridge).url), {
requestInit: { headers: { Authorization: authorization } },
});
const client = new Client({ name: "test", version: "0" });
await client.connect(transport);
return client;
}
function toolResult(content: ToolResultMessage["content"], isError = false): ToolResultMessage {
return { role: "toolResult", toolCallId: "call-1", toolName: "read", content, isError, timestamp: 0 };
}
test("lists exactly the given tools with their JSON schemas", async (t) => {
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const { tools } = await client.listTools();
assert.deepEqual(
tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
[
{ name: "read", description: "Read a file", inputSchema: JSON.parse(JSON.stringify(READ_TOOL.parameters)) },
{ name: "screenshot", description: "Capture the screen", inputSchema: JSON.parse(JSON.stringify(SCREENSHOT_TOOL.parameters)) },
],
);
assert.deepEqual(bridge.allowedTools, ["mcp__learnharness__read", "mcp__learnharness__screenshot"]);
});
test("rejects requests without the bearer token", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
await assert.rejects(connectClient(bridge, "Bearer wrong"), (error: { code?: number }) => error.code === 401);
const response = await fetch(getServerConfig(bridge).url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
assert.equal(response.status, 401);
});
test("binds 127.0.0.1", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const url = new URL(getServerConfig(bridge).url);
assert.equal(url.hostname, "127.0.0.1");
const externalAddress = Object.values(os.networkInterfaces())
.flat()
.find((address) => address && !address.internal && address.family === "IPv4");
if (!externalAddress) {
t.diagnostic("no non-loopback IPv4 interface; skipped external connect check");
return;
}
await assert.rejects(
new Promise((resolve, reject) => {
const socket = net.connect(Number(url.port), externalAddress.address, () => resolve(socket.end()));
socket.once("error", reject);
}),
{ code: "ECONNREFUSED" },
);
});
test("tools/call stays pending until resolved and returns its text and image content", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
let settled = false;
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => {
settled = true;
});
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
assert.deepEqual(call, { id: "call-1", name: "read", arguments: { path: "notes.md" } });
assert.deepEqual(bridge.getPendingToolCalls(), [call]);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(settled, false);
bridge.resolveToolCall(
call,
toolResult([
{ type: "text", text: "# Notes" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]),
);
const result = await pending;
assert.deepEqual(result.content, [
{ type: "text", text: "# Notes" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]);
assert.notEqual(result.isError, true);
assert.deepEqual(bridge.getPendingToolCalls(), []);
});
test("individual MCP cancellation removes only that request and leaves the other request resolvable", async (t) => {
let acknowledgeCancel!: () => void;
const cancelled = new Promise<void>((resolve) => { acknowledgeCancel = resolve; });
const cancellations: string[] = [];
const bridge = await createToolBridge([READ_TOOL], {
onCall() {}, onError(error) { throw error; },
onCancel(call) { cancellations.push(call.id); acknowledgeCancel(); },
});
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const first = client.callTool({ name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "call-a" } });
const controller = new AbortController();
const second = client.callTool({ name: "read", arguments: { path: "b" }, _meta: { "claudecode/toolUseId": "call-b" } }, undefined, { signal: controller.signal });
const rejected = assert.rejects(second, /cancelled B/);
await bridge.waitForToolCalls(2);
controller.abort("cancelled B");
await cancelled;
await rejected;
assert.deepEqual(cancellations, ["call-b"]);
const pending = bridge.getPendingToolCalls();
assert.deepEqual(pending.map((call) => call.id), ["call-a"]);
bridge.resolveToolCall(pending[0]!, toolResult([{ type: "text", text: "A" }]));
assert.deepEqual((await first).content, [{ type: "text", text: "A" }]);
assert.deepEqual(bridge.getPendingToolCalls(), []);
});
test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => {
const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
let waited = false;
const waiting = bridge.waitForToolCalls(2).finally(() => {
waited = true;
});
void client.callTool({ name: "read", arguments: { path: "a.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(waited, false);
void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {});
assert.deepEqual(await waiting, [
{ id: "call-1", name: "read", arguments: { path: "a.md" } },
{ id: "call-2", name: "screenshot", arguments: {} },
]);
});
test("isError result becomes MCP isError true", async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
const pending = client.callTool({ name: "read", arguments: { path: "missing.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "ENOENT" }], true));
const result = await pending;
assert.equal(result.isError, true);
assert.deepEqual(result.content, [{ type: "text", text: "ENOENT" }]);
});
test("close fails pending calls and frees the port", async () => {
const bridge = await createToolBridge([READ_TOOL]);
const port = Number(new URL(getServerConfig(bridge).url).port);
const client = await connectClient(bridge);
const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } });
await bridge.waitForToolCalls(1);
await bridge.close();
await assert.rejects(pending);
assert.deepEqual(bridge.getPendingToolCalls(), []);
await client.close();
const probe = net.createServer();
await new Promise<void>((resolve, reject) => {
probe.once("error", reject);
probe.listen(port, "127.0.0.1", resolve);
});
await new Promise((resolve) => probe.close(resolve));
});
test("close rejects consumers still waiting for tool calls", { timeout: 1000 }, async () => {
const bridge = await createToolBridge([READ_TOOL]);
const waiting = bridge.waitForToolCalls(1);
await bridge.close();
await assert.rejects(waiting, /tool bridge closed/);
});
test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}', async (t) => {
const bridge = await createToolBridge([READ_TOOL]);
t.after(() => bridge.close());
const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record<string, unknown> };
assert.deepEqual(Object.keys(config.mcpServers), ["learnharness"]);
const server = getServerConfig(bridge);
assert.deepEqual(Object.keys(server).sort(), ["headers", "type", "url"]);
assert.equal(server.type, "http");
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/);
});
test("missing and reused Claude tool IDs are rejected rather than matched by arguments", async (t) => {
const failures: string[] = [];
const bridge = await createToolBridge([READ_TOOL], { onCall: () => {}, onError: (error) => failures.push(error.message) });
t.after(() => bridge.close());
const client = await connectClient(bridge);
t.after(() => client.close());
await assert.rejects(client.callTool({ name: "read", arguments: { path: "a" } }), /claudecode\/toolUseId/);
assert.deepEqual(bridge.getPendingToolCalls(), []);
const params = { name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "same-id" } };
const first = client.callTool(params);
const [call] = await bridge.waitForToolCalls(1);
assert.ok(call);
bridge.resolveToolCall(call, toolResult([{ type: "text", text: "first" }]));
await first;
await assert.rejects(client.callTool(params), /claudecode\/toolUseId/);
assert.deepEqual(bridge.getPendingToolCalls(), []);
assert.equal(failures.length, 2);
});
test("toolNameFromMcp strips the bridge prefix", () => {
assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read");
assert.equal(toolNameFromMcp("read"), "read");
});
+161
View File
@@ -0,0 +1,161 @@
/**
* Claude Code transcript replay (src/agent/claude-code/transcript.ts).
*
* The CLI makes one model call per input user message, so earlier turns are
* rendered as labelled text inside the single user message it receives.
*/
import assert from "node:assert/strict";
import test from "node:test";
import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } };
const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } };
function system(fields: Partial<SystemMessage>): SystemMessage {
return { role: "system", content: "", timestamp: 0, ...fields };
}
function user(content: UserMessage["content"]): UserMessage {
return { role: "user", content, timestamp: 0 };
}
function assistant(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "claude-code-cli",
provider: "claude-code",
model: "opus",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
};
}
function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage {
return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 };
}
test("system prompt joins all system content, then the live sections", () => {
const messages: Message[] = [
system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }),
user("hi"),
system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }),
user("next"),
];
assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS.");
});
test("tools reflect toolsAdded minus toolsRemoved", () => {
const messages: Message[] = [
system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }),
user("hi"),
system({ toolsRemoved: [{ name: "read" }] }),
user("next"),
];
assert.deepEqual(
toClaudeInput(messages).tools.map((t) => t.name),
["write"],
);
});
test("single user message passes through as text", () => {
const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]);
assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]);
});
test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => {
const messages: Message[] = [
user([{ type: "text", text: "open notes.md" }]),
assistant([
{ type: "text", text: "Reading it." },
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } },
]),
toolResult("read", [{ type: "text", text: "# Notes" }]),
assistant([{ type: "text", text: "It has a heading." }]),
user("summarise it"),
];
const expected = [
"[user]\nopen notes.md",
"[assistant]\nReading it.",
'[tool call read] {"path":"notes.md"}',
"[tool result read]\n# Notes",
"[assistant]\nIt has a heading.",
"[user]\nsummarise it",
].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("error tool results are marked as errors in the replay", () => {
const messages: Message[] = [
user("open missing.md"),
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]),
toolResult("read", [{ type: "text", text: "ENOENT" }], true),
user("why?"),
];
const expected = [
"[user]\nopen missing.md",
'[tool call read] {"path":"missing.md"}',
"[tool result read error]\nENOENT",
"[user]\nwhy?",
].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("images in user and tool-result content become base64 image blocks", () => {
const messages: Message[] = [
user([
{ type: "text", text: "look" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
]),
assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]),
toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]),
user("compare"),
];
assert.deepEqual(toClaudeInput(messages).userContent, [
{ type: "text", text: "[user]\nlook" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } },
{ type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } },
{ type: "text", text: "[user]\ncompare" },
]);
});
test("transcript ending on a tool result renders as history with no trailing user turn", () => {
const messages: Message[] = [
user("open notes.md"),
assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]),
toolResult("read", [{ type: "text", text: "# Notes" }]),
];
const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n");
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
});
test("thinking blocks are omitted from replay", () => {
const messages: Message[] = [
user("hi"),
assistant([
{ type: "thinking", thinking: "secret plan" },
{ type: "text", text: "Hello." },
]),
user("bye"),
];
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]);
});
test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => {
assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), {
type: "user",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
});
});
+55
View File
@@ -0,0 +1,55 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts";
import { graphCostSummary } from "../src/core/cost-summary.ts";
import { accumulateJobUsage } from "../src/core/contracts/jobs.ts";
import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts";
import { toModelDto } from "../src/server/model-catalog.ts";
import { ModelMenuList } from "../components/settings/model-menu.tsx";
import { StatsBar } from "../components/stats-bar.tsx";
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined };
for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => {
const id = `${model.provider}:${model.id}`;
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] };
assert(!isSubscriptionModel(id));
assert.equal(callCost(step, id), null);
const pricing = sessionCost({ turns: [turn] });
assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined);
assert.equal(pricing.turnCosts[turn.id], null);
assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined });
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
assert(!html.includes("$0"));
assert.match(html, /data-testid="stats-cost">CLI<\/span>/);
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
}] }], sources: [] }, callCost);
assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined);
assert.equal(summary.runs[0]!.dollars, null);
});
test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => {
const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] };
const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" };
const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } };
const html = renderToStaticMarkup(<StatsBar turns={[api, cli]} pricing={pricing} />);
assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/);
const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" };
const mixed = renderToStaticMarkup(<StatsBar turns={[api, cli, subscription]} pricing={{ ...pricing, subscription: 1, subscriptionProviders: ["ChatGPT"] }} />);
assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/);
assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting");
});
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
const models = CLAUDE_CODE_MODELS.map(toModelDto);
assert(models.every((model) => model.cost === null));
const html = renderToStaticMarkup(<ModelMenuList models={models} effectiveId={undefined} onPick={() => {}} />);
assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3);
assert.match(html, /does not estimate it/);
assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)"));
});
+63
View File
@@ -0,0 +1,63 @@
/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */
import assert from "node:assert/strict";
import { type spawn } from "node:child_process";
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
import { readClaudeVersion } from "../src/agent/claude-code/installation.ts";
import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath);
function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") {
const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const bin = join(dir, "bin"); mkdirSync(bin);
const binary = join(bin, name);
const body = mode === "hang" ? `
import { writeFileSync } from "node:fs";
writeFileSync(process.argv[1] + ".pid", String(process.pid));
process.on("SIGTERM", () => {});
setInterval(() => {}, 1000);
` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : `
if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2);
process.stdout.write("2.1.310 (Claude Code)\\n");
`;
writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return { dir, binary };
}
test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "normal", "claude ; fixture");
assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n");
});
test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "oversize");
await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
});
test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => {
const { binary } = fixture(t, "hang");
const start = Date.now();
await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL");
assert(Date.now() - start < 8500, "bounded five-second timeout");
const pid = Number(readFileSync(`${binary}.pid`, "utf8"));
assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists");
});
test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => {
const { dir, binary } = fixture(t, "normal");
const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir,
isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } },
});
assert.equal(found, binary);
assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file");
let spawned = false;
const launch = createClaudeLauncher({ findBinary: () => found,
spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn,
});
await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } });
assert(spawned, "same verified absolute identity passed to the model launcher");
});
+148
View File
@@ -0,0 +1,148 @@
/**
* Stand-in for the claude CLI in tests. Reads the stream-json input from
* stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by
* line. Lines with a "fake" key are directives instead of output:
* {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call
* {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code
* {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result
* {"fake":"start-call",...} start the same request without waiting for its result
* {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request
* {"fake":"checkpoint"} notify the parent after previous sends finish
* {"fake":"wait-for-message"} pause until the test releases the child over IPC
* {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation
* {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | "
* FAKE_CLAUDE_STDIN, when set, receives the stdin it read.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { on } from "node:events";
import { createInterface } from "node:readline";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const parentMessages = on(process, "message");
const HOLD_INTERVAL_MS = 60_000;
const BRIDGE_SERVER_NAME = "learnharness";
function writeLine(line) {
return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve));
}
function streamEvent(event) {
return JSON.stringify({ type: "stream_event", event });
}
let lastSend = Promise.resolve();
async function connectBridge(mcpConfig) {
const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME];
const client = new Client({ name: "fake-claude", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
const send = transport.send.bind(transport);
transport.send = (...args) => (lastSend = send(...args));
await client.connect(transport);
return client;
}
async function writeReply(text) {
await writeLine(streamEvent({ type: "message_start", message: { usage: {} } }));
await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }));
await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }));
await writeLine(streamEvent({ type: "content_block_stop", index: 0 }));
await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} }));
await writeLine(streamEvent({ type: "message_stop" }));
}
const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8");
let input = "";
let receiveUser;
const userReceived = new Promise((resolve) => { receiveUser = resolve; });
createInterface({ input: process.stdin }).on("line", async (line) => {
input += `${line}\n`;
const message = JSON.parse(line);
if (message.type !== "control_request") {
if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input);
receiveUser(); return;
}
if (message.request.subtype === "initialize") {
if (process.env.FAKE_CLAUDE_INIT === "ignore") return;
await writeLine(JSON.stringify({ type: "control_response", response: {
request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success",
response: {}, error: "unsupported initialize",
} }));
} else if (message.request.subtype === "get_context_usage") {
await writeLine(JSON.stringify({ type: "control_response", response: {
request_id: message.request_id, subtype: "success",
response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null },
} }));
}
});
await userReceived;
let bridge = null;
const results = [];
const pendingResults = [];
const controllers = new Map();
const cancelled = new Set();
const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean);
for (const line of lines) {
const directive = JSON.parse(line);
if (directive.fake === "cancel-call") {
cancelled.add(directive.id);
controllers.get(directive.id)?.abort("cancelled by fake CLI");
await lastSend;
continue;
}
if (directive.fake === "checkpoint") {
await lastSend;
process.send?.({ fake: "checkpoint" });
continue;
}
if (directive.fake === "wait-for-message") {
await parentMessages.next();
continue;
}
if (directive.fake === "hold") {
setInterval(() => {}, HOLD_INTERVAL_MS);
break;
}
if (directive.fake === "exit") {
process.stderr.write(directive.stderr ?? "");
process.exit(directive.code);
}
if (directive.fake === "ignore-term") {
process.on("SIGTERM", () => {});
continue;
}
if (directive.fake === "call" || directive.fake === "start-call") {
bridge ??= await connectBridge(mcpConfig);
const controller = new AbortController();
controllers.set(directive.id, controller);
const request = bridge.callTool({
name: directive.name,
arguments: directive.arguments,
...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}),
}, undefined, { signal: controller.signal });
if (directive.fake === "start-call") {
pendingResults.push({ id: directive.id, request });
void request.catch(() => {});
await lastSend;
} else {
const result = await request;
results.push(result.content.map((block) => block.text).join(""));
}
continue;
}
if (directive.fake === "reply") {
for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join(""));
await writeReply(results.join(" | "));
continue;
}
await writeLine(line);
}
+4
View File
@@ -0,0 +1,4 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"941c8a75-e5cc-4014-93ec-4b065e619d15"}
{"type":"system","subtype":"status","status":"requesting","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"1cbc259e-ad27-49ae-8a03-1dd0bc7d04ae"}
{"type":"assistant","message":{"diagnostics":null,"id":"a4eeead1-04bb-47fd-9567-bebc78052a3e","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null,"fallback_credit":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"58c2fe59-d155-4d2d-a0e1-f64e6aa09199","timestamp":"2026-10-24T14:03:24.319Z","error":"authentication_failed","is_api_error_message":true}
{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard","fallback_credit":null},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Not logged in · Please run /login","type":"result","duration_ms":21,"uuid":"7e41c29d-1912-4c14-8955-9f176fef725f","queued_turn_count":0,"result_index":0}
+11
View File
@@ -0,0 +1,11 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"c7e564b7-af08-4024-a89e-66736710e2c1"}
{"type":"system","subtype":"status","status":"requesting","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"0738b666-b249-4cca-8c36-f92df8326546"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"512e3a21-2563-457b-addb-adade96b3d2b","ttft_ms":1224,"thinking_display":"updates","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"0b2a75fb-819f-43ef-9fc7-839f1a5c5a01","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85823b9b-d5e0-4973-93f9-8525a8894762","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[{"type":"text","text":"hello there"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"92acba82-60ca-4502-a5a9-bafd75bd7277","timestamp":"2026-10-24T14:02:46.324Z","request_id":"req_011CgMBkZrxiAPxuioCwrRRu"}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"1d19a483-51e6-45b6-96a2-bf445a9ab04b","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85ded13f-99fc-4ac2-b211-2c71a40bb817","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"d9085ac3-b00c-4e22-a17e-0fafcf7782a2","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"679f75df-0571-4b65-bc3e-c4cee96c7061","session_id":"a489e311-9d94-4990-b539-63a765dd0edd"}
{"duration_api_ms":1236,"stop_reason":"end_turn","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","total_cost_usd":0.0001241,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":607,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":607,"webSearchRequests":0,"costUSD":0.0001241,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"hello there","ttft_ms":1245,"type":"result","duration_ms":1258,"uuid":"faf44ee1-84e1-40eb-914e-654abb6c2286","ttft_stream_ms":1244,"time_to_request_ms":21,"first_content_frame_ms":1245,"queued_turn_count":0,"result_index":0}
+26
View File
@@ -0,0 +1,26 @@
{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","model":"claude-haiku-5-5","tools":["mcp__learnharness__get_weather"],"mcp_servers":[{"name":"learnharness","status":"connected","source":"dynamic"}],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"9524e7cd-f580-4019-8e63-861e36aa190d"}
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"6a55f520-5449-4845-bfe7-d3d74e4f5dda"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"16a84391-efcc-4221-9029-1fbea99edbab","ttft_ms":1170,"thinking_display":"updates","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{},"caller":{"type":"direct"}}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"460ff54c-2ab3-46a0-b2bf-c0d780d04e58","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"e73df731-5ecc-439c-811a-7b6cc5219a7f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1ae8e01b-8cfe-4282-a008-a9ef3f2e05fa","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"b7cf01be-dc67-463e-ad06-19267ea81cc3","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{"city":"Paris"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"da987ff6-9b30-4c1e-8f5b-20bda7386663","timestamp":"2026-10-24T14:03:09.115Z","request_id":"req_011CgMBnETxUPY4jwbRZ3rFy","wire_tool_inputs":{"toolu_01JBB4cJsPa585FWb1XrfbNX":{"city":"Paris"}},"tool_use_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","display_name":"Get Weather","server_display_name":"learnharness","tool_name":"get_weather"}]}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"f438dbab-5bac-4b68-a237-ef9564abd25c","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBB4cJsPa585FWb1XrfbNX","type":"tool_result","content":[{"type":"text","text":"Sunny, 21C in Paris"}]}]},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"f44480b5-8e02-4958-888e-fbbe3b54dc80","timestamp":"2026-10-24T14:03:09.124Z","tool_use_result":[{"type":"text","text":"Sunny, 21C in Paris"}],"tool_result_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"output_tokens":59,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":0,"cache_creation_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7fec4e77-f338-4f24-810c-d6fa959d154f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"5d3b3311-08d0-4af6-be82-b0d3f472e99d","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"f21e5357-4ffc-4346-b9fc-6543e910eab3","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038"}
{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"22297606-28e0-4b2f-9208-bc4be5487a80"}
{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"90d16779-8e65-40d2-a55e-a68a9786a927","ttft_ms":1274,"thinking_display":"updates","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1f8d1832-7837-444e-a07e-2598927888bb","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"It"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"29baece7-f120-4088-8901-9ee4c16f9ffa","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s sunny in Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7189b574-320a-4730-9cf7-c92c089bdf95","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" right"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1db451a5-7027-4b25-bb69-2b8b6b94d600","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" now, with a temperature of"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7ca48472-b631-4117-a204-9f6fe7854f4a","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 21°C."}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"86930cda-4e2b-405c-a45f-29ee42d2efca","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[{"type":"text","text":"It's sunny in Paris right now, with a temperature of 21°C."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"69cad926-aa35-4a82-a24c-599cc5422d99","timestamp":"2026-10-24T14:03:10.497Z","request_id":"req_011CgMBnLc4EeExeJ32dkAoH"}
{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"c05c7c31-f90a-4f4b-8a5b-31f2dae60294","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"output_tokens":25,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"6193aeb9-61b1-41e8-959a-9ae646429d11","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"22fbda30-76c1-4527-9819-cc3d00fb4e68","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"}
{"duration_api_ms":2827,"stop_reason":"end_turn","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","total_cost_usd":0.00029382000000000003,"usage":{"input_tokens":4,"cache_creation_input_tokens":1202,"cache_read_input_tokens":1102,"output_tokens":84,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1202,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":4,"outputTokens":84,"cacheReadInputTokens":1102,"cacheCreationInputTokens":1202,"webSearchRequests":0,"costUSD":0.00029382000000000003,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"It's sunny in Paris right now, with a temperature of 21°C.","ttft_ms":1457,"type":"result","duration_ms":2854,"uuid":"0be743a1-83e1-45ab-96f5-926ffee7a85b","ttft_stream_ms":1191,"time_to_request_ms":21,"first_content_frame_ms":1191,"queued_turn_count":0,"result_index":0}
+98 -9
View File
@@ -1,6 +1,13 @@
import { describe, it } from "node:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import assert from "node:assert/strict";
import {
const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-"));
process.env.LH_DATA_DIR = testData;
after(() => fs.rmSync(testData, { recursive: true, force: true }));
const {
catalogContextLimits,
catalogDefaultsFor,
catalogProviders,
catalogRolePins,
@@ -12,16 +19,18 @@ import {
toModelDto,
validateChatSettings,
validateModelsSettings,
} from "../src/server/model-catalog.ts";
} = await import("../src/server/model-catalog.ts");
import type { EffectiveSlot } from "../src/core/settings-resolution.ts";
import type { SettingsValidation } from "../src/server/model-catalog.ts";
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
const { piEngineForSpec, piModels } = await import("../src/agent/models.ts");
import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { readSettings } from "../src/store/settings.ts";
import { effectiveSettings } from "../src/server/stores.ts";
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
const { SETTINGS_FILE } = await import("../config.ts");
const { effectiveSettings } = await import("../src/server/stores.ts");
const { providerKeyPresent } = await import("../src/server/keys.ts");
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
// hold our projection + validation honest against the shipped catalog, and
@@ -59,8 +68,10 @@ describe("the model catalog projection", () => {
assert.equal(typeof m.id, "string");
assert.equal(typeof m.name, "string");
assert.ok(Array.isArray(m.supportedLevels));
assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number");
assert.ok(m.contextWindow > 0);
if (m.provider === "claude-code") assert.equal(m.cost, null);
else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number");
if (m.provider === "claude-code") assert.equal(m.contextWindow, null);
else assert.ok(m.contextWindow !== null && m.contextWindow > 0);
}
const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash");
assert.ok(glm, "glm-5.3-flash in catalog");
@@ -121,6 +132,7 @@ describe("the model catalog projection", () => {
assert.equal(dto.id, raw.id);
assert.equal(dto.name, raw.name);
assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw));
assert.ok(dto.cost);
assert.equal(dto.cost.input, raw.cost.input);
assert.equal(dto.contextWindow, raw.contextWindow);
});
@@ -326,4 +338,81 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => {
const { piEngineFor } = await import("../src/agent/models.ts");
assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/);
});
});
});
/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only
* finds a claude binary when installed is true. */
function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-"));
const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix };
const settings = readSettings();
writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } });
if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = dir;
process.env.HOME = dir;
delete process.env.npm_config_prefix;
try {
fn();
} finally {
for (const [name, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
if (settings) writeSettings(settings);
else fs.rmSync(SETTINGS_FILE, { force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => {
it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const ids = modelCatalog()
.models.filter((m) => m.provider === "claude-code")
.map((m) => m.id);
assert.deepEqual(ids, ["haiku", "opus", "sonnet"]);
});
});
it("catalogProviders() marks claude-code present when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const row = catalogProviders().find((p) => p.id === "claude-code");
assert.deepEqual(row, { id: "claude-code", envVar: "", keyPresent: true });
});
});
it("catalogProviders() marks claude-code absent and modelCatalog() hides it when the CLI is missing", () => {
withClaudeInstalled(false, () => {
const row = catalogProviders().find((p) => p.id === "claude-code");
assert.equal(row?.keyPresent, false);
assert.equal(
modelCatalog().models.some((m) => m.provider === "claude-code"),
false,
);
});
});
it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => {
withClaudeInstalled(true, () => {
assert.equal(catalogContextLimits().has("claude-code:opus"), false);
});
});
it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => {
const engine = piEngineForSpec({ provider: "claude-code", id: "sonnet" });
assert.equal(engine.model.provider, "claude-code");
assert.equal(engine.model.id, "sonnet");
});
it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => {
withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true));
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
withClaudeInstalled(true, () => {
assert.equal(providerKeyPresent("claude-code"), false);
assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false);
assert(!modelCatalog().models.some((m) => m.provider === "claude-code"));
assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false);
}, false);
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => {
assert.ok(models.length > 100);
for (const model of models) {
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
@@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved
const usage = nativeUsage(input);
const call = Object.freeze(saved(usage));
const before = structuredClone(call);
const expected = calculateCost(model, usage).total;
const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total;
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
assert.deepEqual(call, before);
}
+3
View File
@@ -88,6 +88,9 @@ describe("send_to_user model gate", () => {
["openrouter", "anthropic/claude-sonnet-5.5", true],
["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true],
["fixture", "CLAUDE-fixture", true],
["claude-code", "opus", true],
["claude-code", "sonnet", true],
["claude-code", "haiku", true],
["openrouter", "z-ai/glm-5.3-flash", false],
["openai", "gpt-4o-mini", false],
["anthropic", "opaque-alias", false],
+5
View File
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { externalCliEnv } from "../src/core/auth-env.ts";
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
// (a developer shell may export REAL keys; tests use throwaway values only,
@@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => {
assert.ok(row.shadowedByEnv, "file value differs from the env value");
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership");
});
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too");
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
assert.equal(row.info, null, "no curated form");
assert.equal(row.source, "file");
@@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("DEEPGRAM_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined);
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
assert.ok(!r.status.shadowedByEnv);
@@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => {
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
assert.ok(r.ok);
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
assert.ok(r.status.shadowedByEnv);
});
+1 -1
View File
@@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => {
// threshold is engine behavior, not an agent-section key.
assert.deepEqual(
Object.keys(SETTINGS_SECTIONS),
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
);
});
});