diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts new file mode 100644 index 0000000..1708fe9 --- /dev/null +++ b/app/api/claude-code/route.ts @@ -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); +} diff --git a/components/chat.tsx b/components/chat.tsx index 8c70164..3648e76 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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={} @@ -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; pricing?: SessionPricing; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; @@ -2408,6 +2412,7 @@ function DockOverlay({ { - if (section === "keys") { + if (section === "keys" || section === "claudeCode") { catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; fetchCatalog(); } else if (section === "models") fetchCatalog(true); diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 0b8e46d..94e4051 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -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): Map { - 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])); } /** diff --git a/components/settings-signal.ts b/components/settings-signal.ts index 47f885d..da4dddf 100644 --- a/components/settings-signal.ts +++ b/components/settings-signal.ts @@ -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). */ diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx new file mode 100644 index 0000000..3626c2d --- /dev/null +++ b/components/settings/claude-code-status.tsx @@ -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(null); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const request = useRef(null); + const settingsRead = useRef(null); + const settingsWrite = useRef(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
+
+ Claude Code + void save(next)} /> +
+ {error &&

{error}

} + {enabled && <> +
+ {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +
+

Install and authenticate Claude Code in your terminal, then select a Claude Code model here. LearnHarness starts it when needed—no background process required.

+

Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. Setup ↗

+

Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.

+ } +
; +} diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 5a92319..cd67f47 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -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
{label} {children} - +
; } diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 198e93d..5d5f18e 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -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({ + + + + {(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( `$${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) => <> {m.name} - - {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + {m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} - {formatTokens(m.contextWindow)} + {m.contextWindow === null ? "—" : formatTokens(m.contextWindow)} } /> diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 6c1c05d..d3e9811 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -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 | undefined, + cliWindow: ReturnType = 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; 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 (
+ {selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( +

Window reported by Claude Code; may be lower for auto-compaction.

+ )}
{subscription ? "Usage" : "Total cost"} {costLabel} @@ -178,7 +194,7 @@ export function StatsBar({ {turn.steps.length} steps - {subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} + {turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} ); diff --git a/package-lock.json b/package-lock.json index 0a2f1db..6ed8114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -1615,6 +1616,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2380,6 +2393,68 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -6443,6 +6518,44 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6490,6 +6603,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anser": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", @@ -7051,6 +7203,59 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", @@ -7157,6 +7362,15 @@ "node": ">=12.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -7219,7 +7433,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7233,7 +7446,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7675,6 +7887,46 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7682,6 +7934,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -7704,7 +7973,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8469,6 +8737,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8710,7 +8987,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8740,6 +9016,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -9077,6 +9359,15 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9225,7 +9516,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9235,7 +9525,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9245,7 +9534,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9381,6 +9669,12 @@ "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", "license": "MIT" }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -9823,6 +10117,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9830,6 +10154,106 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9840,7 +10264,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9897,7 +10320,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -10018,6 +10440,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10101,6 +10544,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/framer-motion": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", @@ -10124,6 +10576,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -10164,7 +10625,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10267,7 +10727,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10301,7 +10760,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10503,7 +10961,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10606,7 +11063,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10635,7 +11091,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10894,6 +11349,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -11038,6 +11502,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11154,7 +11638,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -11187,6 +11670,24 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11526,6 +12027,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11695,7 +12202,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jake": { @@ -11793,6 +12299,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12366,7 +12878,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12729,6 +13240,31 @@ "ce-la-react": "^0.3.2" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13644,6 +14180,35 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/next": { "version": "16.4.3", "resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz", @@ -13966,11 +14531,19 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14079,11 +14652,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14291,6 +14879,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -14327,7 +14924,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14340,6 +14936,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pdfjs-dist": { "version": "6.3.289", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", @@ -14386,6 +14992,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", @@ -14642,6 +15257,23 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/proxy-agent-negotiate": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", @@ -14717,6 +15349,22 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14828,6 +15476,50 @@ } } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -15289,7 +15981,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15449,6 +16140,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15616,6 +16323,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -15633,6 +16391,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15682,6 +16459,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.35.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", @@ -15736,7 +16519,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15749,7 +16531,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15772,7 +16553,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15792,7 +16572,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15809,7 +16588,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15828,7 +16606,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15942,6 +16719,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -16433,6 +17219,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tokenlens": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tokenlens/-/tokenlens-1.3.1.tgz", @@ -16591,6 +17386,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typebox": { "version": "1.3.27", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", @@ -16866,6 +17717,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -17037,6 +17897,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -17122,7 +17991,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -17271,7 +18139,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -17369,6 +18236,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index 6e1464e..f22b7e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts new file mode 100644 index 0000000..1ee1871 --- /dev/null +++ b/src/agent/claude-code/binary.ts @@ -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; + 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, + }); +} diff --git a/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts new file mode 100644 index 0000000..f3dddca --- /dev/null +++ b/src/agent/claude-code/cli-events.ts @@ -0,0 +1,188 @@ +import type { Usage } from "@earendil-works/pi-ai"; + +export type CliUsage = Partial>; + +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; + } +} diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts new file mode 100644 index 0000000..488f3f0 --- /dev/null +++ b/src/agent/claude-code/constants.ts @@ -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", +} diff --git a/src/agent/claude-code/context-window.ts b/src/agent/claude-code/context-window.ts new file mode 100644 index 0000000..9dd2354 --- /dev/null +++ b/src/agent/claude-code/context-window.ts @@ -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; + 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 | 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): void { + this.write(JSON.stringify({ type: "control_request", request_id: id, request })); + } +} diff --git a/src/agent/claude-code/installation.ts b/src/agent/claude-code/installation.ts new file mode 100644 index 0000000..9189922 --- /dev/null +++ b/src/agent/claude-code/installation.ts @@ -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; + +/** 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 { + 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" }; + } +} diff --git a/src/agent/claude-code/launch.ts b/src/agent/claude-code/launch.ts new file mode 100644 index 0000000..5b296bd --- /dev/null +++ b/src/agent/claude-code/launch.ts @@ -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" }); + }; +} diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts new file mode 100644 index 0000000..ebb729b --- /dev/null +++ b/src/agent/claude-code/live-processes.ts @@ -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(); + +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); +} diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts new file mode 100644 index 0000000..0165e04 --- /dev/null +++ b/src/agent/claude-code/provider.ts @@ -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, "id" | "name">; + +export const CLAUDE_CODE_MODELS: readonly Model[] = [ + { ...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): 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 }, + }); +} diff --git a/src/agent/claude-code/reuse-context.ts b/src/agent/claude-code/reuse-context.ts new file mode 100644 index 0000000..63cbd7d --- /dev/null +++ b/src/agent/claude-code/reuse-context.ts @@ -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, 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; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts new file mode 100644 index 0000000..ba96098 --- /dev/null +++ b/src/agent/claude-code/stream.ts @@ -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; + +// 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 = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +const STOP_REASONS: Record = { + 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): 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; + private readonly contentIndexes = new Map(); + private readonly partialJson = new Map(); + private readonly toolBlocks = new Map(); + private readonly intents: ToolCall[] = []; + started = false; + finished = false; + + constructor( + private readonly stream: AssistantMessageEventStream, + model: Model, + ) { + 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 { + 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 { + 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(); + 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, + context: TranscriptContext, + options: SimpleStreamOptions | undefined, + owner: symbol, + configuration: unknown, + env: NodeJS.ProcessEnv, +): Promise { + 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 { + 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()); diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts new file mode 100644 index 0000000..71dc6de --- /dev/null +++ b/src/agent/claude-code/tool-bridge.ts @@ -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; + resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void; + getPendingToolCalls(): PendingToolCall[]; + close(): Promise; +}; + +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 void>(); + private waiters: ToolCallWaiter[] = []; + + add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise { + const result = new Promise((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 { + 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(); + 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 { + 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 { + 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 { + 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); + }, + }; +} diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts new file mode 100644 index 0000000..ecc5e5d --- /dev/null +++ b/src/agent/claude-code/transcript.ts @@ -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 } }); +} diff --git a/src/agent/models.ts b/src/agent/models.ts index 69815cf..8edd8d5 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -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; diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index f2178c8..bc36717 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -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["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)] : []), ], diff --git a/src/core/auth-env.ts b/src/core/auth-env.ts new file mode 100644 index 0000000..f2d8a65 --- /dev/null +++ b/src/core/auth-env.ts @@ -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; + /** Names reflected into process.env by LearnHarness's key store. */ + fillGap: Set; +} +const BOOT_KEY = Symbol.for("lh.authBoot"); + +export function authEnvRecord(): AuthBoot { + const g = globalThis as Record; + 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; +} diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts new file mode 100644 index 0000000..83d8873 --- /dev/null +++ b/src/core/contracts/claude-code.ts @@ -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"; diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 7b9e9e7..e0225a2 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -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 diff --git a/src/core/index.ts b/src/core/index.ts index f1fd3dc..bc32276 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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 diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index c9d5a46..520fa86 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -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; diff --git a/src/core/settings.ts b/src/core/settings.ts index f7966f1..2e8d3ae 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -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).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); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index b0eef2a..21ccc7e 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -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."; } diff --git a/src/server/claude-code-config.ts b/src/server/claude-code-config.ts new file mode 100644 index 0000000..9deffc2 --- /dev/null +++ b/src/server/claude-code-config.ts @@ -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; +} diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts new file mode 100644 index 0000000..c6b0c3b --- /dev/null +++ b/src/server/claude-code.ts @@ -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 { + 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 }); +} diff --git a/src/server/index.ts b/src/server/index.ts index a04dfb5..0c3b002 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 9a74b5c..2151e49 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -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; - /** Vars WE put into process.env (the file's reflection) — deletable. */ - fillGap: Set; -} -const BOOT_KEY = Symbol.for("lh.authBoot"); - -function boot(): AuthBoot { - const g = globalThis as Record; - 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; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index f0d8e8d..e585150 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -32,6 +32,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise ]); } function connectionModel(provider: string, id: string): Model | 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): 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 { const keyed = keyedProviderIds(); const index = new Map(); 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); } diff --git a/src/server/pricing.ts b/src/server/pricing.ts index eaec42d..1454c11 100644 --- a/src/server/pricing.ts +++ b/src/server/pricing.ts @@ -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)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 9b1a173..81ae0f2 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { + 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; diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index 2edcf2f..b329ba7 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -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(); 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"]')); diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts new file mode 100644 index 0000000..ae9c855 --- /dev/null +++ b/tests/claude-code-binary.test.ts @@ -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 = {}, 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); +}); diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx new file mode 100644 index 0000000..9aa62f3 --- /dev/null +++ b/tests/claude-code-context-ui.test.tsx @@ -0,0 +1,46 @@ +import React, { act } from "react"; +(globalThis as Record).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(""); +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(); + 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('[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"); +}); diff --git a/tests/claude-code-context-window.test.ts b/tests/claude-code-context-window.test.ts new file mode 100644 index 0000000..5b131eb --- /dev/null +++ b/tests/claude-code-context-window.test.ts @@ -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 }[] = []; + 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); +}); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts new file mode 100644 index 0000000..aa6d735 --- /dev/null +++ b/tests/claude-code-harness.test.ts @@ -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[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[1]): Promise { + 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 { + 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((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); +}); diff --git a/tests/claude-code-installation.test.ts b/tests/claude-code-installation.test.ts new file mode 100644 index 0000000..cc33126 --- /dev/null +++ b/tests/claude-code-installation.test.ts @@ -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, 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, /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); +}); diff --git a/tests/claude-code-opt-in.test.ts b/tests/claude-code-opt-in.test.ts new file mode 100644 index 0000000..b0e0026 --- /dev/null +++ b/tests/claude-code-opt-in.test.ts @@ -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")); +}); diff --git a/tests/claude-code-provider.test.ts b/tests/claude-code-provider.test.ts new file mode 100644 index 0000000..a8ef24d --- /dev/null +++ b/tests/claude-code-provider.test.ts @@ -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); +}); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts new file mode 100644 index 0000000..b9bed2d --- /dev/null +++ b/tests/claude-code-status-http.test.ts @@ -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 = {}) { + 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[]) 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[]) 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); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx new file mode 100644 index 0000000..ee92d29 --- /dev/null +++ b/tests/claude-code-status-ui.test.tsx @@ -0,0 +1,141 @@ +import React, { act } from "react"; +(globalThis as Record).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(""); +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('[data-testid="claude-code-check-again"]')!; +const toggle = () => document.querySelector('[role="switch"]')!; +const label = () => document.querySelector('[role="status"]')?.textContent; +function fixture(probe: (init?: RequestInit) => Promise, 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(<>{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(); + assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/); + assert.equal((html.match(/