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
+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>
);