diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..ae5a75d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,67 @@
+name: Bug report
+description: Report something that is not working as expected.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for helping improve LearnHarness. Describe what you observed; you do not need to know the cause.
+
+ **This report is public.** Remove API keys, tokens, passwords and private information before posting. Never attach credential files or your whole data folder. Share only the learning content you are comfortable making public.
+ - type: textarea
+ id: description
+ attributes:
+ label: What happened?
+ description: Describe the problem and what you expected to happen instead.
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: LearnHarness version or build
+ description: Include the app version, installer filename or source commit. If you cannot find it, write "unknown".
+ validations:
+ required: true
+ - type: input
+ id: operating_system
+ attributes:
+ label: Operating system
+ description: For example, Windows, macOS or your Linux distribution. Include the OS version if known.
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Steps or context (optional)
+ description: How can we reproduce it? If it is intermittent or you are unsure, describe what you were doing when it happened.
+ validations:
+ required: false
+ - type: input
+ id: installation
+ attributes:
+ label: Installation method (optional)
+ description: For example, desktop installer, AppImage, system package, self-hosted web or source build.
+ validations:
+ required: false
+ - type: input
+ id: provider_model
+ attributes:
+ label: Provider and model (if relevant)
+ description: Include the provider, model and API-key or subscription connection type if relevant. Do not include any keys or tokens.
+ validations:
+ required: false
+ - type: textarea
+ id: transcript_excerpt
+ attributes:
+ label: Sanitized raw JSON or chat-log excerpt (optional)
+ description: A few relevant entries around the affected turn or tool call are especially helpful. Paste the excerpt directly, preserving JSON escaping where possible. Remove credentials, personal information and private learning content first. Leave this blank if the records are difficult to find.
+ render: json
+ validations:
+ required: false
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots or other context (optional)
+ description: Attach screenshots or add other relevant details. Check for private information before posting.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..0086358
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..456ac46
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,17 @@
+name: Feature request
+description: Suggest a new feature or an improvement.
+labels: [enhancement]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Ideas are welcome; you do not need a finished design or implementation plan. A request opens a discussion, not a commitment to build it.
+
+ **This report is public.** Do not include credentials or private information.
+ - type: textarea
+ id: idea
+ attributes:
+ label: What would you like to see?
+ description: Describe your idea and the problem it would help you solve.
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 0000000..29d8d91
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,15 @@
+name: Question
+description: Ask about using LearnHarness.
+labels: [question]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **This report is public.** Do not include credentials or private information.
+ - type: textarea
+ id: question
+ attributes:
+ label: What is your question?
+ description: Include any relevant context. Version, OS, provider or model details can help when they relate to your question.
+ validations:
+ required: true
diff --git a/.gitignore b/.gitignore
index 692ea6d..c15f3bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,9 @@ dist/
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
auth.json
+chatgpt-credentials.json*
+github-copilot-credentials.json*
+xai-credentials.json*
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
.fresh-data/
diff --git a/README.md b/README.md
index 10c5aa7..71f1026 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,8 @@
Download ·
How it works ·
Documentation ·
- Issues
+ Issues ·
+ Discord
LearnHarness uses AI agents to plan and teach lessons from your current understanding to your goal. It builds a detailed picture of what you understand and how you reason. That picture is updated as you learn.
@@ -52,6 +53,8 @@ Add your provider API key and choose your models, then start a session with what
All three platforms support in-app updates.
+**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role.
+
## Your keys, your data
- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs.
diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts
index 1b19ce5..33f7e04 100644
--- a/app/api/chats/[id]/route.ts
+++ b/app/api/chats/[id]/route.ts
@@ -1,4 +1,4 @@
-import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
+import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, turnFailure, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
messages: chat.messages,
pricing: sessionCost(chat),
turnInFlight: isTurnRunning(id),
+ // Also reaches idle views that missed the failing turn's stream.
+ turnError: turnFailure(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
});
diff --git a/app/api/chats/[id]/stream/route.ts b/app/api/chats/[id]/stream/route.ts
index 6dcc08f..7c56152 100644
--- a/app/api/chats/[id]/stream/route.ts
+++ b/app/api/chats/[id]/stream/route.ts
@@ -5,8 +5,9 @@
* submitter too attaches here. One SSE stream per chat: a running turn →
* subscribe to the live turn object: FULL replay of the event log, then
* live chunks until settle ([DONE]). No running turn → the cheap terminal
- * ({"type":"turn-settled"} + [DONE]): the record is current, the client
- * refetches it.
+ * ({"type":"turn-settled"} + [DONE]): the client refetches the record.
+ * A retained settlement-failure notice precedes that terminal when a
+ * reply could not be saved before the submitter attached.
*
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
* wrapper over the server barrel (the import law); tests drive the seams
@@ -18,6 +19,6 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
const { id } = await ctx.params;
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
const handle = runningTurn(id);
- if (!handle) return settledTurnResponse();
+ if (!handle) return settledTurnResponse(id);
return piTurnResponse(handle);
}
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/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts
new file mode 100644
index 0000000..54b267c
--- /dev/null
+++ b/app/api/custom-provider/route.ts
@@ -0,0 +1,9 @@
+/** PR #62's single custom endpoint: discover/manual connect, offline pins,
+ * and disconnect. Host boundaries and mutations live behind the server barrel. */
+import { customProviderGet, customProviderMutation } from "@/src/server";
+
+export const dynamic = "force-dynamic";
+export const GET = customProviderGet;
+export const POST = customProviderMutation;
+export const PATCH = customProviderMutation;
+export const DELETE = customProviderMutation;
diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts
index 9446d47..90f5d0f 100644
--- a/app/api/keys/route.ts
+++ b/app/api/keys/route.ts
@@ -10,7 +10,8 @@
* tests/auth-provider-keys.test.ts) — the settings UI reads it optionally
* with a graceful fallback.
*
- * Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH
+ * Masked only — last 4 + length for ordinary keys; custom keys are fully
+ * redacted at every length (no credential suffix). PLAINTEXT NEVER LEAVES THE STORE THROUGH
* THIS API (owner decision). `source` tells the page whether the live
* value comes from the environment (dev .env.local / launch env) or the
* file, and `shadowedByEnv` explains why a saved key is not the live one
diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts
index 616db9f..2c955cf 100644
--- a/app/api/settings/[section]/route.ts
+++ b/app/api/settings/[section]/route.ts
@@ -5,12 +5,13 @@
* discipline, same as the per-chat and defaults routes); sections are
* independent, so partial writes can never clobber each other.
*
- * section ∈ general | chat | agent | appearance | graph | compaction
+ * section ∈ general | chat | agent | appearance | graph | compaction | models | dictation
* Body: the complete desired section object. Parse + validate strictly
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
* supported list where one exists. The chat and models sections
* additionally pass the catalog gate (validateChatSettings /
- * validateModelsSettings — unknown provider/model = 400). Thinking
+ * validateModelsSettings — unknown provider/model = 400, except unchanged
+ * model-slot selections may remain saved while unavailable). Thinking
* levels never reject a write (owner, interlude 2026-09-22): a pinned
* level CLAMPS to the named model's nearest supported level and the
* clamped value is stored — model swaps just work, same as the
@@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 });
}
+ // Compare against the same record that this whole-section write replaces.
+ const record = readSettingsRecord() ?? { version: 1 };
let agentHealed: AgentSettings | undefined;
// Section-specific cross-validation (against the catalog / the prompt
@@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
}
- // Model slots wave (2026-09-20): the models section gets the same
- // catalog gate the chat section gets — a slot must name a real
- // provider+model and a thinking level it supports.
+ // New/replaced slot models pass the catalog gate. Unchanged saved model
+ // references may survive disconnect, including thinking-preference edits,
+ // so each unavailable slot can be repaired independently of the others.
if (section === "models") {
- const verdict = validateModelsSettings(parsed.settings as ModelsSettings);
+ const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models);
if (!verdict.ok) {
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
}
@@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
// Read-modify-write ONE section of the record; everything else rides
// along untouched (other sections, unknown keys — sanitize preserves).
- const record = readSettingsRecord() ?? { version: 1 };
const next = { ...record, [section]: sectionOut };
writeSettingsRecord(next);
return Response.json({ settings: next });
diff --git a/app/api/subscriptions/browser/route.ts b/app/api/subscriptions/browser/route.ts
new file mode 100644
index 0000000..760380f
--- /dev/null
+++ b/app/api/subscriptions/browser/route.ts
@@ -0,0 +1,6 @@
+import { subscriptionBrowserResponse } from "@/src/server";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const GET = subscriptionBrowserResponse;
+export const POST = subscriptionBrowserResponse;
diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts
new file mode 100644
index 0000000..3674db5
--- /dev/null
+++ b/app/api/subscriptions/route.ts
@@ -0,0 +1,6 @@
+import { subscriptionControlsResponse } from "@/src/server";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const GET = subscriptionControlsResponse;
+export const POST = subscriptionControlsResponse;
diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx
index 9f35d80..83413b3 100644
--- a/app/chat/[id]/page.tsx
+++ b/app/chat/[id]/page.tsx
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import { Chat } from "@/components/chat";
import { slotsConfigured } from "@/src/core";
-import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
+import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord, turnFailure } from "@/src/server";
export const dynamic = "force-dynamic";
@@ -29,6 +29,7 @@ export default async function ChatPage({
// derived app-side (core/view.ts projectView; GET serves the same
// native messages).
initialMessages={chat.messages}
+ initialError={turnFailure(chat.id)}
initialConfigured={slotsConfigured(record?.models)}
initialPrompt={prompt}
// A teacher turn that outlived its client: the remounted view must
diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx
index 41b3f5b..cf5f457 100644
--- a/components/ai-elements/message.tsx
+++ b/components/ai-elements/message.tsx
@@ -38,7 +38,7 @@ import {
type PluginConfig,
} from "streamdown";
import { mdComponents } from "@/components/md-blocks";
-import { transformMathDelimiters } from "@/src/core";
+import { repairMathForDisplay, transformMathDelimiters } from "@/src/core";
export type MessageProps = HTMLAttributes & {
from: LhViewMessage["role"];
@@ -362,6 +362,12 @@ export const streamdownPlugins = {
math: createMathPlugin({ singleDollarTextMath: true }),
} as unknown as PluginConfig;
+// Remend's incomplete-HTML heuristic mistakes ` {
const { t } = useI18n();
@@ -385,6 +391,7 @@ export const MessageResponse = memo(
translations={translations}
components={mdComponents}
plugins={streamdownPlugins}
+ remend={streamdownRemend}
{...props}
>
{content}
@@ -398,6 +405,16 @@ export const MessageResponse = memo(
MessageResponse.displayName = "MessageResponse";
+/** Recovery is a tool-text presentation concern, not a shape/answer-data
+ * transform. Ordinary assistant/user prose keeps MessageResponse unchanged. */
+export function ToolContentResponse({ children, ...props }: MessageResponseProps) {
+ return (
+
+ {typeof children === "string" ? repairMathForDisplay(children) : children}
+
+ );
+}
+
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx
index 9d8985f..458895b 100644
--- a/components/ai-elements/reasoning.tsx
+++ b/components/ai-elements/reasoning.tsx
@@ -25,7 +25,7 @@ import {
import { Streamdown } from "streamdown";
import { transformMathDelimiters } from "@/src/core";
-import { streamdownPlugins } from "./message";
+import { streamdownPlugins, streamdownRemend } from "./message";
import { mdComponents } from "@/components/md-blocks";
import { Shimmer } from "./shimmer";
@@ -212,7 +212,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
-
+
{transformMathDelimiters(children)}
diff --git a/components/answered-card.tsx b/components/answered-card.tsx
index e60a188..12a72ab 100644
--- a/components/answered-card.tsx
+++ b/components/answered-card.tsx
@@ -6,10 +6,11 @@ import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block";
+import { PathText } from "@/components/file-path-chip";
import { cn } from "@/lib/utils";
-import { MessageResponse } from "@/components/ai-elements/message";
+import { ToolContentResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core";
-import { parseAskInput, parseQuizInput } from "@/src/core";
+import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core";
/**
* Static rendering of an answered question (tool part in `output-available`).
@@ -21,8 +22,9 @@ import { parseAskInput, parseQuizInput } from "@/src/core";
* client — the card reveals his answer, the committed correct answer, and
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/
-export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
+export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionAnswerData; input?: unknown }) {
const { t } = useI18n();
+ const answer = answerForPresentation(savedAnswer, input);
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) {
return (
@@ -88,13 +90,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
))}
- {answer.questionText}
+ {answer.questionText}
{parsedDetails && (
// The details line, same paint as the pending dock (owner,
// 2026-10-12): full markdown, LaTeX included.
- {parsedDetails}
+ {parsedDetails}
)}
@@ -133,14 +135,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
)}
- {opt.label}
+ {opt.label}
{descByIndex.get(opt.index) && (
// Ask-option subtext (owner, 2026-10-12): same markdown path
// as the pending dock — LaTeX included. Quiz options carry
// none (owner ruling 2026-09-26); the map stays empty for them.
-
+
{descByIndex.get(opt.index)}
-
+
)}
@@ -154,13 +156,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
data-testid="answered-other"
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
>
- “{answer.other}”
+ “”
)}
)}
- {answer.text && “{answer.text}”
}
+ {answer.text && }
{/* The confidence record (owner round 6): the slider's rail, disc
removed — the missing handle itself says "this is a record, not a
@@ -213,9 +215,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
{whyOpen && (
{t("Why:")}{" "}
- {/* The markdown wrapper must go truly inline — a block
after the
- "Why:" label created the oversized leading gap (owner's fix list). */}
- {answer.explanation}
+ {/* Keep the first paragraph beside the label, not every paragraph:
+ later paragraphs must retain their recovered document structure. */}
+ {answer.explanation}
)}
@@ -227,7 +229,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
{isExplain && answer.committed && (
{t("The answer:")}{" "}
- {answer.committed}
+ {answer.committed}
)}
diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx
index 5bdf70a..73df341 100644
--- a/components/artifacts/dispatch-card.tsx
+++ b/components/artifacts/dispatch-card.tsx
@@ -17,7 +17,7 @@ import { BrailleSpinner } from "@/components/braille-spinner";
*
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
* running → live activity card (activity line + elapsed)
- * done/error → quiet one-liner ("delivered" / "failed") — the artifact
+ * done/error → quiet one-liner ("finished" / "failed") — the artifact
* itself arrives in the steer message right below, so the
* box must not duplicate it.
*/
@@ -57,7 +57,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
>
{failed ? : }
{t(label)}
- {failed ? t("failed") : t("delivered")}
+ {failed ? t("failed") : t("finished")}
);
}
diff --git a/components/artifacts/svg-image.tsx b/components/artifacts/svg-image.tsx
new file mode 100644
index 0000000..6c74cdd
--- /dev/null
+++ b/components/artifacts/svg-image.tsx
@@ -0,0 +1,37 @@
+/** Two display-only repairs for inputs the old HTML embedding accepted.
+ * Do not rewrite stored artifacts/downloads or attempt general XML repair.
+ * Comments, CDATA and processing instructions are copied verbatim. */
+export function svgImageSource(svg: string): string {
+ let foundRoot = false;
+ return svg.replace(
+ /|$)||$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g,
+ (token) => {
+ if (token.startsWith("|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"]
- ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"]
- PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"}
- GATE -->|no| PUMP
- GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"]
- REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"]
- RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)
— status flips only after the wake turn persists"]
- RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"]
- CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)
re-deliver results / respawn via setJobRespawn"] --> PUMP
+ R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"]
+ ENQ --> LIVE{"live agent and background consent?"}
+ LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"]
+ LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"]
+ GATE --> RUN["idle wake through the normal turn composition"]
+ RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"]
+ BACKOFF --> GATE
+ RUN -->|"403"| HOLD["retain ownership until consent permits delivery"]
+ RUN -->|"other failed start"| QUIET["quiet pending; next learner run or restart recovery"]
+ RUN --> SAVE["canonical marker persistence, not merely HTTP 200"]
+ STEER --> SAVE
+ SAVE --> FIN["acknowledge delivery; worker outcome unchanged"]
+ CRASH["lazy recovery + live-agent registration restore known pending jobs"] --> ENQ
```
-Job status flips done/error only after the wake turn persists. Delivery is **exactly-once**: deterministic steer ids (`steer_`, `steer_probe_`), `steerDelivered` checks at every claim point, and `claimSteers` finalizing in-transcript steers instead of re-appending. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate, append-only `mergeAdopted` adoption) and renders steers as artifacts — never a bubble.
+The per-chat registry retains ownership across live handoff, idle-wake flight, and 409/403 return; repeated submissions share one entry/promise. A deterministic marker ID in the **saved conversation** completes delivery. Streaming visibility alone does not. A failed bookkeeping acknowledgement cannot replay a saved marker, and later teaching/model failure does not reopen delivery or retry the accepted turn. Failed starts leave results durably pending without a new error UI or retry timer; the in-memory entry stays parked until a learner run or process restart. Normal question/dismissal waits and consent deferrals are not failures.
+
+Recovery reads saved job input before legacy transcript lookup. Missing research questions fail honestly without invoking the researcher. Legacy running jobs with saved results become finished workers with pending delivery; legacy terminal jobs without delivery metadata remain untouched, rather than guessing that old outcomes should be replayed. Pending result and abort notices are restored before a newly registered agent's first steering poll, even without a preceding jobs-feed read. Deleting a session discards its pending delivery obligations; only live turns and computing workers block deletion. These guarantees use the existing JSON storage and single-process delivery registry, not a transactional queue or a promise of exactly-once model execution.
Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings).
@@ -423,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te
### 8.1 Subscription connection boundary
-`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
+`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
-The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
+The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
+
+For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations.
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
diff --git a/docs/compaction-accounting.md b/docs/compaction-accounting.md
index f3b20d6..b2abcdc 100644
--- a/docs/compaction-accounting.md
+++ b/docs/compaction-accounting.md
@@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
calls. A cut that drops no active history does not launch a summarizer.
+When the active cutoff is applied, conversation requests carry the latest summary
+once, followed by the retained transcript without older compaction markers. When
+a subsequent compaction supplies that prior summary separately, it includes it
+once, plus the newly superseded transcript without summary markers. All markers
+and original messages remain stored. The existing full-history fallback for an
+unresolved cutoff is unchanged, including its summary text.
+
+A provider response stopped at its output limit (`length`) is an incomplete
+summary, not a successful checkpoint. Compaction fails without changing the
+existing history or prior summary; a later attempt can retry normally.
+
## Conversation meter
The numerator is the latest qualifying completed conversation response's native
diff --git a/docs/custom-providers.md b/docs/custom-providers.md
new file mode 100644
index 0000000..eae4abd
--- /dev/null
+++ b/docs/custom-providers.md
@@ -0,0 +1,133 @@
+# Custom and local models
+
+LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**.
+The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur.
+Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling.
+
+## Configure an endpoint
+
+1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same
+ editor is available from the first-run provider picker.
+2. Enter the server's complete API base URL, for example
+ `http://localhost:11434/v1`. LearnHarness does **not** append `/v1`.
+3. Choose **Keyless** or **API key**:
+ - **Keyless** never uses a saved or environment custom-provider key for
+ authentication. Pi's OpenAI client receives an internal dummy credential.
+ - **API key** uses the effective `CUSTOM_OPENAI_API_KEY`. A real environment
+ value wins over a file-saved value; the editor identifies environment ownership.
+ Any nonempty trimmed custom key is accepted—there is no arbitrary length floor.
+ - Changing the URL requires a new effective key or explicit consent to reuse
+ the existing effective key. A key is never silently forwarded to a new URL.
+ Clearing authentication selects Keyless.
+4. Enter optional **model IDs**, separated by commas or whitespace. This supports
+ servers whose chat endpoint works but whose `/models` endpoint is absent.
+5. Click **Connect**, then choose models for Strong, Cheap, and Vision.
+
+HTTP and HTTPS, including localhost/private-network servers, are supported. URLs
+with embedded credentials, query strings, or fragments are rejected. Put a key in
+the authentication field, not the URL. Discovery redirects are rejected.
+
+Connect requests `GET /models`. Discovery metadata wins over duplicate manually
+entered IDs. A failed probe can fall back to entered IDs; a failed probe for the
+same URL preserves its previous model snapshot and pins. A different URL starts
+with its own models. **401/403 always fails**, even with entered IDs. Response-size,
+model-count, and redirect violations also fail explicitly rather than silently
+truncating or bypassing a discovery limit.
+
+Configuration is not a continuous health check. An endpoint may be configured while
+offline, and model capability or output quality is not proven by listing its ID.
+No backend/model installation, downloads, or OAuth flows are performed.
+
+## Model budgets and reasoning support
+
+Settings exposes offline-editable pins per model:
+
+- **Context budget**: user pin → reported context → **8192 fallback**.
+- **Maximum output**: user pin → reported output limit → **4096 fallback**.
+- **Reasoning support**: user declaration → reported support.
+
+Every positive integer token pin is accepted, including a 512-token output limit.
+Blank fields remove that pin. The editor identifies reported, fallback, and user-pin
+values. **These are request/compaction budgets; changing them does not resize the
+backend's actual context.** Configure the server's active context separately and
+enter budgets that match it. Reconnecting prunes pins only to the resulting model
+IDs; pin saves do not contact the server.
+
+Reasoning support is a **capability declaration, not an execution toggle**. The
+existing Pi-backed effort picker remains available. In particular, **Off may not
+disable a custom backend's default thinking**: an absent effort parameter is not a
+universal disable command. There is no compatibility/thinking-map editor, Ollama
+preset, or model-name inference. Backends requiring additional nonstandard headers
+or compatibility configuration are outside this release's custom-endpoint UI.
+
+## Images and Vision
+
+All custom models can be selected for Vision; models that report image support
+appear first. The endpoint owner is responsible for choosing an appropriate model.
+Built-in model filtering is unchanged.
+
+Missing discovery metadata does not silently remove images: **every custom-engine
+image-bearing request attempts delivery**, including teaching `read` results,
+rendered PDF pages, and visual-maker screenshots. A request-local model clone permits
+Pi's native image encoding; registered/catalog metadata is not promoted to claim
+that every model understands images. A text-only backend may reject the request;
+that error is surfaced, with no silent text-only fallback. This does not establish
+real-model image-understanding quality.
+
+## Credentials, reloads, and concurrent editing
+
+Nonsecret configuration is stored in the existing `settings.json.customProvider`;
+file-saved credentials use the existing `auth.json` key seam. A reserved internal
+string entry, `__learnharness_custom_provider_binding`, binds authorization to the
+endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings.
+The file key and its binding are published in one existing atomic auth-map write.
+The internal entry is not an environment variable or an editable/displayed key.
+Neither keys nor raw upstream errors are returned by the custom-provider API.
+
+Every startup and upstream operation requires matching binding IDs, URL, and mode
+before using credentials. A crash between auth/settings writes, or a failed
+settings write whose auth rollback also fails, leaves the custom provider disabled
+rather than sending a new key to an old endpoint. Reload and explicitly reconnect
+to repair authorization. A shadowed file key retains its own authorized URL: if an
+environment key is reused for another URL and later disappears, the old file key
+is not silently forwarded there. Keyless also requires its nonsecret binding, but
+never resolves a stored/environment key. Disconnect leaves an internal disabled
+marker so a latent environment key cannot revive an interrupted disconnect.
+
+Use the custom endpoint editor to change custom credentials; generic
+`/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is
+stored in settings or a public revision. These guards cover process crashes and
+restart using the existing atomic rename; they are not a power-loss/fsync
+transaction system. Missing auth storage can initialize normally; unreadable or
+malformed private storage fails closed without overwriting its contents.
+
+Each upstream model operation resolves the current custom model, endpoint, effective
+credential, limits, and original saved effort preference against Pi's current model
+metadata. Already-started requests keep their setup. A removed model or disconnected
+provider fails rather than falling back; saved model selections and history are not
+rewritten.
+
+Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with
+reload/retry guidance. A connect also checks configuration and effective credentials
+after discovery, and merges only the custom section into the latest settings record,
+preserving intervening unrelated edits. Failed mutations never claim success.
+
+Desktop requests use the existing desktop capability. Browser mutations must be
+same-origin JSON requests. Limits are **15 seconds** for discovery, **8 MiB** for its
+response, **10,000** listed models, and **64 KiB** for configuration-write bodies.
+These are protections for the new endpoint, not a multiuser authentication system.
+
+## Pricing and previous development configuration
+
+Custom model pricing is **unknown/unpriced**, not $0—even for an endpoint that may
+be locally hosted. Pi-required internal zero-rate placeholders are not presented as
+free inference. There is no rate editor in this release.
+
+The earlier, unshipped app-data `models.json` feature is retired and is **not read**.
+No user file is deleted or renamed, and there is no importer, coexistence mode,
+profile reset, history rewrite, or silent model remapping. Existing development
+profiles need manual endpoint configuration and model reselection. Unbound early
+GUI development configurations also require explicit reconnect/new-key entry or
+the existing reuse-consent checkbox, even at the same URL; they are never silently
+trusted or migrated. This release
+supports one OpenAI-compatible custom endpoint, not multiple arbitrary protocols.
diff --git a/docs/development.md b/docs/development.md
index 062a72e..f775f61 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -2,13 +2,13 @@
The developer README (split out of the storefront README, 2026-10-10). Everything below is for people working ON LearnHarness, not using it.
-A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](docs/pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
+A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page.
Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, sessions accumulate into the graph through the extraction pipeline (propositions + reasoning chains — the stored truth; the canvas renders a derived image), and the teacher reaches back into the graph through read-only prober tools on its own judgment so probing compounds across sessions.
-- [`docs/architecture.md`](docs/architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
-- Living design doc (all decisions): [`docs/vision.md`](docs/vision.md)
-- Build plan + wave/gate results: [`docs/plan.md`](docs/plan.md)
+- [`docs/architecture.md`](architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.)
+- Living design doc (all decisions): [`docs/vision.md`](vision.md)
+- Build plan + wave/gate results: [`docs/plan.md`](plan.md)
## Commands
@@ -22,6 +22,7 @@ npm start # serve the production build
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
npm run lint # eslint — includes the import law (architecture §2)
npm run typecheck # tsc --noEmit
+node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate
npm run desktop:dev # next dev + Electron shell pointed at it
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
@@ -32,7 +33,7 @@ npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extrac
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
```
-For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
+For Windows/Linux packages and the three-OS CI/release flow, see [`docs/desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
## Environment (`.env.local`)
@@ -47,9 +48,19 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
+### Subscription connections in web/dev
+
+Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport.
+
+Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock.
+
+Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing.
+
+This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented.
+
## Architecture
-Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
+Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](architecture.md).
```mermaid
flowchart TB
@@ -165,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
-**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
+**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement.
## Testing
@@ -179,10 +190,10 @@ All deterministic, no LLM, no network (engines are injected — pi-ai's faux pro
## Lineage
-The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](docs/pi-engine-swap.md)).
+The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](pi-engine-swap.md)).
## Data
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
-**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
\ No newline at end of file
+**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
diff --git a/docs/probe-teach-split.md b/docs/probe-teach-split.md
index d1b1c96..7f54530 100644
--- a/docs/probe-teach-split.md
+++ b/docs/probe-teach-split.md
@@ -21,7 +21,8 @@ philosophy so they share a view of the whole process).
field it must not use. No plan tools, no jobs, no demos.
- **The teacher** runs everything from planning onward: full tool set
(teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the
- transcript carries a `probe_done`.
+ transcript carries a concluded `probe_done`: a non-failed/non-aborted
+ assistant call followed by a matching successful tool result.
- **The swap happens between probing and planning** — the planning phase
needs full knowledge of how teaching is done, so the teacher agent starts
at planning.
@@ -43,6 +44,12 @@ The prober concludes explicitly:
- **Refuses while a question is open** ("resolve the open question first") —
same doctrine as nodeId validation.
- **Refuses a second conclusion** (idempotent: the probe concludes once).
+ Conclusion requires a later `probe_done` result matching the call's ID
+ and tool name. The calling generation must not have failed or aborted;
+ the result must not be `isError` or explicitly contain `details.ok:false`.
+ Clean acknowledgments without an explicit `ok:true` remain valid. Bare,
+ unmatched, errored, or refused calls keep probing open; saved attempts
+ are never deleted or replay-executed by this detection rule.
- The assessment is the tool call's input, so it streams to the learner live
and is durable in the transcript by the tool result itself. A dedicated
assessment card/viewer comes later (UI).
@@ -53,7 +60,7 @@ The prober concludes explicitly:
- **Side effect: it queues the handoff.** When the prober's turn ends, the
wake pipeline (the same machinery that delivers sub-agent results and
abort steers) fires a server-side turn with a handoff steer — the teacher
- is constructed (probe_done is in the transcript) and authors the plan.
+ is constructed (probe_done has successfully concluded) and authors the plan.
The learner experiences assessment → plan as one continuous flow. If the
wake is lost (crash window), the next client message still constructs the
teacher — the transcript is the source of truth.
diff --git a/docs/tool-content-recovery.md b/docs/tool-content-recovery.md
new file mode 100644
index 0000000..8151b1a
--- /dev/null
+++ b/docs/tool-content-recovery.md
@@ -0,0 +1,63 @@
+# Tool-content presentation recovery
+
+## Contract
+
+Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content.
+
+`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery.
+
+There are three separate responsibilities:
+
+1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here.
+2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects.
+3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout.
+
+## Recovery rules
+
+### Literal code
+
+Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown.
+
+### Document escapes
+
+A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected.
+
+Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content.
+
+### JSON-swallowed command prefixes
+
+Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection.
+
+The owner-approved typical-model-authoring policy is:
+
+- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff.
+- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes.
+- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`.
+- **Unrecognized whitespace:** remains unchanged. `x+y` does not become invented `\t`/`\ty` commands.
+
+The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings.
+
+### Extra command escaping, percentages and rows
+
+Recovery examines the original TeX context before changing backslash runs:
+
+- Complete native environment pairs and native `\substack` arguments preserve genuine row separators.
+- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one.
+- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented.
+- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule.
+- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully.
+
+Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders.
+
+## Historical answers
+
+`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed.
+
+## Verification
+
+- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples.
+- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity.
+- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure.
+- Existing Markdown, card, practice, tool-shape and grading suites remain applicable.
+
+The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests.
diff --git a/electron/main.cjs b/electron/main.cjs
index 4ba7bb0..4f53c96 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -24,12 +24,13 @@
* would shadow every settings-page key change until restart.
*/
-const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
+const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
const { desktopAPIHeaders } = require("./desktop-api.cjs");
+const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs");
const { randomBytes } = require("node:crypto");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
@@ -140,6 +141,7 @@ let subscriptions = null;
let subscriptionChild = null;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null;
+let reopenWindow = null;
const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
@@ -315,6 +317,7 @@ function createWindow(url) {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
+ ...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}),
preload: path.join(__dirname, "preload.cjs"),
},
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
@@ -437,31 +440,57 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
* and before-quit alone would race the swap. */
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
let autoUpdater = null; // electron-updater's, packaged builds only
+// Observational only: this snapshot NEVER gates a check, download or install.
+// The revision lets late subscribers reject an older in-flight IPC snapshot.
+let updateState = { status: "idle", version: null, revision: 0 };
-function broadcastUpdateStatus(status) {
- for (const win of BrowserWindow.getAllWindows()) {
- try {
- win.webContents.send("lh:update-status", status);
- } catch {
- /* window mid-teardown — the next push retries */
+function reportUpdateStatus(status, version = null) {
+ try {
+ // A later check/error must not hide an update already staged for quit.
+ if (updateState.status === "downloaded" && status !== "downloaded") return;
+ updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 };
+ for (const win of BrowserWindow.getAllWindows()) {
+ try {
+ win.webContents.send("lh:update-status", updateState);
+ } catch {
+ /* window mid-teardown — snapshot retrieval covers a later load */
+ }
}
+ } catch {
+ /* Status reporting must never interrupt the updater. */
+ }
+}
+
+/** Read the SAME data directory passed to the packaged server. Only an
+ * explicit false opts out; absent/corrupt/unreadable settings keep updates ON.
+ * Re-read for each automatic check so saving the toggle needs no restart. */
+function automaticUpdatesEnabled() {
+ try {
+ const settings = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8"));
+ return settings?.general?.autoUpdate !== false;
+ } catch {
+ return true;
}
}
function checkForUpdates({ manual = false } = {}) {
if (!autoUpdater) return;
+ if (!manual && !automaticUpdatesEnabled()) {
+ log("auto-update: automatic check skipped (turned off in Settings)");
+ return;
+ }
+ // Do not gate on display state: a stale spinner/error must never disable
+ // future background checks. electron-updater owns its in-flight work.
autoUpdater
.checkForUpdates()
.then((result) => {
- // The download itself is silent (autoDownload); the banner fires on
- // update-downloaded. Manual checks additionally answer directly.
- if (!manual) return;
- const version = result?.updateInfo?.version;
- if (!version) dialog.showMessageBox({ type: "info", message: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
+ // A declined check (e.g. an unsupported package) establishes no
+ // latest-version claim. Real results arrive through updater events.
+ if (!result) reportUpdateStatus("idle");
})
.catch((err) => {
log(`update check failed: ${err?.message ?? err}`);
- if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
+ reportUpdateStatus("error");
});
}
@@ -478,12 +507,14 @@ function installUpdateNow() {
function setupAutoUpdater() {
if (!app.isPackaged) {
log("auto-update: dev build — updater disabled");
+ reportUpdateStatus("disabled");
return;
}
try {
autoUpdater = require("electron-updater").autoUpdater;
} catch (err) {
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
+ reportUpdateStatus("disabled");
return;
}
const feed = process.env.LH_UPDATE_FEED;
@@ -501,11 +532,37 @@ function setupAutoUpdater() {
debug: (m) => {}, // too chatty for the persistent log
error: (m) => log(`updater error: ${m}`),
};
+ autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking"));
+ autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version));
+ autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date"));
autoUpdater.on("update-downloaded", (info) => {
log(`update downloaded: ${info?.version} — banner shown`);
- broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
+ reportUpdateStatus("downloaded", info?.version);
});
- autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
+ autoUpdater.on("error", (err) => {
+ log(`updater error: ${err?.message ?? err}`);
+ reportUpdateStatus("error");
+ });
+}
+
+/** One result surface for manual checks. Preload buffers the navigation
+ * request until React subscribes, including when all macOS windows closed. */
+function checkForUpdatesFromMenu() {
+ try {
+ const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.();
+ if (win) {
+ if (win.isMinimized()) win.restore();
+ win.focus();
+ const openSettings = () => {
+ try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ }
+ };
+ if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings);
+ else openSettings();
+ }
+ } catch {
+ /* Navigation failure must not prevent the manual check. */
+ }
+ checkForUpdates({ manual: true });
}
function zoomStep(win, dir) {
@@ -541,9 +598,8 @@ function installMenu() {
label: app.name,
submenu: [
{ role: "about", label: nt("About LearnHarness") },
- // Manual check (Wave C): answers via native dialog — no renderer
- // round-trip. The 24h automatic checks stay silent.
- { label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
+ // Manual checks report their result in Settings → General.
+ { label: nt("Check for Updates…"), click: () => checkForUpdatesFromMenu() },
{ type: "separator" },
{ role: "hide", label: nt("Hide LearnHarness") },
{ role: "hideOthers", label: nt("Hide Others") },
@@ -657,6 +713,13 @@ async function main() {
return subscriptionControl(command, subscriptions);
});
+ // The dev shell has no browser tabs. Only a one-use action on its own
+ // Next server may be resolved here; never accept an arbitrary external URL.
+ ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => {
+ if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request.");
+ await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) });
+ });
+
installMenu();
ipcMain.on("lh:language", (event, language) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
@@ -672,6 +735,14 @@ async function main() {
// are log lines — this can never block boot.
setupAutoUpdater();
ipcMain.on("lh:update-install", () => installUpdateNow());
+ ipcMain.handle("lh:update:get", (event) => {
+ if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
+ return updateState;
+ });
+ ipcMain.handle("lh:update:check", (event) => {
+ if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
+ checkForUpdates({ manual: true });
+ });
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
@@ -688,13 +759,14 @@ async function main() {
const target = SIDEBAR_AUTOCLOSE;
if (width < target) win.setBounds({ x, y, width: target, height });
});
+ reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); };
app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
+ if (BrowserWindow.getAllWindows().length === 0) reopenWindow();
});
createWindow(url);
log(`window open: ${url}`);
- // First update check AFTER the window exists (the downloaded-banner
- // push needs a listener); dev builds no-op (no autoUpdater).
+ // Startup + 24h checks stay main-owned and read the current preference.
+ // No renderer subscription is required; late loads retrieve the snapshot.
if (autoUpdater) {
checkForUpdates();
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
diff --git a/electron/preload.cjs b/electron/preload.cjs
index 513b3b2..90ddee4 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -19,17 +19,35 @@
*
* window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the
* auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes
- * {status:"downloaded", version} once an update is staged; installUpdate
- * asks main to kill the server and quit-and-install. Browser builds:
- * both absent, the banner never renders.
+ * {status, version, revision} on updater events; installUpdate asks main
+ * to kill the server and quit-and-install. getUpdateState retrieves the
+ * snapshot for a late subscriber; checkForUpdates requests a manual
+ * check (including download/install-on-quit, even with automatic checks OFF).
+ *
+ * window.lh.onOpenSettings(cb) — menu navigation, buffered until React
+ * subscribes after a window is reopened. Browser builds have no bridge.
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
-const { contextBridge, ipcRenderer } = require("electron");
+const { contextBridge, ipcRenderer, webUtils } = require("electron");
+
+let openSettingsListener = null;
+let pendingOpenSettings = null;
+ipcRenderer.on("lh:open-settings", (_event, section) => {
+ if (openSettingsListener) openSettingsListener(section);
+ else pendingOpenSettings = section;
+});
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
setLanguage: (language) => ipcRenderer.send("lh:language", language),
+ // Resolve only a renderer-supplied, OS-backed File. No filesystem API,
+ // uploads, copies, or DOM File IPC to main; sandbox/isolation stay on.
+ getPathForFile: (file) => webUtils.getPathForFile(file),
+ ...(process.argv?.includes("--lh-web-subscriptions") ? {
+ subscriptionTransport: "web",
+ openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
+ } : {}),
chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
@@ -72,4 +90,17 @@ contextBridge.exposeInMainWorld("lh", {
return () => ipcRenderer.removeListener("lh:update-status", listener);
},
installUpdate: () => ipcRenderer.send("lh:update-install"),
+ getUpdateState: () => ipcRenderer.invoke("lh:update:get"),
+ checkForUpdates: () => ipcRenderer.invoke("lh:update:check"),
+ onOpenSettings: (cb) => {
+ openSettingsListener = cb;
+ if (pendingOpenSettings !== null) {
+ const section = pendingOpenSettings;
+ pendingOpenSettings = null;
+ cb(section);
+ }
+ return () => {
+ if (openSettingsListener === cb) openSettingsListener = null;
+ };
+ },
});
diff --git a/electron/subscriptions.cjs b/electron/subscriptions.cjs
index 16e0af3..1b731bc 100644
--- a/electron/subscriptions.cjs
+++ b/electron/subscriptions.cjs
@@ -1,7 +1,7 @@
"use strict";
const { randomUUID } = require("node:crypto");
const path = require("node:path");
-const { createRequire, registerHooks } = require("node:module");
+const { registerHooks } = require("node:module");
const { pathToFileURL } = require("node:url");
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
@@ -21,17 +21,18 @@ const MESSAGES = {
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
async function loadSubscriptionSDK(baseDirectory) {
- const resolve = createRequire(path.join(baseDirectory, "package.json"));
- const load = (id) => {
- // Pi exports are import-only. Resolve the public export with ESM
- // conditions, then remove this synchronous, exact-specifier hook
- // BEFORE evaluation; no private package path or global hook remains.
+ const parentURL = pathToFileURL(path.join(baseDirectory, "package.json")).href;
+ const load = async (id) => {
+ // The manifest URL plus fragment is an exact resolution token, never
+ // evaluated. Normal ESM resolution selects the public import export
+ // from the server's installation, including under test-loader hooks.
+ const token = `${parentURL}#${encodeURIComponent(id)}`;
const hook = registerHooks({ resolve(specifier, context, next) {
- return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
+ if (specifier !== token) return next(specifier, context);
+ hook.deregister();
+ return next(id, { ...context, parentURL });
} });
- let file;
- try { file = resolve.resolve(id); } finally { hook.deregister(); }
- return import(pathToFileURL(file).href);
+ try { return await import(token); } finally { hook.deregister(); }
};
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
return { ...sdk, ...copilot, ...xai };
diff --git a/electron/web-subscription-browser.cjs b/electron/web-subscription-browser.cjs
new file mode 100644
index 0000000..b61c73b
--- /dev/null
+++ b/electron/web-subscription-browser.cjs
@@ -0,0 +1,19 @@
+"use strict";
+
+/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next
+ * server, then open the owner-validated provider page outside the renderer.
+ * The caller must authenticate its trusted app main frame first. */
+async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) {
+ if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action.");
+ const response = await request(new URL(action, origin), {
+ method: "POST", redirect: "manual", credentials: "omit",
+ headers: { origin, "x-learnharness-subscriptions": "1" },
+ signal: AbortSignal.timeout(15000),
+ });
+ const target = response.headers.get("location");
+ if (response.status !== 303 || !target) throw new Error("Sign-in action expired.");
+ const parsed = new URL(target);
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page.");
+ await openExternal(target);
+}
+module.exports = { openWebSubscriptionBrowser };
diff --git a/next.config.ts b/next.config.ts
index db9352e..43aef8d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,11 @@
+import { readFileSync } from "node:fs";
import type { NextConfig } from "next";
+const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version: string; homepage: string };
+
const nextConfig: NextConfig = {
+ // Public build metadata only; no new runtime Electron version API.
+ env: { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage },
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
// self-contained .next/standalone/server.js the Electron shell spawns
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
@@ -43,6 +48,9 @@ const nextConfig: NextConfig = {
"*": [
"**/data/**/*",
"auth.json",
+ "chatgpt-credentials.json*",
+ "github-copilot-credentials.json*",
+ "xai-credentials.json*",
".env*",
],
},
@@ -70,6 +78,11 @@ const nextConfig: NextConfig = {
// npm installs only the current platform's binary, so this glob
// resolves to exactly one Skia binary per build.
"node_modules/@napi-rs/canvas-*/**/*",
+ // Pi-compatible image resizing runs in an unbundled worker. Photon
+ // loads its colocated WASM through Node, also in packaged Electron.
+ "src/agent/image-processing/*.mjs",
+ "src/agent/image-processing/LICENSE",
+ "node_modules/@silvia-odwyer/photon-node/**/*",
// Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same
// per-platform-binary story as the canvas above (one per build).
"node_modules/@resvg/resvg-js-*/**/*",
@@ -98,6 +111,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-agent-core",
"pdfjs-dist", // sources v0: bundling breaks its node fake-worker path
"@napi-rs/canvas", // native binding — bundling breaks the .node require
+ "@silvia-odwyer/photon-node", // CJS loads its adjacent WASM from disk
"@resvg/resvg-js", // native binding — same
],
};
diff --git a/package-lock.json b/package-lock.json
index f5cc3e3..0e46b7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,10 +11,12 @@
"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",
"@rive-app/react-webgl2": "^4.32.1",
+ "@silvia-odwyer/photon-node": "0.3.4",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
@@ -35,11 +37,14 @@
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
+ "mdast-util-from-markdown": "2.0.3",
+ "mdast-util-math": "3.0.0",
"media-chrome": "^4.19.2",
"mermaid": "^11.17.0",
+ "micromark-extension-math": "3.1.0",
"motion": "^13.1.1",
"nanoid": "^6.0.1",
- "next": "^16.3.2",
+ "next": "^16.4.3",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289",
"puppeteer-core": "^25.8.0",
@@ -57,7 +62,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
- "@next/eslint-plugin-next": "^16.3.3",
+ "@next/eslint-plugin-next": "^16.4.3",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
@@ -65,7 +70,7 @@
"@types/turndown": "^5.0.6",
"@typescript-eslint/parser": "^8.68.0",
"concurrently": "^10.0.5",
- "electron": "^44.4.1",
+ "electron": "^44.9.3",
"electron-builder": "26.16.1",
"eslint": "^9.39.5",
"eslint-import-resolver-typescript": "^4.4.5",
@@ -1611,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",
@@ -1705,9 +1722,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
- "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -1723,13 +1740,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
- "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -1745,20 +1762,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.3.2"
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
- "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
- "@img/sharp-wasm32": "0.35.3"
+ "@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -1768,9 +1785,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
- "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -1784,9 +1801,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
- "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -1800,12 +1817,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
- "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1816,12 +1836,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
- "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1832,12 +1855,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
- "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1848,12 +1874,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
- "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1864,12 +1893,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
- "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1880,12 +1912,15 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
- "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1896,12 +1931,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
- "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1912,12 +1950,15 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
- "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1928,12 +1969,15 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
- "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1946,16 +1990,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.3.2"
+ "@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
- "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1968,16 +2015,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.3.2"
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
- "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1990,16 +2040,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
- "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2012,16 +2065,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
- "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2034,16 +2090,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.3.2"
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
- "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2056,16 +2115,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.3.2"
+ "@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
- "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2078,16 +2140,19 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
- "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2100,17 +2165,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
- "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.11.1"
+ "@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2120,16 +2185,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
- "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@img/sharp-wasm32": "0.35.3"
+ "@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -2139,9 +2204,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
- "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -2158,9 +2223,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
- "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -2177,9 +2242,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.35.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
- "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -2328,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",
@@ -2625,15 +2752,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz",
- "integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.3.tgz",
+ "integrity": "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.3.tgz",
- "integrity": "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.3.tgz",
+ "integrity": "sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2642,9 +2769,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz",
- "integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.3.tgz",
+ "integrity": "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==",
"cpu": [
"arm64"
],
@@ -2658,9 +2785,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz",
- "integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.3.tgz",
+ "integrity": "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==",
"cpu": [
"x64"
],
@@ -2674,12 +2801,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz",
- "integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.3.tgz",
+ "integrity": "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2690,12 +2820,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz",
- "integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.3.tgz",
+ "integrity": "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2706,12 +2839,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz",
- "integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.3.tgz",
+ "integrity": "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2722,12 +2858,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz",
- "integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.3.tgz",
+ "integrity": "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2738,9 +2877,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz",
- "integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.3.tgz",
+ "integrity": "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==",
"cpu": [
"arm64"
],
@@ -2754,9 +2893,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz",
- "integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==",
+ "version": "16.4.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.3.tgz",
+ "integrity": "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==",
"cpu": [
"x64"
],
@@ -4714,6 +4853,12 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
+ "node_modules/@silvia-odwyer/photon-node": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz",
+ "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==",
+ "license": "Apache-2.0"
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -6373,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",
@@ -6420,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",
@@ -6981,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",
@@ -7087,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",
@@ -7149,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",
@@ -7163,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",
@@ -7605,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",
@@ -7612,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",
@@ -7634,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",
@@ -8399,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",
@@ -8640,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",
@@ -8670,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",
@@ -8687,9 +9039,9 @@
}
},
"node_modules/electron": {
- "version": "44.4.1",
- "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.1.tgz",
- "integrity": "sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==",
+ "version": "44.9.3",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz",
+ "integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9007,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",
@@ -9155,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"
@@ -9165,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"
@@ -9175,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"
@@ -9311,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",
@@ -9753,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",
@@ -9760,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",
@@ -9770,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": {
@@ -9827,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",
@@ -9948,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",
@@ -10031,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",
@@ -10054,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",
@@ -10094,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"
@@ -10197,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",
@@ -10231,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",
@@ -10433,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"
@@ -10536,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"
@@ -10565,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"
@@ -10824,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",
@@ -10968,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",
@@ -11084,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": {
@@ -11117,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",
@@ -11456,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",
@@ -11625,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": {
@@ -11723,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",
@@ -12296,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"
@@ -12659,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",
@@ -13574,13 +14180,42 @@
"dev": true,
"license": "MIT"
},
- "node_modules/next": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz",
- "integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==",
+ "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": {
- "@next/env": "16.3.2",
+ "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",
+ "integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.4.3",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -13594,15 +14229,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.3.2",
- "@next/swc-darwin-x64": "16.3.2",
- "@next/swc-linux-arm64-gnu": "16.3.2",
- "@next/swc-linux-arm64-musl": "16.3.2",
- "@next/swc-linux-x64-gnu": "16.3.2",
- "@next/swc-linux-x64-musl": "16.3.2",
- "@next/swc-win32-arm64-msvc": "16.3.2",
- "@next/swc-win32-x64-msvc": "16.3.2",
- "sharp": "^0.35.3"
+ "@next/swc-darwin-arm64": "16.4.3",
+ "@next/swc-darwin-x64": "16.4.3",
+ "@next/swc-linux-arm64-gnu": "16.4.3",
+ "@next/swc-linux-arm64-musl": "16.4.3",
+ "@next/swc-linux-x64-gnu": "16.4.3",
+ "@next/swc-linux-x64-musl": "16.4.3",
+ "@next/swc-win32-arm64-msvc": "16.4.3",
+ "@next/swc-win32-x64-msvc": "16.4.3",
+ "sharp": "^0.35.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -13896,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"
@@ -14009,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"
@@ -14221,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",
@@ -14257,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"
@@ -14270,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",
@@ -14316,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",
@@ -14572,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",
@@ -14647,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",
@@ -14758,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",
@@ -15219,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"
@@ -15379,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",
@@ -15546,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",
@@ -15563,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",
@@ -15612,10 +16459,16 @@
"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.3",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
- "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -15630,31 +16483,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.35.3",
- "@img/sharp-darwin-x64": "0.35.3",
- "@img/sharp-freebsd-wasm32": "0.35.3",
- "@img/sharp-libvips-darwin-arm64": "1.3.2",
- "@img/sharp-libvips-darwin-x64": "1.3.2",
- "@img/sharp-libvips-linux-arm": "1.3.2",
- "@img/sharp-libvips-linux-arm64": "1.3.2",
- "@img/sharp-libvips-linux-ppc64": "1.3.2",
- "@img/sharp-libvips-linux-riscv64": "1.3.2",
- "@img/sharp-libvips-linux-s390x": "1.3.2",
- "@img/sharp-libvips-linux-x64": "1.3.2",
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
- "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
- "@img/sharp-linux-arm": "0.35.3",
- "@img/sharp-linux-arm64": "0.35.3",
- "@img/sharp-linux-ppc64": "0.35.3",
- "@img/sharp-linux-riscv64": "0.35.3",
- "@img/sharp-linux-s390x": "0.35.3",
- "@img/sharp-linux-x64": "0.35.3",
- "@img/sharp-linuxmusl-arm64": "0.35.3",
- "@img/sharp-linuxmusl-x64": "0.35.3",
- "@img/sharp-webcontainers-wasm32": "0.35.3",
- "@img/sharp-win32-arm64": "0.35.3",
- "@img/sharp-win32-ia32": "0.35.3",
- "@img/sharp-win32-x64": "0.35.3"
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -15666,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"
@@ -15679,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"
@@ -15702,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",
@@ -15722,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",
@@ -15739,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",
@@ -15758,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",
@@ -15872,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",
@@ -16363,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",
@@ -16521,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",
@@ -16796,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",
@@ -16967,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",
@@ -17052,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"
@@ -17201,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": {
@@ -17299,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 eb6ac95..652ccfa 100644
--- a/package.json
+++ b/package.json
@@ -27,10 +27,12 @@
"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",
"@rive-app/react-webgl2": "^4.32.1",
+ "@silvia-odwyer/photon-node": "0.3.4",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
@@ -51,11 +53,14 @@
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
+ "mdast-util-from-markdown": "2.0.3",
+ "mdast-util-math": "3.0.0",
"media-chrome": "^4.19.2",
+ "micromark-extension-math": "3.1.0",
"mermaid": "^11.17.0",
"motion": "^13.1.1",
"nanoid": "^6.0.1",
- "next": "^16.3.2",
+ "next": "^16.4.3",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289",
"puppeteer-core": "^25.8.0",
@@ -76,7 +81,7 @@
"katex": "$katex"
},
"devDependencies": {
- "@next/eslint-plugin-next": "^16.3.3",
+ "@next/eslint-plugin-next": "^16.4.3",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
@@ -84,7 +89,7 @@
"@types/turndown": "^5.0.6",
"@typescript-eslint/parser": "^8.68.0",
"concurrently": "^10.0.5",
- "electron": "^44.4.1",
+ "electron": "^44.9.3",
"electron-builder": "26.16.1",
"eslint": "^9.39.5",
"eslint-import-resolver-typescript": "^4.4.5",
diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs
index bbb1480..266ff75 100644
--- a/scripts/build-server.mjs
+++ b/scripts/build-server.mjs
@@ -52,7 +52,8 @@ if (exists(leakedData)) {
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
// makes a regression loud instead of silent).
-for (const secret of ["auth.json", ".env", ".env.local"]) {
+const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name));
+for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) {
if (exists(path.join(STANDALONE, secret))) {
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
console.error(" Check outputFileTracingExcludes in next.config.ts.");
diff --git a/scripts/smoke-compression-runtime.mjs b/scripts/smoke-compression-runtime.mjs
new file mode 100644
index 0000000..767d856
--- /dev/null
+++ b/scripts/smoke-compression-runtime.mjs
@@ -0,0 +1,195 @@
+/** Fake-input regression for Next's real compression/backpressure path.
+ * Runs in a disposable child so zlib instrumentation cannot affect the app.
+ * node scripts/smoke-compression-runtime.mjs [--standalone ] [--runtime ]
+ */
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { once } from "node:events";
+import * as fs from "node:fs";
+import * as http from "node:http";
+import { createRequire } from "node:module";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Readable } from "node:stream";
+import { fileURLToPath } from "node:url";
+
+export function verifyCompressionRuntime(root, runtime = process.execPath) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-compression-runtime-"));
+ try {
+ for (const dir of ["data", "tmp"]) fs.mkdirSync(path.join(home, dir));
+ fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
+ // Allowlist rather than inherit credentials, Node preloads, or app settings.
+ const env = Object.fromEntries(["PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].flatMap(key =>
+ process.env[key] === undefined ? [] : [[key, process.env[key]]]));
+ const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(root)], {
+ cwd: home,
+ env: { ...env, HOME: home, USERPROFILE: home, TMPDIR: path.join(home, "tmp"), TEMP: path.join(home, "tmp"), TMP: path.join(home, "tmp"),
+ LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NEXT_TELEMETRY_DISABLED: "1" },
+ encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024,
+ });
+ assert.equal(result.error, undefined, `compression probe failed: ${result.error?.message}`);
+ assert.equal(result.status, 0, `compression probe failed:\n${result.stdout}${result.stderr}`);
+ const report = JSON.parse(result.stdout);
+ assert.equal(report.passed, true);
+ console.log(`[compression-runtime] passed (${report.node}): gzip Node/Web, identity, no-transform, disconnect`);
+ return report;
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+}
+
+async function probe(root) {
+ const require = createRequire(path.join(root, "package.json"));
+ const inside = (specifier) => {
+ const resolved = fs.realpathSync(require.resolve(specifier));
+ const relative = path.relative(fs.realpathSync(root), resolved);
+ assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `dependency escaped payload: ${resolved}`);
+ return require(resolved);
+ };
+ const zlib = require("node:zlib");
+ const descriptor = Object.getOwnPropertyDescriptor(zlib, "createGzip");
+ const records = [];
+ const warnings = [];
+ const onWarning = warning => {
+ if (warning.name === "MaxListenersExceededWarning" && warning.type === "drain" && warning.emitter?.constructor.name === "Gzip") warnings.push(warning.message);
+ };
+ process.on("warning", onWarning);
+ let active;
+ Object.defineProperty(zlib, "createGzip", { ...descriptor, value: (...args) => {
+ const stream = descriptor.value(...args);
+ const record = active;
+ record.gzip = stream;
+ record.registrations = 0;
+ record.drains = 0;
+ record.maxListeners = stream.getMaxListeners();
+ stream.on("newListener", event => { if (event === "drain") record.registrations++; });
+ const emit = stream.emit;
+ stream.emit = function(event, ...args) {
+ if (event === "drain") record.drains++;
+ return emit.call(this, event, ...args);
+ };
+ return stream;
+ } });
+ const compression = inside("next/dist/compiled/compression");
+ const { pipeNodeReadableToNodeResponse, pipeToNodeResponse } = inside("next/dist/server/pipe-readable");
+ const { releaseCompressionStream } = inside("next/dist/server/lib/release-compression-stream");
+ const middleware = compression();
+ const chunk = randomBytes(128 * 1024);
+ const pieces = 24;
+ const server = http.createServer((req, res) => {
+ const record = { mode: req.url, backpressure: 0, lateResumes: 0 };
+ active = record;
+ records.push(record);
+ middleware(req, res, () => {});
+ // Match router-server's abort teardown; do not compensate for drain ownership.
+ record.closed = new Promise(resolve => res.once("close", () => {
+ if (!res.writableFinished) releaseCompressionStream(res);
+ record.finished = res.writableFinished;
+ resolve();
+ }));
+ res.setHeader("Content-Type", "text/plain");
+ if (req.url === "/no-transform") res.setHeader("Cache-Control", "no-cache, no-transform");
+ const write = res.write;
+ res.write = function(...args) {
+ const ok = write.apply(this, args);
+ if (!ok) record.backpressure++;
+ return ok;
+ };
+ const source = Readable.from(Array.from({ length: pieces }, () => chunk));
+ record.source = source;
+ const resume = source.resume;
+ source.resume = function() {
+ if (this.destroyed) record.lateResumes++;
+ return resume.call(this);
+ };
+ record.pipe = req.url === "/web" ? pipeToNodeResponse(Readable.toWeb(source), res) : pipeNodeReadableToNodeResponse(source, res);
+ // Observe failures immediately, including client abort, without unhandled rejections.
+ record.pipe.catch(error => { record.error = error; res.destroy(); });
+ });
+ async function request(mode) {
+ await new Promise((resolve, reject) => {
+ const req = http.get({ host: "127.0.0.1", port: server.address().port, path: mode, agent: false,
+ headers: { "Accept-Encoding": mode === "/identity" ? "identity" : "gzip" } }, res => {
+ const chunks = [];
+ res.on("data", data => {
+ if (mode === "/abort") { res.destroy(); resolve(); return; }
+ chunks.push(data);
+ // A slow reader exercises both zlib and socket backpressure.
+ res.pause();
+ setTimeout(() => res.resume(), 1);
+ });
+ res.on("error", reject);
+ res.on("end", () => {
+ try {
+ const record = records.at(-1);
+ record.encoding = res.headers["content-encoding"] || "identity";
+ const body = Buffer.concat(chunks);
+ const output = record.encoding === "gzip" ? zlib.gunzipSync(body) : body;
+ assert.equal(output.length, pieces * chunk.length);
+ for (let i = 0; i < pieces; i++) assert.deepEqual(output.subarray(i * chunk.length, (i + 1) * chunk.length), chunk);
+ record.payloadVerified = true;
+ resolve();
+ } catch (error) { reject(error); }
+ });
+ });
+ req.setTimeout(5_000, () => req.destroy(new Error(`compression request stalled: ${mode}`)));
+ req.on("error", reject);
+ });
+ const record = records.at(-1);
+ await record.closed;
+ await record.pipe;
+ assert.equal(record.error, undefined);
+ assert.ok(record.backpressure > 0, `${mode} did not exercise backpressure`);
+ if (mode === "/abort") {
+ assert.equal(record.finished, false);
+ if (!record.gzip.closed) await once(record.gzip, "close");
+ assert.equal(record.gzip.destroyed, true);
+ assert.equal(record.source.destroyed, true);
+ } else {
+ assert.equal(record.finished, true);
+ assert.equal(record.payloadVerified, true);
+ }
+ if (mode === "/node" || mode === "/web") {
+ assert.equal(record.encoding, "gzip");
+ assert.ok(record.backpressure > 10, "must exceed the original warning threshold");
+ assert.ok(record.drains > 10, "must repeat real drain cycles");
+ assert.equal(record.registrations, 1, "drain registrations must not scale with writes");
+ assert.equal(record.gzip.listenerCount("drain"), 1, "upstream retains one response-lifetime listener");
+ }
+ if (mode === "/identity" || mode === "/no-transform") {
+ assert.equal(record.gzip, undefined);
+ assert.equal(record.encoding, "identity");
+ } else {
+ assert.equal(record.maxListeners, 10, "do not raise or silence the listener limit");
+ if (mode !== "/web") {
+ // The gzip listener survives off() but must ignore post-teardown drain.
+ record.gzip.emit("drain");
+ assert.equal(record.lateResumes, 0);
+ }
+ }
+ }
+ try {
+ server.listen(0, "127.0.0.1");
+ await once(server, "listening");
+ for (const mode of ["/node", "/web", "/identity", "/no-transform", "/abort"]) await request(mode);
+ await new Promise(resolve => setImmediate(resolve)); // Node delivers warnings asynchronously.
+ assert.deepEqual(warnings, []);
+ return { passed: true, node: process.version, cases: records.map(({ mode, backpressure, registrations, payloadVerified, finished }) => ({ mode, backpressure, registrations, payloadVerified, finished })) };
+ } finally {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ process.off("warning", onWarning);
+ Object.defineProperty(zlib, "createGzip", descriptor);
+ }
+}
+
+const args = process.argv.slice(2);
+const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ if (args[0] === "--probe") {
+ probe(path.resolve(args[1])).then(report => console.log(JSON.stringify(report))).catch(error => { console.error(error); process.exitCode = 1; });
+ } else {
+ verifyCompressionRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
+ }
+}
diff --git a/scripts/smoke-file-paths.mjs b/scripts/smoke-file-paths.mjs
new file mode 100644
index 0000000..13d6891
--- /dev/null
+++ b/scripts/smoke-file-paths.mjs
@@ -0,0 +1,425 @@
+/** Offline synthetic UI + actual Electron preload smoke. No providers/accounts.
+ * Run: node scripts/smoke-file-paths.mjs
+ * App/profile/auth/OS-backed fixture files are temporary; only this script's
+ * processes are stopped. Browser requests outside the owned origin are blocked.
+ */
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import fs from "node:fs";
+import net from "node:net";
+import { createRequire } from "node:module";
+import os from "node:os";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const root = path.resolve(import.meta.dirname, "..");
+const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-file-paths-"));
+const require = createRequire(import.meta.url);
+const checks = [];
+const errors = [];
+const visualFailures = [];
+const coverageFailures = [];
+const posts = [];
+const blocked = [];
+const networkFailures = [];
+const consoleErrors = [];
+const children = [];
+const browsers = [];
+const screenshots = [];
+let serverLogs = "";
+let electronLogs = "";
+let page;
+let origin;
+let shuttingDown = false;
+const fixtureCatalog = (() => {
+ const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
+ const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
+ return { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
+})();
+async function freePort() {
+ const listener = net.createServer();
+ listener.listen(0, "127.0.0.1");
+ await once(listener, "listening");
+ const port = listener.address().port;
+ await new Promise(resolve => listener.close(resolve));
+ return port;
+}
+async function stop(child) {
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
+ const exited = once(child, "exit");
+ child.kill("SIGTERM");
+ const timer = setTimeout(() => child.kill("SIGKILL"), 5000); timer.unref();
+ await exited; clearTimeout(timer);
+}
+async function startup(child, getLogs, marker, timeout = 90000) {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => finish(new Error(`Startup timed out: ${getLogs()}`)), timeout);
+ const inspect = () => { if (marker.test(getLogs())) finish(); };
+ const exited = code => finish(new Error(`Process exited ${code}: ${getLogs()}`));
+ function finish(error) {
+ clearTimeout(timer); child.stdout.off("data", inspect); child.stderr.off("data", inspect); child.off("exit", exited);
+ if (error) reject(error); else resolve();
+ }
+ child.stdout.on("data", inspect); child.stderr.on("data", inspect); child.once("exit", exited); inspect();
+ });
+}
+async function instrument(target) {
+ target.setDefaultTimeout(20000);
+ target.on("pageerror", error => { if (!shuttingDown) errors.push(error.message); });
+ target.on("requestfailed", request => { if (!shuttingDown) networkFailures.push({ url: request.url(), error: request.failure()?.errorText }); });
+ target.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
+ await target.setRequestInterception(true);
+ target.on("request", async request => {
+ try {
+ const url = new URL(request.url());
+ if (url.protocol === "data:" || url.protocol === "about:") return await request.continue();
+ if (url.origin !== origin) { blocked.push(request.url()); return await request.abort(); }
+ const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
+ if (url.pathname === "/api/models") return await respond(fixtureCatalog);
+ if (url.pathname === "/api/events") return await request.respond({ status: 204 });
+ // Never forward agent, answer or import requests, even if a UI bug
+ // submits while dropping. Record their complete synthetic bodies.
+ if (request.method() === "POST" && (/^\/api\/(chat|answer)(\/|$)/.test(url.pathname) || /\/(answer|import|turn|retry)(\/|$)/.test(url.pathname))) {
+ posts.push({ pathname: url.pathname, body: JSON.parse(request.postData() || "{}") });
+ return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
+ }
+ if (/^\/api\/chats\/[^/]+\/stream$/.test(url.pathname)) return await request.respond({ status: 204 });
+ return await request.continue();
+ } catch (error) {
+ if (!shuttingDown && !/Target closed|Session closed|Invalid InterceptionId|already handled/.test(String(error))) errors.push(String(error));
+ }
+ });
+}
+const inputSelector = '[data-file-path-input]';
+async function open(target, route) {
+ await target.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
+ await target.waitForSelector(inputSelector);
+ await target.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"], [data-testid="setup-cta-keys"]'));
+ await target.waitForFunction(() => document.querySelector('[data-testid="file-path-drop-zone"]'));
+}
+async function read(target, selector = inputSelector) {
+ return target.$eval(selector, element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join(""));
+}
+async function expectValue(target, expected, selector = inputSelector) {
+ await target.waitForFunction((selector, expected) => {
+ const element = document.querySelector(selector);
+ return element && [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === expected;
+ }, {}, selector, expected);
+}
+async function fill(target, value, selector = inputSelector) {
+ await target.$eval(selector, (element, value) => {
+ element.focus(); element.textContent = value;
+ const selection = document.getSelection(); selection.collapse(element, element.childNodes.length);
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
+ }, value);
+ await expectValue(target, value, selector);
+}
+async function caret(target, offset, selector = inputSelector) {
+ await target.$eval(selector, (element, offset) => {
+ element.focus(); let rest = offset;
+ for (const node of element.childNodes) {
+ const source = node.nodeType === 1 ? node.getAttribute("data-file-path-source") : null;
+ const length = source?.length ?? node.textContent.length;
+ if (rest <= length) {
+ if (source !== null) {
+ const index = [...element.childNodes].indexOf(node);
+ document.getSelection().collapse(element, index + (rest > 0 ? 1 : 0));
+ } else document.getSelection().collapse(node, rest);
+ document.dispatchEvent(new Event("selectionchange")); return;
+ }
+ rest -= length;
+ }
+ document.getSelection().collapse(element, element.childNodes.length);
+ document.dispatchEvent(new Event("selectionchange"));
+ }, offset);
+}
+async function shortcut(target, key, shift = false) {
+ await target.keyboard.down("Meta"); if (shift) await target.keyboard.down("Shift");
+ await target.keyboard.press(key);
+ if (shift) await target.keyboard.up("Shift"); await target.keyboard.up("Meta");
+}
+async function clipboardCommand(target, command, key, modifiers = 4) {
+ // Headless Chromium does not run the macOS menu accelerator for Meta+C/V.
+ // CDP's native edit command triggers the real browser clipboard event.
+ const session = await target.createCDPSession();
+ try { await session.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: key ?? (command === "copy" ? "c" : "v"), modifiers, commands: [command] }); }
+ finally { await session.detach(); }
+}
+async function syntheticDrop(target, paths, selector = '[data-testid="file-path-drop-zone"]') {
+ return target.$eval(selector, (zone, paths) => {
+ const transfer = new DataTransfer();
+ window.__syntheticPaths = new WeakMap();
+ window.lh = { platform: "darwin", getPathForFile(file) { return window.__syntheticPaths.get(file) ?? ""; } };
+ for (const [index, path] of paths.entries()) {
+ const file = new File(["never read"], `synthetic-display-${index}.pdf`, { type: "application/pdf" });
+ window.__syntheticPaths.set(file, path);
+ for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("File bytes must not be read"); } });
+ transfer.items.add(file);
+ }
+ const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
+ zone.dispatchEvent(event); return event.defaultPrevented;
+ }, paths);
+}
+async function blur(target) { await target.evaluate(() => { document.activeElement?.blur(); }); }
+function seedChatFixtures() {
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
+ const transcript = Array.from({ length: 100 }, (_, index) => `Synthetic lesson paragraph ${index}: one and one make two. These are offline fixture notes.`).join("\n\n");
+ const assistant = (id, content) => ({ id, role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "synthetic", timestamp: 1, stopReason: "toolUse", usage, content });
+ const call = (id, name, args) => ({ type: "toolCall", id, name, arguments: args });
+ const result = (id, toolCallId, toolName, details) => ({ id, role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: "Synthetic tool completed." }], ...(details ? { details } : {}), isError: false, timestamp: 2 });
+ const quiz = { type: "check", nodeId: "addition", question: "What is 1 + 1?", options: [{ value: "two", label: "2" }, { value: "three", label: "3" }], explanation: "Adding one to one gives two.", correctAnswer: "two" };
+ const records = [
+ { id: "path_teach", messages: [assistant("m_probe", [{ type: "text", text: transcript }, call("call_probe", "probe_done", { assessment: { strands: [{ strand: "Addition", assessment: "Ready for a check." }] } })]), result("m_probe_result", "call_probe", "probe_done", { ok: true }), assistant("m_quiz", [call("call_quiz", "quiz", quiz)])] },
+ { id: "path_probe", messages: [assistant("m_probe_question", [{ type: "text", text: transcript }, call("call_probe_question", "probe", quiz)])] },
+ { id: "path_practice", kind: "practice", messages: [assistant("m_problem", [{ type: "text", text: transcript }, call("call_problem", "problem", { statement: "Compute 1 + 1. Explain your reasoning.", mode: "recall", modelSolution: "1 + 1 = 2." })]), result("m_problem_result", "call_problem", "problem")] },
+ ];
+ for (const record of records) {
+ record.messages.unshift({ id: "sent_user", role: "user", content: "Attached `/synthetic/transcript notes.pdf`", timestamp: 0 });
+ fs.writeFileSync(path.join(home, "data", "chats", `${record.id}.json`), JSON.stringify({ version: 1, title: `Synthetic ${record.id}`, createdAt: "2026-10-28T00:00:00.000Z", updatedAt: "2026-10-28T00:00:00.000Z", ...record }));
+ }
+}
+async function foldDock(target, barSelector) {
+ // Let growth/geometry muting settle, then use a real wheel gesture so
+ // stick-to-bottom records user intent instead of a programmatic clamp.
+ // The explicit reveal owns a bounded 2s fold-suppression window.
+ await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 2250)));
+ const log = await target.$('[role="log"]'); const box = await log.boundingBox();
+ await target.mouse.move(box.x + box.width / 2, box.y + 100);
+ // Multiple real wheel frames let stick-to-bottom publish isAtBottom
+ // before DockOverlay evaluates the next away-from-bottom scroll event.
+ for (const deltaY of [-420, -420, -840]) {
+ await target.mouse.wheel({ deltaY });
+ await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 100)));
+ }
+ try { await target.waitForSelector(barSelector, { visible: true, timeout: 5000 }); return true; }
+ catch {
+ coverageFailures.push({ url: target.url(), case: barSelector, message: "Native wheel did not expose collapsed bar", scrollers: await target.evaluate(() => [...document.querySelectorAll('[role="log"], [role="log"] *')].filter(element => element.scrollHeight > element.clientHeight + 100 && getComputedStyle(element).overflowY !== "visible").map(element => ({ tag: element.tagName, className: element.className, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }))) });
+ return false;
+ }
+}
+
+try {
+ fs.mkdirSync(path.join(home, "data", "chats"), { recursive: true });
+ fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
+ seedChatFixtures();
+ const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
+ const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
+ const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`]));
+ fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
+ const port = await freePort(); origin = `http://127.0.0.1:${port}`;
+ const env = { PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs") };
+ const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); children.push(server);
+ const captureServer = data => { serverLogs = (serverLogs + data).slice(-28000); };
+ server.stdout.on("data", captureServer); server.stderr.on("data", captureServer);
+ await startup(server, () => serverLogs, /Ready in/);
+ if (!process.argv.includes("--desktop-only")) {
+ const chrome = await puppeteer.launch({ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, userDataDir: path.join(home, "chrome-profile"), env, args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"] }); browsers.push(chrome);
+ page = await chrome.newPage(); await page.setViewport({ width: 1280, height: 900 }); await instrument(page);
+ await chrome.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
+ const token = "`/synthetic/course notes.pdf`";
+ for (const route of ["/", "/chat/new"]) {
+ await open(page, route);
+ // Sidebar drops prevent browser navigation but must not stage paths or
+ // activate the right-content attachment indicator.
+ assert.equal(await page.$eval("aside", sidebar => {
+ const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "sidebar.pdf"));
+ const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); sidebar.dispatchEvent(event); return event.defaultPrevented;
+ }), true);
+ assert.equal(await page.$('[data-testid="file-path-drop-indicator"]'), null);
+ await expectValue(page, "");
+ // Browser mode has no bridge: prevent file navigation with a clear notice.
+ const prevented = await page.$eval('[data-testid="file-path-drop-zone"]', zone => {
+ const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "notes.pdf"));
+ const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); zone.dispatchEvent(event); return event.defaultPrevented;
+ });
+ assert.equal(prevented, true);
+ await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("desktop app"));
+ await fill(page, "read "); await page.keyboard.type(token + " now");
+ await expectValue(page, `read ${token} now`);
+ assert.equal(await page.$$eval('[data-file-path-input] [role="img"]', elements => elements.length), 1);
+ // Native browser arrows cross the atomic host without entering its label.
+ await caret(page, 5); await page.keyboard.press("ArrowRight");
+ assert.equal(await page.evaluate(() => !!document.getSelection().anchorNode?.parentElement?.closest('[role="img"]')), false);
+ await page.keyboard.press("Backspace"); await expectValue(page, "read now");
+ await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
+ await shortcut(page, "KeyZ", true); await expectValue(page, "read now");
+ await shortcut(page, "KeyZ"); await caret(page, 5); await page.keyboard.press("Delete"); await expectValue(page, "read now");
+ await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
+ // Select the actual rendered label; copy must emit the full source token.
+ await page.$eval('[data-file-path-input] [role="img"] span', label => {
+ const range = document.createRange(); range.selectNodeContents(label); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
+ });
+ await clipboardCommand(page, "copy");
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token);
+ await clipboardCommand(page, "cut", "Delete", 8);
+ await expectValue(page, "read now");
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token, "Shift+Delete is native cut, not editor-only deletion");
+ await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
+ // Clipboard image-only input must not erase the current selected path.
+ await page.$eval('[data-file-path-input]', editor => {
+ const host = editor.querySelector('[data-file-path-source]'); const range = document.createRange(); range.selectNode(host); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
+ const data = new DataTransfer(); data.items.add(new File(["image fixture"], "image.png", { type: "image/png" }));
+ editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
+ });
+ await expectValue(page, `read ${token} now`);
+ // Pasting HTML-bearing clipboard content is plain text, at the range.
+ await page.evaluate(() => navigator.clipboard.writeText('
\n中'));
+ await clipboardCommand(page, "paste");
+ await expectValue(page, 'read
\n中 now');
+ assert.equal(await page.$('[data-file-path-input] img'), null);
+ await fill(page, "readthis"); await caret(page, 4);
+ assert.equal(await syntheticDrop(page, ["/synthetic/one.pdf", "~/two`final.pdf"]), true);
+ await expectValue(page, "read `/synthetic/one.pdf` ``~/two`final.pdf`` this");
+ assert.equal(posts.length, 0, "typing/paste/drop must never submit");
+ await fill(page, "one visible line"); await caret(page, "one visible line".length);
+ const beforeNewline = await page.$eval(inputSelector, element => element.getBoundingClientRect().height);
+ await page.keyboard.down("Shift"); await page.keyboard.press("Enter"); await page.keyboard.up("Shift");
+ await expectValue(page, "one visible line\n");
+ const afterNewline = await page.$eval(inputSelector, element => {
+ const range = document.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect();
+ return { height: element.getBoundingClientRect().height, caret: { top: rect.top, height: rect.height }, html: element.innerHTML };
+ });
+ // Record this independently so a visual editor regression does not hide
+ // the higher-priority actual Electron/target-routing evidence below.
+ try { assert.ok(afterNewline.height > beforeNewline, `Trailing newline must occupy a visible blank line: ${JSON.stringify({ beforeNewline, afterNewline })}`); }
+ catch (error) { visualFailures.push({ route, message: error.message }); }
+ await page.keyboard.type("second line");
+ const afterTyping = await read(page);
+ try { assert.equal(afterTyping, "one visible line\nsecond line", "Typing after Shift+Enter must stay after the newline"); }
+ catch (error) { visualFailures.push({ route, message: error.message }); }
+ assert.equal(posts.length, 0, "Shift+Enter is a newline, never submit");
+ await fill(page, "composer fallback"); await blur(page);
+ await syntheticDrop(page, ["/synthetic/default.pdf"]);
+ await expectValue(page, "composer fallback `/synthetic/default.pdf`");
+ // Small viewport: wrapped multiline content stays scrollable inside its editor.
+ await page.setViewport({ width: 900, height: 520 });
+ await fill(page, (`${token} a long synthetic line wrapping at narrow widths.\n`).repeat(24));
+ const sizing = await page.$eval(inputSelector, element => ({ height: element.clientHeight, scrollHeight: element.scrollHeight, overflowY: getComputedStyle(element).overflowY, bodyWidth: document.body.scrollWidth, viewport: innerWidth }));
+ assert.ok(sizing.scrollHeight > sizing.height && sizing.height > 0, JSON.stringify(sizing));
+ assert.ok(["auto", "scroll"].includes(sizing.overflowY));
+ assert.ok(sizing.bodyWidth <= sizing.viewport + 1, JSON.stringify(sizing));
+ const screenshot = path.join(home, `chrome-${route === "/" ? "home" : "new"}.png`); await page.screenshot({ path: screenshot }); screenshots.push({ surface: route, bytes: fs.statSync(screenshot).size, sizing });
+ await page.setViewport({ width: 1280, height: 900 });
+ checks.push(`${route}: native typing/chips/arrows/deletion/undo-redo/full-source clipboard/plaintext paste/caret drop/newlines/wrap-scroll`);
+ }
+ // Only an explicit Enter may initiate a send; agent endpoint is refused.
+ await fill(page, "explicit synthetic send `/synthetic/one.pdf`");
+ await page.keyboard.press("Enter");
+ await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
+ await new Promise((resolve, reject) => {
+ const deadline = Date.now() + 15000;
+ const inspect = () => { if (posts.length) resolve(); else if (Date.now() > deadline) reject(new Error("Explicit Enter did not send")); else setTimeout(inspect, 50); }; inspect();
+ });
+ assert.equal(posts.length, 1); assert.equal(posts[0].body.input.text, "explicit synthetic send `/synthetic/one.pdf`");
+ checks.push("Explicit Enter submitted exactly one full-source prompt; endpoint intercepted/refused");
+
+ // These raw JSON fixtures are synthetic native records, never store imports.
+ const reading = '[data-testid="reading-thinking-field"] [data-file-path-input]';
+ const question = '[data-testid="thinking-field"] [data-file-path-input]';
+ const practice = '[data-testid="practice-thinking-field"] [data-file-path-input]';
+ await open(page, "/chat/path_teach");
+ await page.waitForSelector('[data-testid="teach-check-reveal"]', { visible: true });
+ assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.title), "/synthetic/transcript notes.pdf");
+ assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.textContent), "transcript notes.pdf");
+ checks.push("Seeded sent learner message renders a filename chip with full-path title; raw record retains quoted full path");
+ await blur(page); await syntheticDrop(page, ["/synthetic/reading.pdf"]);
+ await expectValue(page, "`/synthetic/reading.pdf`", reading);
+ assert.equal(await read(page, question), "");
+ checks.push("Unrevealed teaching check: unfocused drop lands in reading-thinking via dictation registry");
+ await page.locator('[data-testid="teach-check-reveal"]').click();
+ await page.waitForSelector(question, { visible: true });
+ await fill(page, "AB", reading); await caret(page, 1, reading);
+ await syntheticDrop(page, ["/synthetic/focused-reading.pdf"]);
+ await expectValue(page, "A `/synthetic/focused-reading.pdf` B", reading);
+ assert.equal(await read(page, question), "", "focused reading field overrides opened question default");
+ await blur(page); await syntheticDrop(page, ["/synthetic/question.pdf"]);
+ await expectValue(page, "`/synthetic/question.pdf`", question);
+ checks.push("Opened teaching check: current reading focus wins; otherwise default routes to question-thinking");
+ if (await foldDock(page, '[data-testid="question-dock-bar"]')) {
+ await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-question.pdf"]);
+ await expectValue(page, "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`", question);
+ await page.locator('[data-testid="question-dock-bar"]').click();
+ await page.waitForSelector(question, { visible: true });
+ assert.equal(await read(page, question), "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`");
+ checks.push("Collapsed revealed teaching dock keeps question-thinking claim/state through expansion");
+ }
+ await open(page, "/chat/path_probe"); await page.waitForSelector(question, { visible: true });
+ await blur(page); await syntheticDrop(page, ["/synthetic/probe.pdf"]);
+ await expectValue(page, "`/synthetic/probe.pdf`", question);
+ checks.push("Probe-phase pending question default routes to question-thinking");
+ await open(page, "/chat/path_practice"); await page.waitForSelector(practice, { visible: true });
+ await blur(page); await syntheticDrop(page, ["/synthetic/practice.pdf"]);
+ await expectValue(page, "`/synthetic/practice.pdf`", practice);
+ checks.push("Expanded practice thinking is the unfocused drop default");
+ if (await foldDock(page, '[data-testid="practice-dock-bar"]')) {
+ await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]);
+ // Practice keeps the owning hook/state mounted, but hides its field
+ // by returning the bar. Inspect the controlled text after expansion.
+ assert.equal(await page.$('[role="status"]'), null, "successful attachment has no confirmation bubble");
+ await page.locator('[data-testid="practice-dock-bar"]').click();
+ await page.waitForSelector(practice, { visible: true });
+ await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice);
+ checks.push("Collapsed practice thinking keeps its claim/state through expansion without submission");
+ }
+ assert.equal(posts.length, 1, "all claim/drop cases stage text only");
+
+ }
+ // Actual Electron main + actual sandboxed preload. Temporary launcher sets
+ // userData BEFORE production main appends its -dev suffix or takes a lock.
+ const debugPort = await freePort();
+ const launcher = path.join(home, "electron-launcher.cjs");
+ fs.mkdirSync(path.join(home, "desktop-profile"), { recursive: true });
+ fs.mkdirSync(path.join(home, "desktop-profile-dev"), { recursive: true });
+ fs.writeFileSync(launcher, `const {app,session}=require('electron');\napp.setPath('home',${JSON.stringify(home)});\napp.setPath('userData',${JSON.stringify(path.join(home, "desktop-profile"))});\napp.commandLine.appendSwitch('remote-debugging-port',${JSON.stringify(String(debugPort))});\napp.commandLine.appendSwitch('disable-background-networking');\napp.commandLine.appendSwitch('disable-sync');\napp.whenReady().then(()=>session.defaultSession.webRequest.onBeforeRequest((details,cb)=>{let u;try{u=new URL(details.url)}catch{return cb({cancel:true})}cb({cancel:!(${JSON.stringify(origin)}===u.origin||(u.protocol==='ws:'&&u.host===${JSON.stringify(new URL(origin).host)})||['about:','data:','devtools:'].includes(u.protocol))});}));\nrequire(${JSON.stringify(path.join(root, "electron", "main.cjs"))});\nconsole.log('LH_FILE_PATH_PROFILE '+JSON.stringify({home:app.getPath('home'),userData:app.getPath('userData')}));\n`);
+ const electron = spawn(require("electron"), [launcher], { cwd: root, env: { ...env, LH_DEV_URL: origin }, stdio: ["ignore", "pipe", "pipe"] }); children.push(electron);
+ const captureElectron = data => { electronLogs = (electronLogs + data).slice(-24000); }; electron.stdout.on("data", captureElectron); electron.stderr.on("data", captureElectron);
+ await startup(electron, () => electronLogs, /DevTools listening on (ws:\/\/[^\s]+)/, 45000);
+ const profile = JSON.parse(electronLogs.match(/LH_FILE_PATH_PROFILE ([^\r\n]+)/)[1]);
+ assert.equal(profile.home, home); assert.equal(profile.userData, path.join(home, "desktop-profile-dev"));
+ const desktopLaunch = { executable: require("electron"), args: [launcher], LH_DEV_URL: origin, HOME: home, userDataBeforeMain: path.join(home, "desktop-profile"), actualProfile: profile, cleanup: "all temporary profiles/data/fixtures removed on exit" };
+ const endpoint = electronLogs.match(/DevTools listening on (ws:\/\/[^\s]+)/)[1];
+ const desktop = await puppeteer.connect({ browserWSEndpoint: endpoint }); browsers.push(desktop);
+ const desktopPage = await desktop.waitForTarget(target => target.type() === "page").then(target => target.page());
+ await instrument(desktopPage); page = desktopPage;
+ await open(desktopPage, "/chat/new");
+ assert.equal(await desktopPage.evaluate(() => typeof window.lh?.getPathForFile), "function");
+ const fixturePath = path.join(home, "actual OS-backed α `notes`.pdf"); fs.writeFileSync(fixturePath, "%PDF-1.7\nsynthetic offline fixture\n");
+ await desktopPage.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "os-backed-fixture"; document.body.append(input); });
+ await (await desktopPage.$("#os-backed-fixture")).uploadFile(fixturePath);
+ const resolved = await desktopPage.evaluate(() => {
+ const input = document.querySelector("#os-backed-fixture");
+ return { count: input.files.length, actual: window.lh.getPathForFile(input.files[0]), virtual: window.lh.getPathForFile(new File(["virtual"], "/not/a/backing/path.pdf")) };
+ });
+ assert.equal(resolved.count, 1); assert.equal(resolved.actual, fixturePath); assert.equal(resolved.virtual, "");
+ await fill(desktopPage, "readthis"); await caret(desktopPage, 4);
+ const beforeDesktopDrop = posts.length;
+ assert.equal(await desktopPage.evaluate(() => {
+ const file = document.querySelector("#os-backed-fixture").files[0];
+ for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("No file content read"); } });
+ const transfer = new DataTransfer(); transfer.items.add(file);
+ const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
+ document.querySelector('[data-testid="file-path-drop-zone"]').dispatchEvent(event); return event.defaultPrevented;
+ }), true);
+ const expectedQuoted = "``" + fixturePath + "``";
+ await expectValue(desktopPage, `read ${expectedQuoted} this`);
+ assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath);
+ assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only");
+ assert.equal(await desktopPage.$('[role="status"]'), null, "successful desktop attachment has no confirmation bubble");
+ assert.ok(fs.existsSync(fixturePath));
+ assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []);
+ checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads");
+ assert.deepEqual(errors, []);
+ console.log(JSON.stringify({ checks, visualFailures, coverageFailures, screenshots, desktopLaunch, postsIntercepted: posts.length, blockedExternalRequests: blocked, isolation: "temporary HOME/data/auth/browser+Electron profiles; synthetic catalog; agent POSTs refused; no providers", origin }, null, 2));
+ assert.deepEqual(visualFailures, [], "Visible trailing-newline/caret projection must pass");
+ assert.deepEqual(coverageFailures, [], "Required collapsed target coverage must run");
+} catch (error) {
+ console.error(error);
+ console.error(JSON.stringify({ checks, visualFailures, coverageFailures, errors, consoleErrors, networkFailures: networkFailures.slice(-30), posts, origin, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(element => ({ placeholder: element.getAttribute("data-placeholder"), text: element.textContent, html: element.innerHTML.slice(0, 1200) })), text: document.body.innerText.slice(-2600) })).catch(() => null), serverLogs, electronLogs }, null, 2));
+ process.exitCode = 1;
+} finally {
+ shuttingDown = true;
+ for (const browser of browsers.reverse()) { try { await browser.close(); } catch {} }
+ for (const child of children.reverse()) await stop(child);
+ fs.rmSync(home, { recursive: true, force: true });
+}
diff --git a/scripts/smoke-image-runtime.mjs b/scripts/smoke-image-runtime.mjs
new file mode 100644
index 0000000..c99e18d
--- /dev/null
+++ b/scripts/smoke-image-runtime.mjs
@@ -0,0 +1,228 @@
+/**
+ * OFFLINE image runtime gate: exercise the shipped Photon/WASM resize core and
+ * actual worker, with file modules confined to the standalone payload. No app,
+ * provider, canvas, or network needed; a tiny synthetic PNG is generated here.
+ *
+ * node scripts/smoke-image-runtime.mjs [--standalone ] [--runtime ]
+ */
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs";
+import { createRequire } from "node:module";
+import * as os from "node:os";
+import * as path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { Worker } from "node:worker_threads";
+import { deflateSync } from "node:zlib";
+import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
+
+export function verifyImageRuntime(standalone, runtime = process.execPath) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-image-runtime-"));
+ try {
+ fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
+ const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], {
+ cwd: home,
+ env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
+ encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 128 * 1024,
+ });
+ if (result.status !== 0 || result.error) {
+ throw new Error(`image runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
+ }
+ assert.match(result.stdout, /Photon in-process and worker resizing passed/, "probe exited without success marker");
+ process.stdout.write(result.stdout);
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
+}
+
+// Self-contained so the eval worker can install the SAME guards using only
+// builtins, before importing any payload module. Module hooks do not otherwise
+// propagate from the dedicated probe process into Worker threads.
+function installGuards(require, standalone) {
+ const assert = require("node:assert/strict");
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const { fileURLToPath } = require("node:url");
+ const { registerHooks, syncBuiltinESMExports } = require("node:module");
+ const root = fs.realpathSync(standalone);
+ const inside = (file) => {
+ const real = fs.realpathSync(file);
+ const relative = path.relative(root, real);
+ assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
+ `dependency escaped standalone payload: ${file}`);
+ return real;
+ };
+ const photonRoot = path.join(root, "node_modules/@silvia-odwyer/photon-node");
+ const manifest = JSON.parse(fs.readFileSync(inside(path.join(photonRoot, "package.json")), "utf8"));
+ assert.equal(manifest.version, "0.3.4", "unexpected Photon engine version");
+ const photonEntry = inside(path.join(photonRoot, "photon_rs.js"));
+ const photonWasm = inside(path.join(photonRoot, "photon_rs_bg.wasm"));
+ assert.ok(fs.statSync(photonWasm).size > 0, "empty Photon WASM asset");
+ let attempts = 0;
+ const deny = () => { attempts++; throw new Error("OFFLINE image gate: network forbidden"); };
+ globalThis.fetch = deny;
+ globalThis.WebSocket = class { constructor() { deny(); } };
+ for (const [name, methods] of [
+ ["http", ["request", "get"]], ["https", ["request", "get"]],
+ ["net", ["connect", "createConnection"]], ["tls", ["connect"]],
+ ["dgram", ["createSocket"]],
+ ["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
+ ["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
+ ]) {
+ const module = require(`node:${name}`);
+ for (const method of methods) module[method] = deny;
+ }
+ require("node:net").Socket.prototype.connect = deny;
+ const loaded = new Set();
+ const wasmReads = new Set();
+ const originalRead = fs.readFileSync;
+ fs.readFileSync = function (file, ...args) {
+ const filename = file instanceof URL ? fileURLToPath(file) : file;
+ const wasmFile = typeof filename === "string" && filename.endsWith(".wasm") ? inside(filename) : null;
+ const bytes = originalRead.call(this, file, ...args);
+ if (wasmFile) wasmReads.add(wasmFile);
+ return bytes;
+ };
+ syncBuiltinESMExports();
+ const hooks = registerHooks({
+ resolve(specifier, context, nextResolve) {
+ const result = nextResolve(specifier, context);
+ if (result.url.startsWith("file:")) loaded.add(inside(fileURLToPath(result.url)));
+ else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
+ return result;
+ },
+ });
+ return {
+ report() {
+ assert.equal(attempts, 0, "image runtime attempted network access");
+ assert.ok(loaded.has(photonEntry), "Photon JS was not loaded from the payload");
+ assert.ok(wasmReads.has(photonWasm), "Photon WASM was not loaded from the payload");
+ return { modules: loaded.size, wasmFiles: [...wasmReads], networkAttempts: attempts };
+ },
+ cleanup() {
+ hooks.deregister();
+ fs.readFileSync = originalRead;
+ syncBuiltinESMExports();
+ },
+ };
+}
+
+/** Pure Node 2×2 RGBA PNG, with valid PNG CRCs and zlib image data. */
+function fixturePng() {
+ const chunk = (type, data) => {
+ const buffer = Buffer.alloc(data.length + 12);
+ buffer.writeUInt32BE(data.length);
+ buffer.write(type, 4, "ascii");
+ data.copy(buffer, 8);
+ let crc = ~0;
+ for (const byte of buffer.subarray(4, -4)) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ buffer.writeUInt32BE(~crc >>> 0, buffer.length - 4);
+ return buffer;
+ };
+ const header = Buffer.alloc(13);
+ header.writeUInt32BE(2, 0);
+ header.writeUInt32BE(2, 4);
+ header[8] = 8; // bit depth
+ header[9] = 6; // RGBA
+ const pixels = Buffer.from([
+ 0, 255, 0, 0, 255, 0, 255, 0, 255,
+ 0, 0, 0, 255, 255, 255, 255, 255, 255,
+ ]);
+ return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]);
+}
+
+function assertResized(result) {
+ assert.ok(result, "resize returned null (missing/broken Photon must not silently pass)");
+ assert.equal(result.originalWidth, 2);
+ assert.equal(result.originalHeight, 2);
+ assert.equal(result.width, 1);
+ assert.equal(result.height, 1);
+ assert.equal(result.wasResized, true);
+ assert.equal(result.mimeType, "image/png");
+ const png = Buffer.from(result.data, "base64");
+ assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
+ assert.equal(png.readUInt32BE(16), 1);
+ assert.equal(png.readUInt32BE(20), 1);
+}
+
+async function probeWorker(root, inputBytes, options, responseType) {
+ const readyType = "lh:image-runtime-ready";
+ const worker = new Worker(`
+ const { parentPort, workerData } = require("node:worker_threads");
+ const guards = (${installGuards.toString()})(require, workerData.root);
+ const post = parentPort.postMessage.bind(parentPort);
+ parentPort.postMessage = (message, ...args) => {
+ if (message?.type === workerData.responseType) {
+ post({ ...message, runtimeProof: guards.report() }, ...args);
+ } else post(message, ...args);
+ };
+ void import(workerData.entry).then(() => post({ type: workerData.readyType }));
+ `, {
+ eval: true,
+ workerData: { root, responseType, readyType, entry: pathToFileURL(path.join(root, "src/agent/image-processing/resize-worker.mjs")).href },
+ });
+ try {
+ return await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("image resize worker timed out")), 10_000);
+ const fail = (error) => { clearTimeout(timer); reject(error); };
+ let ready = false;
+ worker.once("error", fail);
+ worker.once("exit", (code) => fail(new Error(`image resize worker exited before response: ${code}`)));
+ worker.on("message", (message) => {
+ try {
+ if (message?.type === readyType) {
+ assert.equal(ready, false, "duplicate worker READY");
+ ready = true;
+ worker.postMessage({ inputBytes, mimeType: "image/png", options });
+ } else if (message?.type === responseType) {
+ assert.equal(ready, true, "worker replied before its import completed");
+ assert.equal(message.error, undefined, `worker resize failed: ${message.error}`);
+ assertResized(message.result);
+ assert.ok(message.runtimeProof?.modules > 0, "missing worker module-confinement proof");
+ assert.equal(message.runtimeProof.networkAttempts, 0);
+ assert.equal(message.runtimeProof.wasmFiles.length, 1);
+ clearTimeout(timer);
+ resolve(message.runtimeProof);
+ }
+ } catch (error) { fail(error); }
+ });
+ });
+ } finally { await worker.terminate(); }
+}
+
+async function probe(standalone) {
+ const root = fs.realpathSync(standalone);
+ const guards = installGuards(createRequire(import.meta.url), root);
+ try {
+ const entry = pathToFileURL(path.join(root, "src/agent/image-processing/resize-core.mjs"));
+ const { resizeImageInProcess, IMAGE_RESIZE_WORKER_RESPONSE_TYPE } = await import(entry.href);
+ assert.equal(typeof resizeImageInProcess, "function");
+ assert.equal(IMAGE_RESIZE_WORKER_RESPONSE_TYPE, "pi:image-resize-response");
+ const input = fixturePng();
+ const options = { maxWidth: 1, maxHeight: 1, maxBytes: 1024 * 1024 };
+ assertResized(await resizeImageInProcess(input, "image/png", options));
+ const inProcess = guards.report();
+ console.log(`[smoke-image-runtime] shipped resize core: PNG 2×2 → 1×1; ${inProcess.modules} modules and Photon WASM confined to ${root}`);
+ const worker = await probeWorker(root, input, options, IMAGE_RESIZE_WORKER_RESPONSE_TYPE);
+ guards.report();
+ console.log(`[smoke-image-runtime] shipped resize worker: READY then tagged PNG 2×2 → 1×1; ${worker.modules} modules and Photon WASM confined to ${root}`);
+ console.log("[smoke-image-runtime] Photon in-process and worker resizing passed; zero network attempts");
+ } finally { guards.cleanup(); }
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ if (args[0] === "--probe") return probe(args[1]);
+ const value = (name, fallback) => {
+ const index = args.indexOf(name);
+ if (index < 0) return fallback;
+ assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
+ return path.resolve(args[index + 1]);
+ };
+ verifyImageRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => { console.error(`[smoke-image-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
+}
diff --git a/scripts/smoke-opening-prompt.mjs b/scripts/smoke-opening-prompt.mjs
new file mode 100644
index 0000000..f5266c4
--- /dev/null
+++ b/scripts/smoke-opening-prompt.mjs
@@ -0,0 +1,254 @@
+/** Focused synthetic live-UI check. No model/provider calls: /api/chat is
+ * intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and
+ * the browser uses a fresh profile. Creates only synthetic session records.
+ * Run: node scripts/smoke-opening-prompt.mjs
+ */
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import fs from "node:fs";
+import net from "node:net";
+import { createRequire } from "node:module";
+import os from "node:os";
+import path from "node:path";
+import puppeteer from "puppeteer-core";
+
+const root = path.resolve(import.meta.dirname, "..");
+const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-"));
+fs.mkdirSync(path.join(home, "data"));
+fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
+// Next's supported font fixture avoids external font downloads in this check.
+const require = createRequire(import.meta.url);
+const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
+const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
+const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [
+ getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"),
+ `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`,
+]));
+fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
+const listener = net.createServer();
+listener.listen(0, "127.0.0.1");
+await once(listener, "listening");
+const port = listener.address().port;
+await new Promise(resolve => listener.close(resolve));
+const origin = `http://127.0.0.1:${port}`;
+const env = {
+ PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development",
+ LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
+ LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1",
+ NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"),
+};
+const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], {
+ cwd: root, env, stdio: ["ignore", "pipe", "pipe"],
+});
+let logs = "";
+const capture = data => { logs = (logs + data).slice(-24000); };
+server.stdout.on("data", capture); server.stderr.on("data", capture);
+let browser;
+let page;
+const errors = [];
+const checks = [];
+try {
+ await new Promise((resolve, reject) => {
+ const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000);
+ const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } };
+ server.stdout.on("data", ready);
+ server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); });
+ ready();
+ });
+ browser = await puppeteer.launch({
+ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ headless: true, userDataDir: path.join(home, "browser"), env,
+ args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"],
+ });
+ page = await browser.newPage();
+ page.setDefaultTimeout(45000);
+ page.on("pageerror", err => errors.push(err.message));
+ const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
+ const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
+ const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null,
+ slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
+ const posts = [];
+ const creates = [];
+ const accepted = new Map();
+ let mode = "accepted";
+ let held = [];
+ await page.setRequestInterception(true);
+ page.on("request", async request => {
+ try {
+ const url = new URL(request.url());
+ if (url.origin !== origin) { await request.abort(); return; }
+ const pathname = url.pathname;
+ const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
+ if (pathname === "/api/models") return await respond(catalog);
+ // Notifications are outside this handover check. 204 tells native
+ // EventSource not to reconnect or hold a test connection open.
+ if (pathname === "/api/events") return await request.respond({ status: 204 });
+ if (pathname === "/api/chat") {
+ const body = JSON.parse(request.postData()); posts.push(body);
+ if (mode === "held-post") { held.push(request); return; }
+ if (mode === "lost-ack") return await request.abort("failed");
+ if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
+ accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]);
+ return await respond({ ok: true });
+ }
+ if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) {
+ // Keep teacher delivery unfinished: cleanup must NOT wait for it.
+ held.push(request); return;
+ }
+ const match = pathname.match(/^\/api\/chats\/([^/]+)$/);
+ if (match && request.method() === "GET" && accepted.has(match[1])) {
+ return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false });
+ }
+ if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData()));
+ await request.continue();
+ } catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); }
+ });
+ async function open(route) {
+ // CDP-intercepted requests do not settle themselves on navigation.
+ // Release synthetic streams from prior cases rather than pin slots.
+ await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
+ await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
+ await page.waitForSelector(route === "/" ? "[data-file-path-input]" : '[data-testid="composer"] [data-file-path-input]');
+ await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
+ }
+ async function fill(text) {
+ await page.evaluate(value => {
+ const input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
+ input.textContent = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }, text);
+ }
+ async function submit() {
+ await page.waitForFunction(() => {
+ const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]');
+ return button && !button.disabled;
+ });
+ await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
+ }
+ async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); }
+ async function assertDraft(text) { await page.waitForFunction(value => {
+ const input = document.querySelector('[data-testid="composer"] [data-file-path-input]');
+ return input && [...input.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === value;
+ }, {}, text); }
+ const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
+ for (const route of ["/", "/chat/new"]) {
+ for (const text of ["a short synthetic topic", long]) {
+ await open(route);
+ const count = posts.length;
+ await fill(text); await submit();
+ await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search);
+ await page.waitForFunction(() => document.querySelector('[data-testid="composer"]'));
+ await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
+ assert.equal(posts.length, count + 1);
+ assert.equal(posts.at(-1).input.text, text);
+ assert.equal(new URL(page.url()).search, "");
+ assert.equal(await stash(posts.at(-1).chatId), null);
+ checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`);
+ }
+ }
+ // The old long-query mechanism really is rejected by this isolated Node server.
+ const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`);
+ assert.equal(oldResponse.status, 431); await oldResponse.text();
+ checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)");
+
+ for (const route of ["/", "/chat/new"]) {
+ await open(route);
+ await page.evaluate(() => {
+ const setItem = Storage.prototype.setItem;
+ Storage.prototype.setItem = function(key, value) {
+ if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError");
+ return setItem.call(this, key, value);
+ };
+ window.failOpeningStorage = true;
+ });
+ const createCount = creates.length; const sendCount = posts.length;
+ await fill(long); await submit();
+ await page.waitForSelector('[role="alert"]');
+ assert.equal(new URL(page.url()).pathname, route);
+ assert.equal(await page.$eval("[data-file-path-input]", element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("")), long);
+ assert.equal(posts.length, sendCount);
+ assert.equal(creates.length, createCount + 1);
+ await page.evaluate(() => { window.failOpeningStorage = false; });
+ await submit();
+ await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
+ await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
+ assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation");
+ assert.equal(posts.at(-1).input.text, long);
+ checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`);
+ }
+
+ for (const failure of ["lost-ack", "refused", "held-post"]) {
+ mode = failure;
+ await open("/chat/new");
+ const text = `recover interrupted ${failure}`;
+ await fill(text); await submit();
+ await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
+ await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true')));
+ if (failure !== "held-post") await assertDraft(text);
+ const count = posts.length;
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await assertDraft(text);
+ assert.equal(posts.length, count, "interrupted start must not automatically repost");
+ checks.push(`${failure}: reload restored full draft, no automatic repost`);
+ if (failure === "lost-ack") {
+ mode = "accepted";
+ await submit();
+ const id = new URL(page.url()).pathname.split("/").at(-1);
+ await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id);
+ assert.equal(posts.length, count + 1);
+ assert.equal(posts.at(-1).input.text, text);
+ checks.push("restored draft sent only after explicit click and cleaned up on acceptance");
+ }
+ }
+ mode = "accepted";
+ // Marker-only session must still automatically deliver a fresh opening.
+ await open("/chat/new");
+ const markerId = await page.evaluate(async () => {
+ const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) });
+ return (await res.json()).id;
+ });
+ await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId);
+ const markerCount = posts.length;
+ await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
+ assert.equal(posts.length, markerCount + 1);
+ assert.equal(posts.at(-1).input.text, "marker opening");
+ checks.push("graph-marker-only record still receives opening text");
+ // A real persisted user prevents delivery and cleans a stale copy on mount.
+ const chatFile = path.join(home, "data", "chats", `${markerId}.json`);
+ const record = JSON.parse(fs.readFileSync(chatFile, "utf8"));
+ record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() });
+ fs.writeFileSync(chatFile, JSON.stringify(record));
+ await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId);
+ const completedCount = posts.length;
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
+ assert.equal(posts.length, completedCount);
+ checks.push("persisted real conversation removed stale stash without reposting");
+ await open("/chat/new");
+ const legacyId = await page.evaluate(async () => {
+ const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) });
+ return (await response.json()).id;
+ });
+ const legacyCount = posts.length;
+ await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.body.innerText.includes("legacy opening"));
+ await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId);
+ assert.equal(posts.length, legacyCount + 1);
+ assert.equal(posts.at(-1).input.text, "legacy opening");
+ checks.push("legacy short ?prompt link still sends its complete opening message");
+ assert.deepEqual(errors, []);
+ console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
+} catch (err) {
+ console.error(err);
+ console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(e => ({ length: [...e.childNodes].map(n => n.nodeType === 1 ? n.getAttribute('data-file-path-source') ?? n.textContent : n.textContent).join('').length, placeholder: e.getAttribute('data-placeholder') })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
+ console.error(logs); process.exitCode = 1;
+} finally {
+ await browser?.close();
+ const exited = once(server, "exit");
+ server.kill("SIGTERM");
+ const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref();
+ await exited; clearTimeout(killTimer);
+ fs.rmSync(home, { recursive: true, force: true });
+}
diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs
index 47c12c6..eb63e4f 100644
--- a/scripts/smoke-provider-runtime.mjs
+++ b/scripts/smoke-provider-runtime.mjs
@@ -1,7 +1,7 @@
/**
- * OFFLINE native Pi Google initialization gate (no generation or provider calls).
- * Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI.
- * The real onPayload hook throws a unique sentinel BEFORE generateContentStream.
+ * OFFLINE native Pi Google/Bedrock initialization gate (no generation or provider calls).
+ * Unlike /api/models, this traverses Pi's lazy adapters and constructs real SDK clients.
+ * The real onPayload hook throws a unique sentinel BEFORE SDK request dispatch.
* All file modules must resolve inside the payload, including nested dependencies.
*
* node scripts/smoke-provider-runtime.mjs [--standalone ] [--runtime ]
@@ -80,18 +80,31 @@ async function probe(standalone) {
// factories, never the checkout's module or a direct/fake Google adapter.
const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
const { createModels } = await import(entry.href);
+ // Keep every native chat implementation evaluable in the standalone
+ // payload even though the custom-provider UI intentionally exposes one API.
+ for (const api of ["anthropic-messages", "openai-completions", "openai-responses", "openai-codex-responses", "azure-openai-responses", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "mistral-conversations", "pi-messages"]) {
+ const implementation = await import(new URL(`api/${api}.js`, entry).href);
+ assert.equal(typeof implementation.streamSimple, "function", `${api}: native implementation missing`);
+ }
+ noNetwork();
+ console.log("[smoke-provider-runtime] all 10 native chat implementations evaluated offline");
const models = createModels();
- for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) {
+ for (const [name, factory, api, label] of [
+ ["google", "googleProvider", "google-generative-ai", "Google"],
+ ["opencode", "opencodeProvider", "google-generative-ai", "Google"],
+ ["opencode-go", "opencodeGoProvider", "google-generative-ai", "Google"],
+ ["amazon-bedrock", "amazonBedrockProvider", "bedrock-converse-stream", "Bedrock"],
+ ]) {
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
models.setProvider(provider);
- const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai");
- assert.ok(model, `${name}: missing Google-backed catalog model`);
+ const model = provider.getModels().find((candidate) => candidate.api === api);
+ assert.ok(model, `${name}: missing ${api} catalog model`);
const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`;
let reached = 0;
const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
apiKey: "dummy-offline-provider-gate-not-a-key",
onPayload(payload, selected) {
- assert.equal(selected.api, "google-generative-ai");
+ assert.equal(selected.api, api);
assert.ok(payload && typeof payload === "object");
reached++;
throw new Error(sentinel);
@@ -101,7 +114,7 @@ async function probe(standalone) {
assert.equal(result.stopReason, "error");
assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`);
noNetwork();
- console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
+ console.log(`[smoke-provider-runtime] ${name}: native lazy ${label} adapter reached onPayload; generation stopped`);
}
// Exercise the public lazy OAuth surface offline. toAuth evaluates the
// real flow module and its imports but neither logs in nor refreshes.
diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs
index 91b6cb5..fc67c83 100644
--- a/scripts/smoke-server.mjs
+++ b/scripts/smoke-server.mjs
@@ -27,6 +27,7 @@
import { spawn } from "node:child_process";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
+import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs";
import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
@@ -83,6 +84,8 @@ async function main() {
verifyDemoRuntime(STANDALONE, RUNTIME);
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
verifyProviderRuntime(STANDALONE, RUNTIME);
+ // A plain GET without Accept-Encoding never exercises gzip backpressure.
+ verifyCompressionRuntime(STANDALONE, RUNTIME);
const port = await freePort();
const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
diff --git a/scripts/smoke-web-subscriptions.mjs b/scripts/smoke-web-subscriptions.mjs
new file mode 100644
index 0000000..9918b65
--- /dev/null
+++ b/scripts/smoke-web-subscriptions.mjs
@@ -0,0 +1,125 @@
+/** Offline Next runtime gate (run after next build, with no dev server running).
+ * Proves cold-start registration, cross-route owner/events and dev reloads using
+ * synthetic credentials in a fresh profile. No OAuth, provider traffic or real
+ * auth/data. The dev reload changes only a source mtime, restoring it afterward.
+ * This is a test, not an alternative app launcher. */
+import assert from "node:assert/strict";
+import fs from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { pathToFileURL } from "node:url";
+import { createRequire } from "node:module";
+import { spawn } from "node:child_process";
+import { createServer } from "node:net";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const require = createRequire(import.meta.url);
+const { SCOPES } = require("../electron/chatgpt.cjs");
+const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file)));
+const { createModels } = await sdk("index.js");
+const { builtinProviders } = await sdk("providers/all.js");
+const { githubCopilotProvider } = await sdk("providers/github-copilot.js");
+const models = createModels();
+for (const provider of builtinProviders()) models.setProvider(provider);
+const model = models.getModels("openai").find((entry) => entry.api === "openai-responses");
+const copilot = githubCopilotProvider().getModels()[0];
+assert(model && copilot, "Installed SDK catalogs required.");
+
+const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-"));
+await fs.chmod(profile, 0o700);
+for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 });
+await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 });
+const preload = path.join(profile, "offline-fetch.mjs");
+await fs.writeFile(preload, `const original = globalThis.fetch;
+globalThis.fetch = async (input, init) => {
+ const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
+ if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] });
+ if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init);
+ throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname);
+};\n`, { mode: 0o600 });
+// Intentionally an allowlist, not a copy of the calling shell's environment.
+const env = {
+ PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"),
+ XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
+ PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"),
+ TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"),
+ NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1",
+};
+const saved = {
+ version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account",
+ registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }],
+};
+function timeout(promise, milliseconds, message) {
+ let timer;
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer));
+}
+async function freePort() {
+ const socket = createServer();
+ await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); });
+ const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port;
+}
+async function run(mode) {
+ await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 });
+ for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({
+ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001",
+ credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) },
+ }), { mode: 0o600 });
+ const port = await freePort(), origin = `http://localhost:${port}`;
+ let logs = "", exited = false;
+ const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
+ const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); }));
+ try {
+ await timeout(new Promise((resolve, reject) => {
+ const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); };
+ child.stdout.on("data", receive); child.stderr.on("data", receive);
+ child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`)));
+ }), 60000, `Next ${mode} readiness timeout.`);
+ const request = (url, command) => fetch(origin + url, {
+ ...(command ? { method: "POST", body: JSON.stringify(command) } : {}),
+ headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" },
+ signal: AbortSignal.timeout(60000),
+ });
+ // Controls have not been imported through an HTTP request yet.
+ const catalog = (await (await request("/api/models")).json()).providers;
+ for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`);
+ let state = await (await request("/api/subscriptions")).json();
+ assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2);
+ for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret));
+ const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) });
+ const reader = stream.body.getReader();
+ try {
+ const first = await reader.read(); assert(first.value);
+ assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200);
+ let events = new TextDecoder().decode(first.value);
+ while (!events.includes("subscriptions-changed")) {
+ const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout.");
+ assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value);
+ }
+ } finally { await reader.cancel(); }
+ state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false);
+ const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json();
+ assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com"));
+ if (mode === "dev") {
+ const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file);
+ await fs.utimes(file, new Date(), new Date());
+ try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); }
+ finally { await fs.utimes(file, times.atime, times.mtime); }
+ }
+ const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) });
+ const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`);
+ const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1");
+ assert.equal((await navigate()).status, 410);
+ assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200);
+ assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403);
+ assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200);
+ assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false);
+ console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`);
+ } catch (error) { console.error(logs); throw error; }
+ finally {
+ if (!exited) child.kill("SIGTERM");
+ try { await timeout(exit, 5000, "Next shutdown timeout."); }
+ catch { child.kill("SIGKILL"); await exit; }
+ }
+}
+try { await run("start"); await run("dev"); }
+finally { await fs.rm(profile, { recursive: true, force: true }); }
diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts
index 5412549..53d748d 100644
--- a/src/agent/chatgpt-bridge.ts
+++ b/src/agent/chatgpt-bridge.ts
@@ -1,4 +1,5 @@
import type { Model } from "@earendil-works/pi-ai";
+import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
function desktopIPCConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
+function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
if (!global[key]) {
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
global[key] = state;
- process.on("message", (raw: unknown) => {
+ const receive = (raw: unknown) => {
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
- if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
+ if (m?.channel !== "lh:chatgpt") return;
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
acceptStatus(state, { ...m.stamp, status: m.status });
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
- });
+ };
+ process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
+ onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
process.on("disconnect", () => {
+ if (webSubscriptionsEnabled()) return;
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
for (const controller of state.controllers.keys()) controller.abort("all");
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
}
-/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
+/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
export function chatGPTRequest(op: "status" | "models" | "lease", fields: Record = {}): Promise {
const state = bridge();
+ if (webSubscriptionsEnabled()) return webSubscriptionRequest("lh:chatgpt", op, fields);
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
return new Promise((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
@@ -74,7 +80,7 @@ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Rec
});
}
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
-export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
+export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
}
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
const s = bridge();
- return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
+ return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise {
const state = bridge();
- if (!desktopIPCConnected()) { clearCatalog(state); return; }
+ if (!runtimeConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest("models");
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/image-processing/LICENSE b/src/agent/image-processing/LICENSE
new file mode 100644
index 0000000..b0a8e9b
--- /dev/null
+++ b/src/agent/image-processing/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Mario Zechner
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/src/agent/image-processing/convert.d.mts b/src/agent/image-processing/convert.d.mts
new file mode 100644
index 0000000..1826e6c
--- /dev/null
+++ b/src/agent/image-processing/convert.d.mts
@@ -0,0 +1 @@
+export function convertImageBytesToPng(bytes: Uint8Array): Promise;
diff --git a/src/agent/image-processing/convert.mjs b/src/agent/image-processing/convert.mjs
new file mode 100644
index 0000000..f271633
--- /dev/null
+++ b/src/agent/image-processing/convert.mjs
@@ -0,0 +1,25 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-convert.js (MIT).
+// Only inline-image conversion is retained; terminal/Kitty registration is not used.
+import { applyExifOrientation } from "./exif-orientation.mjs";
+import { loadPhoton } from "./photon.mjs";
+
+function encodePng(photon, bytes) {
+ try {
+ const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
+ const image = applyExifOrientation(photon, rawImage, bytes);
+ if (image !== rawImage) rawImage.free();
+ try {
+ return new Uint8Array(image.get_bytes());
+ } finally {
+ image.free();
+ }
+ } catch {
+ return null;
+ }
+}
+
+export async function convertImageBytesToPng(bytes) {
+ const photon = await loadPhoton();
+ if (!photon) return null;
+ return encodePng(photon, bytes);
+}
diff --git a/src/agent/image-processing/exif-orientation.mjs b/src/agent/image-processing/exif-orientation.mjs
new file mode 100644
index 0000000..1915c7f
--- /dev/null
+++ b/src/agent/image-processing/exif-orientation.mjs
@@ -0,0 +1,157 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/exif-orientation.js (MIT).
+function readOrientationFromTiff(bytes, tiffStart) {
+ if (tiffStart + 8 > bytes.length)
+ return 1;
+ const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
+ const le = byteOrder === 0x4949;
+ const read16 = (pos) => {
+ if (le)
+ return bytes[pos] | (bytes[pos + 1] << 8);
+ return (bytes[pos] << 8) | bytes[pos + 1];
+ };
+ const read32 = (pos) => {
+ if (le)
+ return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
+ return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
+ };
+ const ifdOffset = read32(tiffStart + 4);
+ const ifdStart = tiffStart + ifdOffset;
+ if (ifdStart + 2 > bytes.length)
+ return 1;
+ const entryCount = read16(ifdStart);
+ for (let i = 0; i < entryCount; i++) {
+ const entryPos = ifdStart + 2 + i * 12;
+ if (entryPos + 12 > bytes.length)
+ return 1;
+ if (read16(entryPos) === 0x0112) {
+ const value = read16(entryPos + 8);
+ return value >= 1 && value <= 8 ? value : 1;
+ }
+ }
+ return 1;
+}
+function findJpegTiffOffset(bytes) {
+ let offset = 2;
+ while (offset < bytes.length - 1) {
+ if (bytes[offset] !== 0xff)
+ return -1;
+ const marker = bytes[offset + 1];
+ if (marker === 0xff) {
+ offset++;
+ continue;
+ }
+ if (marker === 0xe1) {
+ if (offset + 4 >= bytes.length)
+ return -1;
+ const segmentStart = offset + 4;
+ if (segmentStart + 6 > bytes.length)
+ return -1;
+ if (hasExifHeader(bytes, segmentStart))
+ return segmentStart + 6;
+ }
+ if (offset + 4 > bytes.length)
+ return -1;
+ const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
+ offset += 2 + length;
+ }
+ return -1;
+}
+function findWebpTiffOffset(bytes) {
+ let offset = 12;
+ while (offset + 8 <= bytes.length) {
+ const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
+ const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
+ const dataStart = offset + 8;
+ if (chunkId === "EXIF") {
+ if (dataStart + chunkSize > bytes.length)
+ return -1;
+ // Some WebP files have "Exif\0\0" prefix before the TIFF header
+ const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
+ return tiffStart;
+ }
+ // RIFF chunks are padded to even size
+ offset = dataStart + chunkSize + (chunkSize % 2);
+ }
+ return -1;
+}
+function hasExifHeader(bytes, offset) {
+ return (bytes[offset] === 0x45 &&
+ bytes[offset + 1] === 0x78 &&
+ bytes[offset + 2] === 0x69 &&
+ bytes[offset + 3] === 0x66 &&
+ bytes[offset + 4] === 0x00 &&
+ bytes[offset + 5] === 0x00);
+}
+function getExifOrientation(bytes) {
+ let tiffOffset = -1;
+ // JPEG: starts with FF D8
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
+ tiffOffset = findJpegTiffOffset(bytes);
+ }
+ // WebP: starts with RIFF....WEBP
+ else if (bytes.length >= 12 &&
+ bytes[0] === 0x52 &&
+ bytes[1] === 0x49 &&
+ bytes[2] === 0x46 &&
+ bytes[3] === 0x46 &&
+ bytes[8] === 0x57 &&
+ bytes[9] === 0x45 &&
+ bytes[10] === 0x42 &&
+ bytes[11] === 0x50) {
+ tiffOffset = findWebpTiffOffset(bytes);
+ }
+ if (tiffOffset === -1)
+ return 1;
+ return readOrientationFromTiff(bytes, tiffOffset);
+}
+function rotate90(photon, image, dstIndex) {
+ const w = image.get_width();
+ const h = image.get_height();
+ const src = image.get_raw_pixels();
+ const dst = new Uint8Array(src.length);
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const srcIdx = (y * w + x) * 4;
+ const dstIdx = dstIndex(x, y, w, h) * 4;
+ dst[dstIdx] = src[srcIdx];
+ dst[dstIdx + 1] = src[srcIdx + 1];
+ dst[dstIdx + 2] = src[srcIdx + 2];
+ dst[dstIdx + 3] = src[srcIdx + 3];
+ }
+ }
+ return new photon.PhotonImage(dst, h, w);
+}
+// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
+export function applyExifOrientation(photon, image, originalBytes) {
+ const orientation = getExifOrientation(originalBytes);
+ if (orientation === 1)
+ return image;
+ switch (orientation) {
+ case 2:
+ photon.fliph(image);
+ return image;
+ case 3:
+ photon.fliph(image);
+ photon.flipv(image);
+ return image;
+ case 4:
+ photon.flipv(image);
+ return image;
+ case 5: {
+ const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
+ photon.fliph(rotated);
+ return rotated;
+ }
+ case 6:
+ return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
+ case 7: {
+ const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
+ photon.fliph(rotated);
+ return rotated;
+ }
+ case 8:
+ return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
+ default:
+ return image;
+ }
+}
diff --git a/src/agent/image-processing/index.ts b/src/agent/image-processing/index.ts
new file mode 100644
index 0000000..ddeaf9b
--- /dev/null
+++ b/src/agent/image-processing/index.ts
@@ -0,0 +1,59 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-process.js (MIT).
+import { convertImageBytesToPng } from "./convert.mjs";
+import { formatDimensionNote, resizeImage } from "./resize.ts";
+import type { ProcessImageOptions, ProcessImageResult } from "./types.ts";
+
+export { detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile } from "./mime.mjs";
+export type { ModelImageResizeOptions, ProcessImageOptions, ProcessImageResult, ResizedImage, SupportedImageMimeType } from "./types.ts";
+
+function baseMimeType(mimeType: string): string {
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
+}
+
+function normalizeSupportedImageMimeType(mimeType: string): string | null {
+ switch (baseMimeType(mimeType)) {
+ case "image/png": return "image/png";
+ case "image/jpeg":
+ case "image/jpg": return "image/jpeg";
+ case "image/gif": return "image/gif";
+ case "image/webp": return "image/webp";
+ default: return null;
+ }
+}
+
+async function normalizeImage(bytes: Uint8Array, mimeType: string): Promise<{ bytes: Uint8Array; mimeType: string; convertedFrom?: string } | null> {
+ const normalizedMimeType = normalizeSupportedImageMimeType(mimeType);
+ if (normalizedMimeType) return { bytes, mimeType: normalizedMimeType };
+ const pngBytes = await convertImageBytesToPng(bytes);
+ if (!pngBytes) return null;
+ return { bytes: pngBytes, mimeType: "image/png", convertedFrom: baseMimeType(mimeType) };
+}
+
+function conversionHint(from: string | undefined, to: string): string | undefined {
+ if (!from || from === to) return undefined;
+ return `[Image converted from ${from} to ${to}.]`;
+}
+
+export async function processImage(bytes: Uint8Array, mimeType: string, options?: ProcessImageOptions): Promise {
+ const autoResizeImages = options?.autoResizeImages ?? true;
+ const normalized = await normalizeImage(bytes, mimeType);
+ if (!normalized) {
+ return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
+ }
+ if (autoResizeImages) {
+ const resized = await resizeImage(normalized.bytes, normalized.mimeType, options?.resizeOptions);
+ if (!resized) {
+ return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
+ }
+ const hints: string[] = [];
+ const convertedHint = conversionHint(normalized.convertedFrom, resized.mimeType);
+ if (convertedHint) hints.push(convertedHint);
+ const dimensionNote = formatDimensionNote(resized);
+ if (dimensionNote) hints.push(dimensionNote);
+ return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
+ }
+ const hints: string[] = [];
+ const convertedHint = conversionHint(normalized.convertedFrom, normalized.mimeType);
+ if (convertedHint) hints.push(convertedHint);
+ return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
+}
diff --git a/src/agent/image-processing/mime.d.mts b/src/agent/image-processing/mime.d.mts
new file mode 100644
index 0000000..6451c62
--- /dev/null
+++ b/src/agent/image-processing/mime.d.mts
@@ -0,0 +1,3 @@
+import type { SupportedImageMimeType } from "./types.ts";
+export function detectSupportedImageMimeType(buffer: Uint8Array): SupportedImageMimeType | null;
+export function detectSupportedImageMimeTypeFromFile(filePath: string): Promise;
diff --git a/src/agent/image-processing/mime.mjs b/src/agent/image-processing/mime.mjs
new file mode 100644
index 0000000..c9930d6
--- /dev/null
+++ b/src/agent/image-processing/mime.mjs
@@ -0,0 +1,110 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/mime.js (MIT).
+import { open } from "node:fs/promises";
+const IMAGE_TYPE_SNIFF_BYTES = 4100;
+const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+export function detectSupportedImageMimeType(buffer) {
+ if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
+ return buffer[3] === 0xf7 ? null : "image/jpeg";
+ }
+ if (startsWith(buffer, PNG_SIGNATURE)) {
+ return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
+ }
+ if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) {
+ return "image/gif";
+ }
+ if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {
+ return "image/webp";
+ }
+ if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
+ return "image/bmp";
+ }
+ return null;
+}
+export async function detectSupportedImageMimeTypeFromFile(filePath) {
+ const fileHandle = await open(filePath, "r");
+ try {
+ const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES);
+ const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0);
+ return detectSupportedImageMimeType(buffer.subarray(0, bytesRead));
+ }
+ finally {
+ await fileHandle.close();
+ }
+}
+function isPng(buffer) {
+ return (buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR"));
+}
+function isAnimatedPng(buffer) {
+ let offset = PNG_SIGNATURE.length;
+ while (offset + 8 <= buffer.length) {
+ const chunkLength = readUint32BE(buffer, offset);
+ const chunkTypeOffset = offset + 4;
+ if (startsWithAscii(buffer, chunkTypeOffset, "acTL"))
+ return true;
+ if (startsWithAscii(buffer, chunkTypeOffset, "IDAT"))
+ return false;
+ const nextOffset = offset + 8 + chunkLength + 4;
+ if (nextOffset <= offset || nextOffset > buffer.length)
+ return false;
+ offset = nextOffset;
+ }
+ return false;
+}
+function isBmp(buffer) {
+ if (buffer.length < 26)
+ return false;
+ const declaredFileSize = readUint32LE(buffer, 2);
+ const pixelDataOffset = readUint32LE(buffer, 10);
+ const dibHeaderSize = readUint32LE(buffer, 14);
+ if (declaredFileSize !== 0 && declaredFileSize < 26)
+ return false;
+ if (pixelDataOffset < 14 + dibHeaderSize)
+ return false;
+ if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize)
+ return false;
+ let colorPlanes;
+ let bitsPerPixel;
+ if (dibHeaderSize === 12) {
+ colorPlanes = readUint16LE(buffer, 22);
+ bitsPerPixel = readUint16LE(buffer, 24);
+ }
+ else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
+ if (buffer.length < 30)
+ return false;
+ colorPlanes = readUint16LE(buffer, 26);
+ bitsPerPixel = readUint16LE(buffer, 28);
+ }
+ else {
+ return false;
+ }
+ return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
+}
+function readUint16LE(buffer, offset) {
+ return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
+}
+function readUint32BE(buffer, offset) {
+ return ((buffer[offset] ?? 0) * 0x1000000 +
+ ((buffer[offset + 1] ?? 0) << 16) +
+ ((buffer[offset + 2] ?? 0) << 8) +
+ (buffer[offset + 3] ?? 0));
+}
+function readUint32LE(buffer, offset) {
+ return ((buffer[offset] ?? 0) +
+ ((buffer[offset + 1] ?? 0) << 8) +
+ ((buffer[offset + 2] ?? 0) << 16) +
+ (buffer[offset + 3] ?? 0) * 0x1000000);
+}
+function startsWith(buffer, bytes) {
+ if (buffer.length < bytes.length)
+ return false;
+ return bytes.every((byte, index) => buffer[index] === byte);
+}
+function startsWithAscii(buffer, offset, text) {
+ if (buffer.length < offset + text.length)
+ return false;
+ for (let index = 0; index < text.length; index++) {
+ if (buffer[offset + index] !== text.charCodeAt(index))
+ return false;
+ }
+ return true;
+}
diff --git a/src/agent/image-processing/photon.mjs b/src/agent/image-processing/photon.mjs
new file mode 100644
index 0000000..713d627
--- /dev/null
+++ b/src/agent/image-processing/photon.mjs
@@ -0,0 +1,21 @@
+// Adapted from @earendil-works/pi-coding-agent 1.1.0, dist/utils/photon.js (MIT).
+// Source-vs-runtime adaptation: this app runs in Node/Electron, not a Bun
+// compiled executable. Photon loads its colocated WASM normally; no global fs
+// monkeypatch or executable-relative WASM fallback is needed.
+let photonModule = null;
+let loadPromise = null;
+
+export async function loadPhoton() {
+ if (photonModule) return photonModule;
+ if (loadPromise) return loadPromise;
+ loadPromise = (async () => {
+ try {
+ photonModule = await import("@silvia-odwyer/photon-node");
+ return photonModule;
+ } catch {
+ photonModule = null;
+ return photonModule;
+ }
+ })();
+ return loadPromise;
+}
diff --git a/src/agent/image-processing/resize-core.d.mts b/src/agent/image-processing/resize-core.d.mts
new file mode 100644
index 0000000..8c2a194
--- /dev/null
+++ b/src/agent/image-processing/resize-core.d.mts
@@ -0,0 +1,10 @@
+import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
+export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE: "pi:image-resize-response";
+export function resizeImageInProcess(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise;
+export type ResizeImageWorkerResponse = {
+ type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
+ result: ResizedImage | null;
+} | {
+ type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
+ error: string;
+};
diff --git a/src/agent/image-processing/resize-core.mjs b/src/agent/image-processing/resize-core.mjs
new file mode 100644
index 0000000..3e89f7c
--- /dev/null
+++ b/src/agent/image-processing/resize-core.mjs
@@ -0,0 +1,130 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-core.js (MIT).
+// Runtime .mjs resources stay unbundled for Next's Node/Electron worker packaging.
+import { applyExifOrientation } from "./exif-orientation.mjs";
+import { loadPhoton } from "./photon.mjs";
+/**
+ * Tag on image resize worker replies. Node can post its own messages on the worker channel
+ * (for example `{ "watch:require": [...] }` under `node --watch`), so replies must be identifiable.
+ */
+export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE = "pi:image-resize-response";
+// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
+const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
+const DEFAULT_OPTIONS = {
+ maxWidth: 2000,
+ maxHeight: 2000,
+ maxBytes: DEFAULT_MAX_BYTES,
+ jpegQuality: 80,
+};
+function encodeCandidate(buffer, mimeType) {
+ const data = Buffer.from(buffer).toString("base64");
+ return {
+ data,
+ encodedSize: Buffer.byteLength(data, "utf-8"),
+ mimeType,
+ };
+}
+/**
+ * Resize an image to fit within the specified max dimensions and encoded file size.
+ * Returns null if the image cannot be resized below maxBytes.
+ *
+ * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
+ * returns null.
+ *
+ * Strategy for staying under maxBytes:
+ * 1. First resize to maxWidth/maxHeight
+ * 2. Try PNG first, then JPEG qualities in insertion order (first under cap wins)
+ * 3. If still too large, try the remaining JPEG qualities
+ * 4. If still too large, progressively reduce dimensions until 1x1
+ */
+export async function resizeImageInProcess(inputBytes, mimeType, options) {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+ const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
+ const photon = await loadPhoton();
+ if (!photon) {
+ return null;
+ }
+ let image;
+ try {
+ const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
+ image = applyExifOrientation(photon, rawImage, inputBytes);
+ if (image !== rawImage)
+ rawImage.free();
+ const originalWidth = image.get_width();
+ const originalHeight = image.get_height();
+ const format = mimeType.split("/")[1] ?? "png";
+ // Check if already within all limits (dimensions AND encoded size)
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
+ return {
+ data: Buffer.from(inputBytes).toString("base64"),
+ mimeType: mimeType || `image/${format}`,
+ originalWidth,
+ originalHeight,
+ width: originalWidth,
+ height: originalHeight,
+ wasResized: false,
+ };
+ }
+ // Calculate initial dimensions respecting max limits
+ let targetWidth = originalWidth;
+ let targetHeight = originalHeight;
+ if (targetWidth > opts.maxWidth) {
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
+ targetWidth = opts.maxWidth;
+ }
+ if (targetHeight > opts.maxHeight) {
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
+ targetHeight = opts.maxHeight;
+ }
+ function tryEncodings(width, height, jpegQualities) {
+ const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
+ try {
+ const candidates = [encodeCandidate(resized.get_bytes(), "image/png")];
+ for (const quality of jpegQualities) {
+ candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
+ }
+ return candidates;
+ }
+ finally {
+ resized.free();
+ }
+ }
+ const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
+ let currentWidth = targetWidth;
+ let currentHeight = targetHeight;
+ while (true) {
+ const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
+ for (const candidate of candidates) {
+ if (candidate.encodedSize < opts.maxBytes) {
+ return {
+ data: candidate.data,
+ mimeType: candidate.mimeType,
+ originalWidth,
+ originalHeight,
+ width: currentWidth,
+ height: currentHeight,
+ wasResized: true,
+ };
+ }
+ }
+ if (currentWidth === 1 && currentHeight === 1) {
+ break;
+ }
+ const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
+ const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
+ if (nextWidth === currentWidth && nextHeight === currentHeight) {
+ break;
+ }
+ currentWidth = nextWidth;
+ currentHeight = nextHeight;
+ }
+ return null;
+ }
+ catch {
+ return null;
+ }
+ finally {
+ if (image) {
+ image.free();
+ }
+ }
+}
diff --git a/src/agent/image-processing/resize-worker.mjs b/src/agent/image-processing/resize-worker.mjs
new file mode 100644
index 0000000..bbc9e88
--- /dev/null
+++ b/src/agent/image-processing/resize-worker.mjs
@@ -0,0 +1,32 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-worker.js (MIT).
+import { parentPort } from "node:worker_threads";
+import { IMAGE_RESIZE_WORKER_RESPONSE_TYPE, resizeImageInProcess, } from "./resize-core.mjs";
+function isResizeImageWorkerRequest(value) {
+ if (!value || typeof value !== "object")
+ return false;
+ const record = value;
+ return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
+}
+const port = parentPort;
+if (!port) {
+ throw new Error("image resize worker requires parentPort");
+}
+port.once("message", (message) => {
+ void (async () => {
+ try {
+ if (!isResizeImageWorkerRequest(message)) {
+ throw new Error("Invalid image resize worker request");
+ }
+ const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
+ const response = { type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, result };
+ port.postMessage(response);
+ }
+ catch (error) {
+ const response = {
+ type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ port.postMessage(response);
+ }
+ })();
+});
diff --git a/src/agent/image-processing/resize.ts b/src/agent/image-processing/resize.ts
new file mode 100644
index 0000000..09115c0
--- /dev/null
+++ b/src/agent/image-processing/resize.ts
@@ -0,0 +1,65 @@
+// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize.js (MIT).
+import path from "node:path";
+import { Worker } from "node:worker_threads";
+import {
+ IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
+ resizeImageInProcess,
+ type ResizeImageWorkerResponse,
+} from "./resize-core.mjs";
+import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
+
+function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
+ return value !== null && typeof value === "object" && "type" in value && value.type === IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
+}
+
+async function resizeImageInWorker(workerPath: string, inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise {
+ const worker = new Worker(workerPath);
+ try {
+ // Transfer a worker-owned copy so the caller's bytes remain intact.
+ const inputBytesForWorker = new Uint8Array(inputBytes);
+ return await new Promise((resolve, reject) => {
+ let settled = false;
+ const settle = (result: ResizedImage | null) => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+ const fail = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ reject(error);
+ };
+ worker.on("message", (message: unknown) => {
+ // Ignore Node's own worker-channel messages (e.g. node --watch).
+ if (!isResizeImageWorkerResponse(message)) return;
+ if ("error" in message) fail(new Error(message.error));
+ else settle(message.result);
+ });
+ worker.once("error", fail);
+ worker.once("exit", (code) => {
+ if (!settled) fail(new Error(`Image resize worker exited with code ${code}`));
+ });
+ worker.postMessage({ inputBytes: inputBytesForWorker, mimeType, options }, [inputBytesForWorker.buffer]);
+ });
+ } finally {
+ void worker.terminate().catch(() => undefined);
+ }
+}
+
+export async function resizeImage(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise {
+ // Source-vs-runtime adaptation: Next bundles the TS orchestration, but traces
+ // unbundled .mjs worker/core/EXIF/Photon resources at this cwd-relative path.
+ // The packaged server's cwd is the standalone root. No Bun path is needed.
+ const workerPath = path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs");
+ try {
+ return await resizeImageInWorker(workerPath, inputBytes, mimeType, options);
+ } catch {
+ return resizeImageInProcess(inputBytes, mimeType, options);
+ }
+}
+
+export function formatDimensionNote(result: ResizedImage): string | undefined {
+ if (!result.wasResized) return undefined;
+ const scale = result.originalWidth / result.width;
+ return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
+}
diff --git a/src/agent/image-processing/types.ts b/src/agent/image-processing/types.ts
new file mode 100644
index 0000000..34cc117
--- /dev/null
+++ b/src/agent/image-processing/types.ts
@@ -0,0 +1,35 @@
+// Port baseline: @earendil-works/pi-coding-agent 1.1.0 image utilities (MIT).
+/** Native resize overrides, supplied by the caller for the selected model. */
+export interface ModelImageResizeOptions {
+ maxWidth?: number;
+ maxHeight?: number;
+ maxBytes?: number;
+ jpegQuality?: number;
+}
+
+export interface ResizedImage {
+ data: string;
+ mimeType: string;
+ originalWidth: number;
+ originalHeight: number;
+ width: number;
+ height: number;
+ wasResized: boolean;
+}
+
+export interface ProcessImageOptions {
+ autoResizeImages?: boolean;
+ resizeOptions?: ModelImageResizeOptions;
+}
+
+export type ProcessImageResult = {
+ ok: true;
+ data: string;
+ mimeType: string;
+ hints: string[];
+} | {
+ ok: false;
+ message: string;
+};
+
+export type SupportedImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/bmp";
diff --git a/src/agent/models.ts b/src/agent/models.ts
index 6aa3dfb..3d7bfb2 100644
--- a/src/agent/models.ts
+++ b/src/agent/models.ts
@@ -5,9 +5,11 @@ import {
type Api,
type Model,
type Models,
+ type MutableModels,
type StreamFunction,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
+import { lazyApi, lazyStream } from "@earendil-works/pi-ai/api/lazy";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
@@ -15,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";
@@ -41,14 +45,31 @@ export const APP_IDENTITY = Object.freeze({
export interface PiEngine {
model: Model;
streamFn: StreamFunction;
- /** The resolved (config-or-settings, CLAMPED) thinking level —
- * observability + tests; the streamFn pins it anyway. */
+ /** The initially resolved (config-or-settings, CLAMPED) thinking level.
+ * Custom streams re-resolve the original preference per operation. */
level?: ThinkingLevel;
}
export type { ThinkingLevel };
let collection: ReturnType | undefined;
+interface ProviderSource {
+ sync(models: MutableModels): void;
+ isCustom(id: string): boolean;
+}
+let providerSource: ProviderSource | undefined;
+
+/** Configuration IO is injected by the server; the agent never imports stores. */
+export function configureProviderSource(source: ProviderSource): void {
+ // Server module re-evaluation can replace the source while this collection
+ // survives. Retire only the previous source's routes, not built-ins.
+ if (collection && providerSource) {
+ for (const provider of collection.getProviders()) {
+ if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id);
+ }
+ }
+ providerSource = source;
+}
/** The process-wide pi-ai Models collection — EVERY builtin provider's
* catalog (model slots wave, 2026-09-20): engines and the picker resolve
@@ -61,8 +82,17 @@ let collection: ReturnType | undefined;
export function piModels() {
if (!collection) {
collection = createModels({ identity: APP_IDENTITY });
- for (const provider of builtinProviders()) collection.setProvider(provider);
+ for (const provider of builtinProviders()) {
+ // Keep the native Bedrock implementation reachable by standalone tracing.
+ // Pi's default Node-only loader hides this import from bundlers. Only
+ // replace its lazy forwarding; preserve the native factory's auth/catalog.
+ collection.setProvider(provider.id === "amazon-bedrock"
+ ? { ...provider, ...lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")) }
+ : provider);
+ }
+ collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
}
+ providerSource?.sync(collection);
return collection;
}
@@ -102,26 +132,48 @@ function defaultThinkingLevel(model: Model): ThinkingLevel {
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const nativeId = nativeSubscriptionId(spec.provider);
- const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
+ const registeredModel = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
+ const custom = providerSource?.isCustom(spec.provider) ?? false;
+ // Custom endpoint owners authorize image attempts independently of sparse
+ // discovery metadata. Never promote the registered/catalog capabilities.
+ const permitImages = (value: Model): Model => ({ ...value, input: ["text", "image"] });
+ const model = custom && registeredModel ? permitImages(registeredModel) : registeredModel;
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
// UNSET level → the baked default order (first entry the model
// supports); SET level → pi's native nearest-clamp (model swaps keep
// the user's intent and adapt honestly).
- const level = spec.thinkingLevel
- ? clampThinkingLevel(model, spec.thinkingLevel)
+ const thinkingPreference = spec.thinkingLevel;
+ const level = thinkingPreference
+ ? clampThinkingLevel(model, thinkingPreference)
: defaultThinkingLevel(model);
+ // A lesson's agent loop can stay alive through many tool calls. Remember
+ // only that this is a custom route, never its configuration for that run.
return {
model,
level,
- // bound streamSimple: the agent loop forwards state.thinkingLevel as
- // `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
- // the pin honest even when config or settings drift off the map.
+ // The agent loop forwards state.thinkingLevel as `reasoning` (undefined
+ // = off on pi-ai's scale). Built-ins keep the factory clamp; custom
+ // requests re-resolve thinking against their current metadata below.
streamFn: (requestModel, context, options) => {
+ if (custom) {
+ // Revalidate before EVERY upstream model operation, including later
+ // tool-loop steps. Resolve thinking from the ORIGINAL preference (or
+ // existing default order), not a previous metadata snapshot's clamp.
+ // Pi retains the setup of an operation already in flight.
+ const current = piModels();
+ const currentModel = current.getModel(spec.provider, spec.id);
+ if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); });
+ const currentLevel = thinkingPreference
+ ? clampThinkingLevel(currentModel, thinkingPreference)
+ : defaultThinkingLevel(currentModel);
+ return current.streamSimple(permitImages(currentModel), context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
+ }
+ const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
- return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
+ return stream(requestModel, context, requestOptions);
},
};
}
diff --git a/src/agent/subagents/aborts.ts b/src/agent/subagents/aborts.ts
index ed5b587..b533b8a 100644
--- a/src/agent/subagents/aborts.ts
+++ b/src/agent/subagents/aborts.ts
@@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void {
* the liveness gate for the stale-job sweep: a "running" record with a
* live registration belongs to a loop this process owns (hung or slow, it
* is the one truth — never re-spawn beside it); without one the record is
- * an orphan of a process death and recovers immediately. The span is
- * loop-start to delivery-completion: the runner's unregistering finally
- * runs only after deliver() resolves, by which point finalizeJob has
- * flipped the record — so absence of registration is unambiguous. */
+ * an orphan of a process death and recovers immediately. The registration
+ * spans computation and cleanup only. Finished outcomes have independent,
+ * persisted delivery obligations; no worker waits for their consumption. */
export function isJobLive(jobId: string): boolean {
return registry().has(jobId);
}
/** Abort a running job's loop. Returns false when no controller is live —
- * the job's loop is not executing (crashed, or in the delivering-result
- * phase where the runner has already returned); the caller then owns the
- * whole abort on the record side. */
+ * the job's loop is not executing. The endpoint separately refuses abort
+ * for a finished worker, even while its session delivery remains pending. */
export function fireJobAbort(jobId: string): boolean {
const controller = registry().get(jobId);
if (!controller) return false;
diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts
index 5d197fb..67077fd 100644
--- a/src/agent/subagents/extraction.ts
+++ b/src/agent/subagents/extraction.ts
@@ -29,7 +29,7 @@ import type { GraphStore } from "../../core/ports.ts";
import { emit } from "../../core/events.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
-import type { LhMessage } from "../../core/native-messages.ts";
+import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
@@ -125,16 +125,20 @@ export function transcriptView(record: ChatRecord): string {
}
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
- * ONLY the teacher agent's text blocks — no learner messages, no
- * thinking, no tool calls or results, no quiz blocks — each labeled with
+ * ONLY learner-facing teacher prose (text or send_to_user) — no learner
+ * messages, thinking, other tool calls/results, or quiz blocks — labeled with
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
* lines the coverage fingerprints hash, so the extractor can anchor
* every proposition to the passage it came from (the session submit's
* required messageId). The shared markdown engine stays reserved for the
* future user-facing export. */
-function proseView(messages: ReadonlyArray, title?: string): string {
- const lines = messages.flatMap((message) => messageViewLines(message, message.id));
+function proseView(
+ messages: ReadonlyArray,
+ title?: string,
+ failedOutputCalls: ReadonlySet = failedOutputCallIds(messages),
+): string {
+ const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
return head + lines.join("\n\n");
}
@@ -1119,6 +1123,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// stage runs (typed — the trigger skips, routes surface 503).
refuseIfStagesUnready();
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
+ const failedOutputCalls = failedOutputCallIds(record.messages);
if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
return { nodes: 0, chains: 0 };
@@ -1154,14 +1159,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// fingerprints' messageViewLines ("TEACHER [id] text"), one
// shared seam.
const messages = batch.map((slot) => slot.message);
- const view = proseView(messages, record.title);
+ const view = proseView(messages, record.title, failedOutputCalls);
// The batch's teacher-prose ids — the ONLY anchors this
// job's submit may name (validateSessionAnchors at the
// submit seam refuses anything else: ids from other
// batches — even real ones of this chat — other chats,
// and non-prose messages).
const batchIds = new Set(
- messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
+ messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
);
setPhase(jobKey, "queued", "chat", undefined, part);
const findings = await withExtractSlot(jobKey, async () => {
@@ -1194,7 +1199,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
- .filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
+ .filter((slot) => slot.tokens > 0)
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts
index 2325fe9..6277017 100644
--- a/src/agent/subagents/headless.ts
+++ b/src/agent/subagents/headless.ts
@@ -461,6 +461,54 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise Number.isFinite(n) && n > 0)) {
+ throw new Error("PDF page dimensions must be finite and positive");
+ }
+ if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
+ throw new Error("PDF render DPI must be finite and positive");
+ }
+ // Limit sides first, so the area calculation cannot overflow even when
+ // the unscaled page dimensions or requested DPI are enormous.
+ let scale = Math.min(dpi / 72, MAX_PDF_CANVAS_SIDE / baseWidth, MAX_PDF_CANVAS_SIDE / baseHeight);
+ const area = (baseWidth * scale) * (baseHeight * scale);
+ if (area > MAX_PDF_CANVAS_PIXELS) scale *= Math.sqrt(MAX_PDF_CANVAS_PIXELS / area);
+ const dimensions = (s: number) => ({
+ width: Math.max(1, Math.ceil(baseWidth * s)),
+ height: Math.max(1, Math.ceil(baseHeight * s)),
+ });
+ const fits = ({ width, height }: { width: number; height: number }) =>
+ width <= MAX_PDF_CANVAS_SIDE && height <= MAX_PDF_CANVAS_SIDE && width * height <= MAX_PDF_CANVAS_PIXELS;
+ // Ceil can put an otherwise bounded floating-point viewport over budget.
+ // Find a safe scale rather than clipping the viewport or allocating first.
+ if (!fits(dimensions(scale))) {
+ let low = 0;
+ let high = scale;
+ for (let i = 0; i < 64; i++) {
+ const mid = low + (high - low) / 2;
+ if (fits(dimensions(mid))) low = mid;
+ else high = mid;
+ }
+ scale = low;
+ }
+ if (scale <= 0 || baseWidth * scale === 0 || baseHeight * scale === 0) {
+ throw new Error("PDF page dimensions cannot be rendered at a positive scale");
+ }
+ return { scale, ...dimensions(scale) };
+}
+
/** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes.
*
* History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's
@@ -475,6 +523,9 @@ async function renderPdfPages(
opts: RenderPdfOptions = {},
): Promise {
const dpi = opts.dpi ?? 150;
+ if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
+ throw new Error("PDF render DPI must be finite and positive");
+ }
// NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into
// @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's
// own Node integration IS @napi-rs/canvas (its optionalDependency: the
@@ -495,8 +546,10 @@ async function renderPdfPages(
for (const pageNo of pages) {
if (pageNo < 1 || pageNo > doc.numPages) continue;
const pg = await doc.getPage(pageNo);
- const vp = pg.getViewport({ scale: dpi / 72 });
- const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
+ const base = pg.getViewport({ scale: 1 });
+ const geometry = pdfCanvasGeometry(base.width, base.height, dpi);
+ const vp = pg.getViewport({ scale: geometry.scale });
+ const canvas = createCanvas(geometry.width, geometry.height);
const ctx = canvas.getContext("2d");
// pdfjs's render types speak the DOM context; @napi-rs/canvas's
// context is the same 2D API implemented over Skia.
diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts
index 140b5df..313f069 100644
--- a/src/agent/subagents/pdf-reader.ts
+++ b/src/agent/subagents/pdf-reader.ts
@@ -2,7 +2,9 @@ import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
+import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
import { Type } from "typebox";
+import { resolveSourcePath } from "../tools/fs-shared.ts";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
@@ -168,7 +170,7 @@ export async function runPdfReaderAgent(
): Promise {
const headless = opts.headless ?? getHeadless();
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
- const pdfPath = input.path;
+ const pdfPath = resolveSourcePath(input.path);
// Deterministic upfront failure: unreadable / unparseable
// for renders — the brief never starts on a broken source.
@@ -247,7 +249,7 @@ export async function runPdfReaderAgent(
const toolResults: ToolResultMessage[] = [];
/** Rendered pages this batch, appended as images AFTER the tool
* results — the visual-maker's image-in-the-loop idiom. */
- let renderedImages: { page: number; png: Buffer }[] | null = null;
+ let renderedImages: { page: number; image: ProcessImageResult }[] | null = null;
for (const call of toolCalls) {
const args = (call.arguments ?? {}) as Record;
@@ -288,19 +290,26 @@ export async function runPdfReaderAgent(
const wanted = specNumbers(spec, RENDER_BATCH_CAP);
opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`);
const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi });
+ const processed: { page: number; image: ProcessImageResult }[] = [];
for (const p of pages) {
- consulted.add(p.page);
- read.add(p.page);
+ const image = await processImage(p.png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
+ opts.abortSignal?.throwIfAborted();
+ processed.push({ page: p.page, image });
+ // Only images actually delivered to the model count as read.
+ if (image.ok) {
+ consulted.add(p.page);
+ read.add(p.page);
+ }
}
if (pages.length === 0) {
toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)"));
} else {
- renderedImages = pages;
+ renderedImages = [...(renderedImages ?? []), ...processed];
toolResults.push(
toolText(
call.id,
call.name,
- `Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`,
+ `Rendered ${pages.length} page(s) at ${dpi} DPI — processed images or explicit omission notes follow below. Read the images carefully (equations, diagrams, layout).`,
),
);
}
@@ -324,11 +333,12 @@ export async function runPdfReaderAgent(
type: "text",
text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`,
},
- ...renderedImages.map((p) => ({
- type: "image" as const,
- data: p.png.toString("base64"),
- mimeType: "image/png",
- })),
+ ...renderedImages.flatMap(({ page, image }) => image.ok
+ ? [
+ { type: "text" as const, text: [`Page ${page}`, ...image.hints].join("\n") },
+ { type: "image" as const, data: image.data, mimeType: image.mimeType },
+ ]
+ : [{ type: "text" as const, text: `Page ${page}: ${image.message}` }]),
],
timestamp: Date.now(),
},
diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts
index 3f8ca73..76f3ac7 100644
--- a/src/agent/subagents/runner.ts
+++ b/src/agent/subagents/runner.ts
@@ -4,6 +4,7 @@ import type { SearchProvider } from "../../core/search.ts";
import {
accumulateJobUsage,
buildSubagentSteer,
+ isSubagentFailure,
piUsageToStep,
type DemoResult,
type DemoToolInput,
@@ -41,13 +42,13 @@ import { runVisualMaker } from "./visual-maker.ts";
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
* updating the job's activity line on every iteration. On completion the
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
- * `deliver`; the wake pipeline fires the server-side teacher turn and
- * flips the job to done/error when the lesson has actually resumed.
+ * `deliver` without waiting. The worker's final outcome and pending
+ * delivery are saved together; the session owns delivery from then on.
*
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
* notes; research: failure brief) — and it STILL steers, because the
- * teacher must learn the outcome. If delivery itself fails, the job is
- * marked error (the learner's activity card shows it).
+ * teacher must learn the outcome. Failed delivery starts stay quietly
+ * pending and never rewrite the worker's computation outcome.
*/
/**
@@ -69,9 +70,9 @@ export interface JobRunnerDeps {
chatStore: ChatStore;
jobStore: JobStore;
/**
- * Hand a finished job's steer to the wake pipeline — resolves when the
- * steer is delivered (the wake turn completed and the job status
- * flipped); rejects when delivery fails unrecoverably.
+ * Hand a finished job's steer to the session delivery registry. The
+ * promise tracks durable marker acceptance, but the worker never waits
+ * on it. Its persisted pending obligation survives process restart.
*/
deliver: (steer: SteerMessage) => Promise;
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
@@ -98,8 +99,8 @@ export interface JobRunnerDeps {
export interface JobRunner {
/** Run one job's sub-agent loop to completion (background; never throws).
* The tool input rides along from dispatch (the spawn fires mid-stream,
- * before the tool part is persisted — only crash-recovery re-spawns
- * resolve the input from the transcript). */
+ * before the tool part is persisted). Recovery reads saved job input,
+ * with transcript fallback for legacy jobs. Delivery is not awaited. */
runJob(jobId: string, input?: JobToolInput): Promise;
}
@@ -272,14 +273,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
}
- /** Resolve the job's tool input: dispatch passes it directly; only a
- * crash-recovery re-spawn (no input) falls back to scanning the
- * transcript (by then onFinish has long persisted the part). */
+ /** Dispatch input wins, then the job's saved arguments. Legacy jobs
+ * without saved input fall back to the durable transcript, which may
+ * lack a call if the dispatching turn died before settlement. */
function resolveInput(
job: JobRecord,
input: JobToolInput | undefined,
): JobToolInput | undefined {
if (input) return input;
+ if (job.input !== undefined) return job.input as JobToolInput;
const chat = chatStore.getChat(job.chatId);
if (!chat) return undefined;
// Wave D: the record is native history — the scan reads the native
@@ -297,27 +299,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
/**
- * The tail of every job: store the result, build the steer, hand it to
- * the wake pipeline. The pipeline flips the status when the lesson has
- * actually resumed — until then the job reads "running" so the client's
- * activity card keeps polling and re-syncs once, at the end.
+ * Save the computation outcome and session delivery obligation together.
+ * Worker liveness ends after cleanup, not after a lesson consumes the result.
*/
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise {
const job = jobStore.getJob(jobId);
if (!job) return;
- // Aborted mid-run or mid-delivery (abortJob flips the record before
+ // Aborted mid-run (abortJob flips the record before
// the loop unwinds): no result write, no steer — the abort path owns
// the record and queues the abort steer itself.
if (job.status !== "running") return;
- jobStore.updateJob(jobId, { result, activity: "delivering result" });
- const steer = buildSubagentSteer(job, result);
- try {
- await deliver(steer);
- } catch (err) {
- const reason = err instanceof Error ? err.message : String(err);
- console.error(`[job ${jobId}] steer delivery failed:`, err);
- jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
- }
+ const failed = isSubagentFailure(job.kind, result);
+ jobStore.updateJob(jobId, {
+ result,
+ status: failed ? "error" : "done",
+ activity: failed ? "failed" : "finished",
+ delivery: { kind: "result", status: "pending" },
+ });
+ // The saved result now belongs to the session. Do not keep the worker,
+ // abort registration, or temporary resources alive behind a question.
+ void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => {
+ console.error(`[job ${jobId}] pending delivery:`, err);
+ });
}
/** Chat-record + input resolution shared by all three runners. */
@@ -345,7 +348,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const { job } = ctx;
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
- const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
+ if (!(typeof input?.question === "string" && input.question.trim())) {
+ jobStore.updateJob(jobId, { error: "research question unavailable" });
+ await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] });
+ return;
+ }
+ const question = input.question;
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
const prompt = [
diff --git a/src/agent/subagents/visual-maker.ts b/src/agent/subagents/visual-maker.ts
index 20943e0..35e95a7 100644
--- a/src/agent/subagents/visual-maker.ts
+++ b/src/agent/subagents/visual-maker.ts
@@ -2,6 +2,7 @@ import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
+import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
import { Type } from "typebox";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
@@ -195,6 +196,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
const msg = err instanceof Error ? err.message : String(err);
return { svg: "", notes: `FAILED: rendering the final SVG failed — ${msg}` };
}
+ const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
+ opts.abortSignal?.throwIfAborted();
+ if (!image.ok) return { svg: "", notes: `FAILED: inspecting the final SVG failed — ${image.message}` };
opts.onActivity?.("render_svg: inspecting final render");
renderedSvg = currentSvg;
messages = [
@@ -202,8 +206,8 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
{
role: "user",
content: [
- { type: "text", text: UNLOOKED_FINISH_MESSAGE },
- { type: "image", data: png.toString("base64"), mimeType: "image/png" },
+ { type: "text", text: [UNLOOKED_FINISH_MESSAGE, ...image.hints].join("\n") },
+ { type: "image", data: image.data, mimeType: image.mimeType },
],
timestamp: Date.now(),
},
@@ -214,7 +218,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
const toolResults: ToolResultMessage[] = [];
- let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results
+ let renderImage: Extract | null = null; // appended AFTER the tool results
for (const call of toolCalls) {
if (call.name === "write_svg") {
const svg = (call.arguments as { svg?: unknown }).svg;
@@ -262,7 +266,10 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
try {
const png = await headless.renderSvg(currentSvg);
- renderPng = png;
+ const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
+ opts.abortSignal?.throwIfAborted();
+ if (!image.ok) throw new Error(image.message);
+ renderImage = image;
renderedSvg = currentSvg;
opts.onActivity?.("render_svg: inspecting render");
toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it."));
@@ -277,14 +284,14 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
// Message order: assistant tool-calls → tool results → render image.
messages = [...messages, ...toolResults];
- if (renderPng) {
+ if (renderImage) {
messages = [
...messages,
{
role: "user",
content: [
- { type: "text", text: LOOK_MESSAGE },
- { type: "image", data: renderPng.toString("base64"), mimeType: "image/png" },
+ { type: "text", text: [LOOK_MESSAGE, ...renderImage.hints].join("\n") },
+ { type: "image", data: renderImage.data, mimeType: renderImage.mimeType },
],
timestamp: Date.now(),
},
diff --git a/src/agent/subscription-bridge.ts b/src/agent/subscription-bridge.ts
index b1a558e..97ef6f8 100644
--- a/src/agent/subscription-bridge.ts
+++ b/src/agent/subscription-bridge.ts
@@ -1,4 +1,5 @@
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
+import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
function desktopConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
+function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
@@ -60,7 +62,11 @@ function bridge(): State {
if (message.ok) pending.resolve(message.value);
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
});
+ onWebSubscriptionPacket("lh:subscriptions", (packet) => {
+ if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
+ });
process.on("disconnect", () => {
+ if (webSubscriptionsEnabled()) return;
invalidate(state);
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
state.pending.clear(); notify(state);
@@ -70,6 +76,7 @@ function bridge(): State {
}
export function subscriptionRequest(op: "snapshot" | "lease", fields: Record = {}): Promise {
const state = bridge();
+ if (webSubscriptionsEnabled()) return webSubscriptionRequest("lh:subscriptions", op, fields);
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
return new Promise((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
@@ -82,17 +89,17 @@ export function subscriptionRequest(op: "snapshot" | "lease", fields: Record<
}
export async function refreshSubscriptionModels(): Promise {
const state = bridge();
- if (!desktopConnected()) { invalidate(state); return; }
+ if (!runtimeConnected()) { invalidate(state); return; }
try { accept(state, await subscriptionRequest("snapshot")); }
catch { invalidate(state); notify(state); }
}
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
const status = nativeSubscriptionStatus(providerId);
- return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
+ return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
}
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
-export function nativeSubscriptionModels(): Model[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
+export function nativeSubscriptionModels(): Model[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
diff --git a/src/agent/subscription-runtime.ts b/src/agent/subscription-runtime.ts
new file mode 100644
index 0000000..56e1f07
--- /dev/null
+++ b/src/agent/subscription-runtime.ts
@@ -0,0 +1,30 @@
+/** Server-injected private web transport. The agent layer never imports a
+ * filesystem owner or server composition root. Desktop IPC remains separate. */
+export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
+export type SubscriptionRuntimePacket = Record;
+interface Runtime {
+ request?: (channel: SubscriptionRuntimeChannel, op: string, fields: Record) => Promise;
+ listeners: Map void>>;
+}
+const KEY = Symbol.for("lh.webSubscriptionRuntime");
+function runtime(): Runtime {
+ const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
+ return global[KEY] ??= { listeners: new Map() };
+}
+export function configureWebSubscriptionRuntime(request: NonNullable): void { runtime().request = request; }
+export function webSubscriptionsEnabled(): boolean {
+ return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
+}
+export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
+ const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
+ listeners.add(listener); state.listeners.set(channel, listeners);
+}
+export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
+ for (const listener of runtime().listeners.get(channel) ?? []) {
+ try { listener(packet); } catch { /* observer isolation */ }
+ }
+}
+export function webSubscriptionRequest(channel: SubscriptionRuntimeChannel, op: string, fields: Record): Promise {
+ if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
+ return runtime().request!(channel, op, fields);
+}
diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts
index b9aa2d7..df2800a 100644
--- a/src/agent/teacher-agent.ts
+++ b/src/agent/teacher-agent.ts
@@ -16,10 +16,13 @@ import {
type TeacherToolContext,
} from "./tools.ts";
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
-import { healToolRecord } from "./tools/heal.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 } from "../core/native-messages.ts";
+import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
@@ -147,22 +150,27 @@ export interface AgentBuildOptions {
* belt-and-suspenders HALF for the mixed batch (a question + other calls
* in one message) that terminate-only semantics would let run on — the
* learner cannot answer while a turn is in flight (per-chat 409), so a
- * cleanly-posed question MUST always end the turn. maxTurns is gone
+ * cleanly-posed question MUST always end the turn. Practice's problem pose
+ * has the same wait-for-the-learner contract, including mixed output/problem
+ * batches; failed poses must still retry. maxTurns is gone
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
* turn).
*/
-function makeQuestionStopper() {
+function makeQuestionStopper(kind: AgentKind) {
return (context: {
message: { content: Array<{ type: string; id?: string; name?: string }> };
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
}): boolean => {
const called = new Set(
context.message.content
- .filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
+ .filter((c) => c.type === "toolCall" && c.name !== undefined && (
+ (QUESTION_TOOLS as readonly string[]).includes(c.name) ||
+ (kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
+ ))
.map((c) => c.id!),
);
if (called.size === 0) return false;
- // Stop only when every question call of this turn resolved cleanly.
+ // Stop only when every question/problem pose resolved cleanly.
for (const result of context.toolResults) {
if (called.has(result.toolCallId) && result.isError) return false;
}
@@ -176,7 +184,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// conversation-spec resolution as teaching); the cast keeps AgentKind
// ("practice" is not a ModelRole) honest about that.
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
- const stopper = makeQuestionStopper();
+ const stopper = makeQuestionStopper(kind);
const basePrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!;
@@ -193,7 +201,18 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
systemPrompt,
model: engine.model,
thinkingLevel: engine.level,
- tools,
+ // Conditional registry inclusion, like the graph-tool gates: when
+ // closed, neither the tool nor its usage instructions reach the model.
+ // Match Claude across providers (including OpenRouter), not the API
+ // transport. Explicit test registries remain caller-owned.
+ tools: [
+ ...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.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude"))
+ ? [withArgumentHeal(sendToUserTool)]
+ : []),
+ ],
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
@@ -315,10 +334,9 @@ export function bareQuestionTools(): Record> {
* from turn 1 of a kind:"practice" session — no prober→teacher split,
* no plan tools, no question tools. Its ENTIRE registry is
* problem + conclude (tools/practice.ts); the doctrine prompt
- * (prompts/practice/) carries the method. The question-stop rule does
- * not apply (problem rides terminate: true natively —
- * QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
- * a no-op here; it stays wired for uniformity.
+ * (prompts/practice/) carries the method. A successful problem pose ends
+ * the run, natively for a problem-only batch and via the shared stop hook
+ * for a mixed batch (for example, send_to_user + problem).
*/
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
const tools = opts.tools ?? practiceTools(ctx);
diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts
index 00a78ca..e3ed6a5 100644
--- a/src/agent/tools/jobs.ts
+++ b/src/agent/tools/jobs.ts
@@ -78,7 +78,7 @@ function dispatch(
input: ResearchInput | DemoInput | VisualInput | PdfInput,
): string {
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
- const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
+ const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input });
if (!existing) ctx.spawn(job, input);
return `Dispatched (job ${job.id}). ${ack}`;
}
diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts
index 11e3cd4..7cd9b84 100644
--- a/src/agent/tools/probe.ts
+++ b/src/agent/tools/probe.ts
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
* echo. details = {ok:true} only.
*
- * The first probe_done in the transcript is also the construction seam:
- * from that message on, every request builds the TEACHER agent.
+ * The first concluded probe_done (non-failed call plus matching successful
+ * result) is also the construction seam: from that result on, every request
+ * builds the TEACHER agent. Bare calls and refused results keep probing.
*/
export function probeDoneTool(ctx: ProbeDoneContext): Record> {
return {
diff --git a/src/agent/tools/read.ts b/src/agent/tools/read.ts
index 7550d70..c7a6300 100644
--- a/src/agent/tools/read.ts
+++ b/src/agent/tools/read.ts
@@ -1,9 +1,10 @@
import * as fs from "node:fs/promises";
-import * as path from "node:path";
import { Type } from "typebox";
+import type { Api, Model } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
import { resolveSourcePath } from "./fs-shared.ts";
+import { detectSupportedImageMimeTypeFromFile, processImage } from "../image-processing/index.ts";
/**
* The read tool — sources v0 (2026-09): a Node port of pi's read semantics
@@ -31,17 +32,6 @@ import { resolveSourcePath } from "./fs-shared.ts";
const MAX_LINES = 2000;
const MAX_BYTES = 50 * 1024;
-/** Extension map for v0 (pi sniffs magic bytes; extensions suffice here —
- * learner files are pasted paths, not adversarial uploads). */
-const IMAGE_MIME: Record = {
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".png": "image/png",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".bmp": "image/bmp",
-};
-
const readInput = Type.Object({
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
@@ -95,26 +85,39 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
* fragment (prompts/shared/). Relative paths resolve against the
* learner's home (fs-shared.ts), the harness's anchor.
*/
-export const readTool: AgentTool = {
- name: "read",
- label: "read",
- description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
- parameters: readInput,
- execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise => {
- const absolute = resolveSourcePath(rawPath);
- const ext = path.extname(absolute).toLowerCase();
- const mime = IMAGE_MIME[ext];
- const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees
- if (mime) {
- // NOTE v0: no resize — raw bytes as base64. Learner files are study
- // material (screenshots, figures), not adversarial payloads; a resize
- // step (sharp) can land later if large images prove wasteful.
- return {
- text: `Read image file [${mime}] — ${path.basename(absolute)} (${formatSize(buffer.byteLength)}), attached as an image.`,
- images: [{ data: buffer.toString("base64"), mimeType: mime }],
- };
- }
- const text = buffer.toString("utf-8");
- return readTextSlice(text, offset, limit);
- }),
-};
+export type ReadModel = Pick, "input" | "inputLimits">;
+
+/** One implementation for every learner-file reader. Bind the active model
+ * per agent, rather than sharing mutable model state between conversations.
+ * Home-relative paths, text slicing and the app's abort guard stay unchanged;
+ * image detection/processing and its notes follow native Pi read behavior. */
+export function createReadTool(model?: ReadModel): AgentTool {
+ return {
+ name: "read",
+ label: "read",
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
+ parameters: readInput,
+ execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }, { signal }): Promise => {
+ const absolute = resolveSourcePath(rawPath);
+ const mime = await detectSupportedImageMimeTypeFromFile(absolute);
+ const buffer = await fs.readFile(absolute, { signal });
+ if (mime) {
+ const processed = await processImage(buffer, mime, { resizeOptions: model?.inputLimits?.images?.resize });
+ signal?.throwIfAborted();
+ const note = processed.ok
+ ? [`Read image file [${processed.mimeType}]`, ...processed.hints]
+ : [`Read image file [${mime}]`, processed.message];
+ if (model && !model.input.includes("image")) {
+ note.push("[Current model does not support images. The image will be omitted from this request.]");
+ }
+ return {
+ text: note.join("\n"),
+ ...(processed.ok ? { images: [{ data: processed.data, mimeType: processed.mimeType }] } : {}),
+ };
+ }
+ return readTextSlice(buffer.toString("utf-8"), offset, limit);
+ }),
+ };
+}
+
+export const readTool = createReadTool();
diff --git a/src/agent/tools/send-to-user.ts b/src/agent/tools/send-to-user.ts
new file mode 100644
index 0000000..fb0e409
--- /dev/null
+++ b/src/agent/tools/send-to-user.ts
@@ -0,0 +1,36 @@
+import { Type } from "typebox";
+import type { AgentTool } from "@earendil-works/pi-agent-core";
+import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
+import { serverExecute } from "../tool-execute.ts";
+
+const sendToUserInput = Type.Object({
+ message: Type.String({
+ minLength: 1,
+ pattern: "\\S",
+ description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
+ }),
+});
+
+/** Claude's documented verbatim mid-turn output channel. Availability is
+ * model-gated at the shared conversation-agent construction boundary;
+ * the native call remains in history, and the view renders its message. */
+export const sendToUserTool: AgentTool = {
+ name: SEND_TO_USER_TOOL,
+ label: SEND_TO_USER_TOOL,
+ description:
+ "## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
+ "**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
+ "feedback, corrections, hints, introductions, progress messages, and final responses. " +
+ "There is no exception for short messages or the end of a turn.\n\n" +
+ "Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
+ "**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
+ "Put the complete message in `message`. Markdown and LaTeX are supported. " +
+ "Do not duplicate it in ordinary response text.\n\n" +
+ "Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
+ "commentary through `send_to_user` before calling that tool.\n\n" +
+ "This tool does not pause or end your turn. Continue with other tools when needed. " +
+ "After delivering your final message, end the turn without additional ordinary response text.",
+ parameters: sendToUserInput,
+ executionMode: "sequential",
+ execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
+};
diff --git a/src/core/answer-presentation.ts b/src/core/answer-presentation.ts
new file mode 100644
index 0000000..ffc61ef
--- /dev/null
+++ b/src/core/answer-presentation.ts
@@ -0,0 +1,57 @@
+/** Read-only provenance for historical question cards. Never persist this
+ * projection: answer identities, ordering, grading, and learner text remain
+ * the recorded facts; only model-authored display text comes from the call. */
+import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
+import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
+import { repairMathForDisplay } from "./math-delims.ts";
+
+export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
+ if (answer.dismissed) return answer;
+ const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
+ const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
+ const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
+ if (!source) return answer;
+ const projected = { ...answer, questionText: source.question };
+ if (quiz) projected.explanation = quiz.explanation;
+ if (explain) projected.committed = explain.correctAnswer;
+ if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
+
+ const original = normalizeOptions(source.options);
+ const saved = answer.options;
+ const bindings = new Map(); // saved position -> original position
+ const used = new Set();
+ const bindUnique = (candidates: number[][]) => {
+ for (let si = 0; si < saved.length; si++) {
+ if (bindings.has(si)) continue;
+ const possible = candidates[si]!.filter((oi) => !used.has(oi));
+ if (possible.length !== 1) continue;
+ const oi = possible[0]!;
+ // Two saved rows claiming the same source are ambiguous, even if
+ // each row independently has just one candidate.
+ if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
+ bindings.set(si, oi);
+ used.add(oi);
+ }
+ };
+ // Selected-answer values are stable identities when the record retained
+ // them. Do not normalize those values, recompute a shuffle, or regrade.
+ bindUnique(saved.map((option) => {
+ const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
+ return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
+ }));
+ const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
+ bindUnique(saved.map((option) => original.flatMap((o, oi) =>
+ o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
+ // A bijection with one remaining pair has no ordering ambiguity. With
+ // multiple unmatched rows, keep their snapshots rather than guess.
+ if (saved.length === original.length && bindings.size === saved.length - 1) {
+ const si = saved.findIndex((_, i) => !bindings.has(i));
+ const oi = original.findIndex((_, i) => !used.has(i));
+ bindings.set(si, oi);
+ }
+ projected.options = saved.map((option, si) => {
+ const oi = bindings.get(si);
+ return oi === undefined ? option : { ...option, label: original[oi]!.label };
+ });
+ return projected;
+}
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/auth-keys.ts b/src/core/auth-keys.ts
index e8e5cc9..b92faf7 100644
--- a/src/core/auth-keys.ts
+++ b/src/core/auth-keys.ts
@@ -122,6 +122,19 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [
{ id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" },
] as const;
+/**
+ * THE single custom endpoint's identity: a user-configured OpenAI-compatible
+ * server registered into pi-ai at runtime (server/custom-provider.ts). NOT part
+ * of AUTH_PROVIDER_KEYS (that table is test-pinned to pi-ai's built-ins and is
+ * env-var-keyed); this one is keyed by a user-supplied base URL, and its key
+ * rides this reserved env var (optional — keyless local servers allowed).
+ */
+export const CUSTOM_PROVIDER = {
+ id: "custom",
+ envVar: "CUSTOM_OPENAI_API_KEY",
+ label: "Custom endpoint",
+} as const;
+
/** Every distinct env var a model-provider key rides in (the write side's
* writability check — see isProviderEnvVar below; also drives which
* PRESENT provider vars surface as status rows). */
@@ -194,7 +207,8 @@ export interface AuthKeyStatus {
info: AuthKeyInfo | null;
/** A key is present when EITHER the environment or the file has it. */
present: boolean;
- /** The masked tail + character count, e.g. "…ab12 (64 chars)". */
+ /** Ordinary masked tail + count, e.g. "…ab12 (64 chars)"; custom keys
+ * use full redaction, with no credential characters at any length. */
masked: string | null;
/** Where the live value comes from, given env-wins precedence. */
source: "env" | "file" | "none";
diff --git a/src/core/compaction.ts b/src/core/compaction.ts
index 8e3f768..4ca0fab 100644
--- a/src/core/compaction.ts
+++ b/src/core/compaction.ts
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
};
}
-/** The ACTIVE compaction marker: the newest one in the record (chained
- * compactions — the last one rules; older markers fall inside the
- * dropped span and never render). */
+/** The ACTIVE compaction marker: the newest one with a retained-message
+ * id. Its summary supersedes older markers even when their stored
+ * positions remain inside its retained tail. */
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
diff --git a/src/core/content-reader.ts b/src/core/content-reader.ts
new file mode 100644
index 0000000..a774094
--- /dev/null
+++ b/src/core/content-reader.ts
@@ -0,0 +1,215 @@
+/** Source-preserving boundaries for tool-authored Markdown. Recovery never
+ * mutates input, reserializes Markdown, or enters protected literal content. */
+import katex from "katex";
+import { fromMarkdown } from "mdast-util-from-markdown";
+import { mathFromMarkdown } from "mdast-util-math";
+import { math } from "micromark-extension-math";
+
+export type ContentSpan =
+ | { kind: "prose" | "code"; raw: string }
+ | { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
+const blankLine = /^\r?\n[ \t]*\r?\n/;
+const escaped = (text: string, i: number) => {
+ let n = 0; while (i > 0 && text[--i] === "\\") n++;
+ return n % 2 === 1;
+};
+const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
+type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
+/** Use the rendering stack's actual CommonMark+math grammar for literal
+ * ranges, including nested quotes/lists and implicit container endings.
+ * Keep source offsets: never serialize the AST back into Markdown. */
+function literalRanges(text: string): Array<{ start: number; end: number }> {
+ const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
+ const ranges: Array<{ start: number; end: number }> = [];
+ while (pending.length) {
+ const node = pending.pop()!;
+ if (node.type === "code" || node.type === "inlineCode") {
+ const start = node.position?.start.offset, end = node.position?.end.offset;
+ if (start !== undefined && end !== undefined) ranges.push({ start, end });
+ } else if (node.children) pending.push(...node.children);
+ }
+ return ranges.sort((a, b) => a.start - b.start);
+}
+
+/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
+ * with code and blank paragraphs as inline boundaries. */
+export function readContentSpans(text: string): ContentSpan[] {
+ const spans: ContentSpan[] = [];
+ const literals = literalRanges(text);
+ let literal = 0, i = 0, plain = 0;
+ const opaque = (end: number) => {
+ if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
+ spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
+ };
+ while (i < text.length) {
+ while (literal < literals.length && literals[literal]!.end <= i) literal++;
+ const barrier = literals[literal]?.start ?? Infinity;
+ if (i >= barrier) { opaque(literals[literal]!.end); continue; }
+ let open = "", close = "", display = false, after = -1, at = -1;
+ if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
+ open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
+ for (let j = i + 2; j < text.length - 1; j++) {
+ if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
+ if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
+ }
+ } else if (text[i] === "$" && !escaped(text, i)) {
+ const size = runLength(text, i, "$"); open = "$".repeat(size);
+ const eol = text.indexOf("\n", i + size);
+ const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
+ display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
+ if (display) {
+ let start = eol;
+ while (start >= 0 && start < text.length) {
+ start++;
+ const next = text.indexOf("\n", start);
+ const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
+ if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
+ start = next;
+ }
+ } else {
+ for (let j = i + size; j < text.length;) {
+ if (j >= barrier || blankLine.test(text.slice(j))) break;
+ if (text[j] === "$" && !escaped(text, j)) {
+ const n = runLength(text, j, "$");
+ if (n === size) { at = j; close = open; after = j + n; break; }
+ j += n;
+ } else j++;
+ }
+ }
+ }
+ if (at >= 0) {
+ if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
+ spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
+ i = plain = after;
+ } else i += open.length || 1;
+ }
+ if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
+ return spans;
+}
+
+export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
+ return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
+}
+
+const knownCommands = new Map();
+/** Probe only a command name, never arbitrary content. Missing-argument errors
+ * still identify a known command. No trust, macros, IO, or persistent TeX state. */
+export function knownTexCommand(name: string): boolean {
+ if (!/^[A-Za-z]+$/.test(name)) return false;
+ const cached = knownCommands.get(name); if (cached !== undefined) return cached;
+ let known = true;
+ try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
+ catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
+ knownCommands.set(name, known);
+ return known;
+}
+
+export function canParseTex(body: string, display: boolean): boolean {
+ try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
+ catch { return false; }
+}
+
+export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
+export function numericPercentEscape(inner: string, percent: number): boolean {
+ let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
+ if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
+ const tail = inner.slice(percent + 1);
+ // A final source-formatting LF before the closing fence is not a next
+ // equation row. A meaningful following line or comment text remains literal.
+ if (/^[\s.)\]}]*$/.test(tail)) return true;
+ return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
+}
+
+/** TeX boundaries shared by document decoding and command recovery. Native
+ * rows are literal; consistently extra-escaped environment pairs supply an
+ * explicit escaping factor for both commands AND row separators. */
+export function texRanges(inner: string): TexRange[] {
+ const ranges: TexRange[] = [];
+ const stack: Array<{ name: string; start: number; factor: number }> = [];
+ let i = 0;
+ while (i < inner.length) {
+ if (inner[i] === "%" && !escaped(inner, i)) {
+ let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
+ const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
+ const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
+ const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
+ if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
+ const newline = inner.indexOf("\n", i);
+ const end = newline < 0 ? inner.length : newline + 1;
+ ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
+ }
+ if (inner[i] !== "\\") { i++; continue; }
+ let end = i + 1; while (inner[end] === "\\") end++;
+ const count = end - i;
+ const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
+ if (marker) {
+ const factor = count % 2 === 1 ? 1 : count;
+ if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
+ else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
+ const open = stack.pop()!;
+ ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
+ }
+ i = end + marker[0].length; continue;
+ }
+ // KaTeX's substack macro expands its braced argument into subarray:
+ // these are row separators even without literal begin/end markers.
+ const substack = /^substack\s*\{/.exec(inner.slice(end));
+ if (substack) {
+ let depth = 1, j = end + substack[0].length;
+ for (; j < inner.length && depth > 0; j++) {
+ if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
+ if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
+ }
+ if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
+ }
+ i = end;
+ }
+ return ranges.sort((a, b) => a.start - b.start);
+}
+
+export function outsideTexComments(body: string, transform: (part: string) => string): string {
+ let from = 0; const out: string[] = [];
+ for (const range of texRanges(body).filter(r => r.kind === "comment")) {
+ out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
+ }
+ out.push(transform(body.slice(from))); return out.join("");
+}
+
+/** Document escapes are not arbitrary string decoding. Complete known TeX
+ * words win over the prefix `\\n`; code is never inspected or rewritten. */
+export function recoverDocumentLayout(text: string): string {
+ const spans = readContentSpans(text);
+ const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
+ const declared = new Set([
+ ...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
+ ...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
+ ].map(m => m[1]!));
+ const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
+ const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
+ const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
+ const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
+ [...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
+ if (!structural) return text;
+ const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
+ if (slashes.length % 2 === 0 || knownN(name)) return whole;
+ const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
+ if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
+ return slashes.slice(0, -1) + "\n" + name.slice(1);
+ });
+ const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
+ let out = "";
+ for (let i = 0; i < projected.length; i++) {
+ const { span, body } = projected[i]!;
+ if (span.kind !== "math") { out += body; continue; }
+ // Decoding document layout can expose a flow opener whose close was
+ // written on the formula line. Frame BOTH ends; otherwise Markdown
+ // consumes the closing dollars as TeX and introduces a rendering error.
+ if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
+ if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
+ out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
+ const next = projected[i + 1];
+ if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
+ } else out += span.open + body + span.close;
+ }
+ return out;
+}
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/jobs.ts b/src/core/contracts/jobs.ts
index 1b14519..144e974 100644
--- a/src/core/contracts/jobs.ts
+++ b/src/core/contracts/jobs.ts
@@ -21,6 +21,12 @@ export interface JobRecord {
chatId: string;
/** The tool part this job fulfills. */
toolCallId: string;
+ /** Dispatch-time arguments, saved before spawn. Older jobs resolve from history. */
+ input?: unknown;
+ /** Session-owned delivery, independent of the worker's terminal outcome.
+ * Absent on legacy records: old terminal jobs must never be replayed. */
+ delivery?: { kind: "result" | "abort"; status: "pending" | "delivered" };
+ /** Worker computation only; a pending delivery does not keep it running. */
status: JobStatus;
/** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */
activity: string;
@@ -148,42 +154,32 @@ export function accumulateJobUsage(
* sourced briefs) — tracking a live activity line in the job record.
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
* job surface: the transcript dispatch cards (live activity by
- * toolCallId, quiet one-liner after delivery), the sub-agents sidebar
+ * toolCallId, quiet one-liner after worker completion), the sub-agents sidebar
* panel, and the single re-sync when a job settles.
*
- * 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
- * and hands it to the wake pipeline (src/server/jobs.ts): a user-role
- * result-carrying native marker — the content is what the MODEL needs
- * (the research brief, the builder's notes), plus a data-subagent-artifact
- * part with what the UI renders (the full result: brief + sources /
- * SVG / demo HTML). Provider conversion drops data parts, so artifact
- * payloads never enter the teacher's context. metadata.subagentResult
- * identifies the job; the UI renders these as sub-agent result cards,
- * NEVER as learner interjections.
+ * 3. Dispatch saves input on the job before spawn. On completion the
+ * runner saves result + terminal worker status + pending delivery in
+ * one job update, then queues buildSubagentSteer WITHOUT waiting for
+ * delivery. The native custom marker's content is model-facing text;
+ * details carry the full artifact payload for the UI. Worker resources
+ * are released even if a question keeps the result pending indefinitely.
+ * Only computing workers appear in Background agents or accept abort.
*
- * 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
- * turn through the same turn composition as a client request
- * (handleChatRequest) — write-ahead persist, turn liveness, extraction
- * trigger: one code path. A per-chat serialized queue: a steer waits
- * while a turn is in flight OR a question tool part is pending (an
- * unanswered client-side tool call breaks provider conversion — the
- * steer fires right after the learner's continuation), then fires; a 409
- * (a client turn raced in) retries. Delivery is idempotent per jobId, so
- * a crashed wake can be re-entered by the stale-job recovery sweep. The
- * job's status flips to done/error only AFTER the wake turn completes —
- * a polling client that sees the flip re-syncs once and gets the
- * finished record.
+ * 4. Session delivery owns one registry entry per deterministic steer ID,
+ * including while a wake is in flight or a 409/403 returns its cargo.
+ * A live agent takes it via pi steering; registration restores saved
+ * pending jobs before the answer run's first poll. Otherwise the idle
+ * wake uses the normal turn composition and respects pending questions,
+ * dismissal, and background consent. A saved transcript marker completes
+ * delivery; HTTP 200 or streamed visibility alone does not.
*
- * CONTINUATION COALESCING: the idle window above can starve — a
- * quiz-chaining teacher ends every turn with a new pending check, so
- * the gates never stay open long enough for a wake to fire. The fix:
- * when a client turn wins the turn registry, the handler CLAIMS every
- * queued, undelivered steer (jobs.ts claimSteers) and appends it after
- * the learner's answer in that very turn — conversion-safe, because the
- * answered quiz carries its output. Every answer flushes pending
- * steers immediately; the wake pump remains for turns that end with no
- * pending question. The pump re-checks steerDelivered right before
- * firing, so a claim racing the pump converges to exactly one delivery.
+ * Failed starts remain quietly pending until a learner run or restart
+ * recovery, without new timers, visible delivery errors, or worker-status
+ * changes. Existing 409 backoff and consent deferral remain. Accepted
+ * turns are never automatically replayed. Legacy running records with a
+ * result become finished/pending; legacy terminal records with no
+ * delivery metadata remain untouched. Session deletion discards pending
+ * deliveries rather than treating them as running work.
*
* 5. No pending job parts ever exist, so the composer never gates on jobs
* (the learner can interject mid-job; the steer queues behind their
diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts
index 2819bb5..22000ec 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
@@ -78,8 +80,8 @@ export interface ModelCatalogResponse {
/** One row of the providers surface. */
export interface CatalogProvider {
id: string;
- /** The env var whose presence federates this provider in. */
+ /** The gating env var; empty for subscription and file-configured routes. */
envVar: string;
- /** Whether that var is currently present (env or auth file). */
+ /** Whether credentials are present (and custom references resolve). */
keyPresent: boolean;
}
\ No newline at end of file
diff --git a/src/core/coverage.ts b/src/core/coverage.ts
index fe5b523..50c3ce0 100644
--- a/src/core/coverage.ts
+++ b/src/core/coverage.ts
@@ -1,9 +1,9 @@
/**
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
- * text blocks — no learner messages, no thinking, no tool calls or
- * results, no quiz blocks. Everything else is INVISIBLE to coverage: it
- * neither opens nor joins a run, so answering a quiz or editing a learner
+ * prose — ordinary text blocks and explicit send_to_user messages. No
+ * learner messages, thinking, other tool calls/results, or quiz blocks.
+ * Everything else is INVISIBLE to coverage: it neither opens nor joins a run, so answering a quiz or editing a learner
* message triggers nothing. "Covered" is by construction exactly "what
* the extractor saw": the fingerprint (sha256 of a message's view lines)
* and the extractor's prompt share ONE selection — the teacher prose.
@@ -22,33 +22,27 @@
import { createHash } from "node:crypto";
import type { CoverageEntry } from "./contracts/graph.ts";
-import type { LhMessage } from "./native-messages.ts";
+import { failedOutputCallIds, userFacingTextOf, type LhMessage } from "./native-messages.ts";
export type { CoverageEntry };
-/** The user-facing text of a native message (string or text blocks). */
-function textOf(content: unknown): string[] {
- if (typeof content === "string") return content.trim() ? [content] : [];
- if (!Array.isArray(content)) return [];
- return content
- .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
- .map((c) => c.text)
- .filter((t) => t.trim().length > 0);
-}
-
/**
* The extraction-relevant lines of ONE native message — the shared seam
* between the extractor's prompt and messageFingerprint (coverage).
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
- * line per text block; everything else (user messages, tool results,
- * markers) yields NOTHING and is invisible to coverage. `mid` is the
- * message id — part of the hash input, so the same prose in two messages
+ * line per learner-facing prose block; everything else (user messages,
+ * tool results, markers) yields NOTHING and is invisible to coverage.
+ * `mid` is the message id — part of the hash input, so the same prose in two messages
* is two slots (per-message coverage).
*/
-export function messageViewLines(message: LhMessage, mid: string): string[] {
+export function messageViewLines(
+ message: LhMessage,
+ mid: string,
+ failedOutputCalls: ReadonlySet = failedOutputCallIds([message]),
+): string[] {
const lines: string[] = [];
if ((message as { role?: string }).role !== "assistant") return lines;
- for (const text of textOf((message as { content: unknown }).content)) {
+ for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) {
lines.push(`TEACHER [${mid}] ${text.trim()}`);
}
return lines;
@@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] {
*/
export function teacherProseBlocks(messages: ReadonlyArray): string[] {
const out: string[] = [];
+ const failedOutputCalls = failedOutputCallIds(messages);
for (const message of messages) {
if ((message as { role?: string }).role !== "assistant") continue;
- for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim());
+ for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) out.push(text.trim());
}
return out;
}
@@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number {
* teacher prose, with the message id as the tag). Changes exactly when
* the teacher prose of the message changes — nothing else can re-open it.
*/
-export function messageFingerprint(message: LhMessage): string {
+export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet): string {
const h = createHash("sha256");
- h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)]));
+ h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id, failedOutputCalls)]));
return h.digest("hex");
}
@@ -95,7 +90,12 @@ export interface CoverageSlot {
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
* and the coverage view — one derivation everywhere). */
export function coverageSlots(messages: ReadonlyArray): CoverageSlot[] {
- return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) }));
+ const failedOutputCalls = failedOutputCallIds(messages);
+ return messages.map((message) => ({
+ message,
+ fp: messageFingerprint(message, failedOutputCalls),
+ tokens: viewTokens(messageViewLines(message, message.id, failedOutputCalls)),
+ }));
}
/** The covered-fingerprint set for one chat, from the ledger. */
@@ -131,16 +131,14 @@ export function coverageOf(
let uncoveredTokens = 0;
for (let i = 0; i < slots.length; i++) {
const slot = slots[i]!;
- if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible
+ if (slot.tokens === 0) continue; // invisible (including failed output calls)
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
else {
uncovered.push(i);
uncoveredTokens += slot.tokens;
}
}
- const visibleCount = slots.length - slots.filter(
- (s) => messageViewLines(s.message, s.message.id).length === 0,
- ).length;
+ const visibleCount = slots.filter((s) => s.tokens > 0).length;
return {
slots,
uncovered,
diff --git a/src/core/custom-provider-auth.ts b/src/core/custom-provider-auth.ts
new file mode 100644
index 0000000..8f161ee
--- /dev/null
+++ b/src/core/custom-provider-auth.ts
@@ -0,0 +1,26 @@
+/** Private custom credential authorization; no credential-derived public value. */
+import { normalizeBaseUrl, type CustomProviderSettings } from "./settings.ts";
+
+export const CUSTOM_AUTH_BINDING = "__learnharness_custom_provider_binding";
+export interface CustomAuthBinding {
+ version: 1;
+ id: string;
+ baseUrl?: string;
+ authMode: "apiKey" | "keyless" | "disabled";
+ /** A shadowed file key keeps its own destination when an env key moves. */
+ fileKeyBaseUrl?: string;
+}
+export function parseCustomAuthBinding(value: unknown): CustomAuthBinding | undefined {
+ try {
+ const b = typeof value === "string" ? JSON.parse(value) : undefined;
+ if (!b || b.version !== 1 || typeof b.id !== "string" || !/^[a-f0-9]{32}$/.test(b.id)) return;
+ if (!["apiKey", "keyless", "disabled"].includes(b.authMode)) return;
+ if (b.authMode !== "disabled" && b.baseUrl === undefined) return;
+ if (b.baseUrl !== undefined && (typeof b.baseUrl !== "string" || !b.baseUrl || normalizeBaseUrl(b.baseUrl) !== b.baseUrl)) return;
+ if (b.fileKeyBaseUrl !== undefined && (typeof b.fileKeyBaseUrl !== "string" || !b.fileKeyBaseUrl || normalizeBaseUrl(b.fileKeyBaseUrl) !== b.fileKeyBaseUrl)) return;
+ return { version: 1, id: b.id, authMode: b.authMode, ...(b.baseUrl !== undefined ? { baseUrl: b.baseUrl } : {}), ...(b.fileKeyBaseUrl !== undefined ? { fileKeyBaseUrl: b.fileKeyBaseUrl } : {}) };
+ } catch { return; }
+}
+export function customBindingMatches(cp: CustomProviderSettings | undefined, binding: CustomAuthBinding | undefined): boolean {
+ return Boolean(cp?.baseUrl && binding && binding.authMode !== "disabled" && cp.credentialBindingId === binding.id && cp.baseUrl === binding.baseUrl && cp.authMode === binding.authMode);
+}
diff --git a/src/core/dictation.ts b/src/core/dictation.ts
new file mode 100644
index 0000000..45d6826
--- /dev/null
+++ b/src/core/dictation.ts
@@ -0,0 +1,90 @@
+/** Nova-3 monolingual language codes, including documented locale aliases.
+ * Source: https://developers.deepgram.com/docs/models-languages-overview
+ * Multilingual is a separate mode; its supported language set differs.
+ */
+const NOVA_3_LANGUAGES = [
+ ["Afrikaans", ["af", "af-ZA"]],
+ ["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]],
+ ["Armenian", ["hy"]],
+ ["Assamese", ["as", "as-IN"]],
+ ["Belarusian", ["be"]],
+ ["Bengali", ["bn"]],
+ ["Bosnian", ["bs"]],
+ ["Bulgarian", ["bg"]],
+ ["Catalan", ["ca"]],
+ ["Chinese (Cantonese, Traditional)", ["zh-HK"]],
+ ["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]],
+ ["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]],
+ ["Croatian", ["hr"]],
+ ["Czech", ["cs", "cs-CZ"]],
+ ["Danish", ["da", "da-DK"]],
+ ["Dutch", ["nl"]],
+ ["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]],
+ ["Estonian", ["et"]],
+ ["Finnish", ["fi"]],
+ ["Flemish", ["nl-BE"]],
+ ["French", ["fr", "fr-CA"]],
+ ["Georgian", ["ka", "ka-GE"]],
+ ["German", ["de"]],
+ ["German (Switzerland)", ["de-CH"]],
+ ["Greek", ["el"]],
+ ["Gujarati", ["gu", "gu-IN"]],
+ ["Hebrew", ["he"]],
+ ["Hindi", ["hi"]],
+ ["Hungarian", ["hu"]],
+ ["Indonesian", ["id"]],
+ ["Italian", ["it"]],
+ ["Japanese", ["ja"]],
+ ["Kannada", ["kn"]],
+ ["Kazakh", ["kk", "kk-KZ"]],
+ ["Korean", ["ko", "ko-KR"]],
+ ["Latvian", ["lv"]],
+ ["Lithuanian", ["lt"]],
+ ["Macedonian", ["mk"]],
+ ["Malay", ["ms"]],
+ ["Marathi", ["mr"]],
+ ["Mongolian", ["mn"]],
+ ["Nepali", ["ne"]],
+ ["Norwegian", ["no"]],
+ ["Pashto", ["ps", "ps-AF"]],
+ ["Persian", ["fa"]],
+ ["Polish", ["pl"]],
+ ["Portuguese", ["pt", "pt-BR", "pt-PT"]],
+ ["Punjabi", ["pa", "pa-IN"]],
+ ["Romanian", ["ro"]],
+ ["Russian", ["ru"]],
+ ["Serbian", ["sr"]],
+ ["Slovak", ["sk"]],
+ ["Slovenian", ["sl"]],
+ ["Spanish", ["es", "es-419"]],
+ ["Swedish", ["sv", "sv-SE"]],
+ ["Tagalog", ["tl"]],
+ ["Tamil", ["ta"]],
+ ["Telugu", ["te"]],
+ ["Thai", ["th", "th-TH"]],
+ ["Turkish", ["tr", "tr-TR"]],
+ ["Ukrainian", ["uk"]],
+ ["Urdu", ["ur"]],
+ ["Vietnamese", ["vi"]],
+] as const;
+
+export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number];
+
+export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [
+ { code: "multi", label: "Multilingual" },
+ ...NOVA_3_LANGUAGES.flatMap(([name, codes]) =>
+ codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })),
+ ).sort((a, b) => a.label.localeCompare(b.label, "en")),
+];
+
+export function isDictationLanguage(value: unknown): value is DictationLanguage {
+ return DICTATION_LANGUAGES.some(({ code }) => code === value);
+}
+
+/** Only language varies. Keep the owner's tuned streaming parameters. */
+export function deepgramListenUrl(language: DictationLanguage): string {
+ return "wss://api.deepgram.com/v1/listen" +
+ "?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" +
+ "&smart_format=true&punctuate=true&endpointing=300" +
+ `&language=${encodeURIComponent(language)}`;
+}
diff --git a/src/core/events.ts b/src/core/events.ts
index e639006..dba4236 100644
--- a/src/core/events.ts
+++ b/src/core/events.ts
@@ -38,6 +38,8 @@ export interface ServerEvents {
"activity-changed": Record;
/** The session list changed (create, delete, title). */
"chats-changed": Record;
+ /** Web subscription state changed. No account or credential data on the bus. */
+ "subscriptions-changed": Record;
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
* Same notify-then-fetch contract as the turn events: open views of
* the chat refetch and adopt the durable record. */
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
"jobs-changed",
"activity-changed",
"chats-changed",
+ "subscriptions-changed",
"record-changed",
"compaction-changed",
] as const satisfies readonly ServerEventType[];
diff --git a/src/core/file-paths.ts b/src/core/file-paths.ts
new file mode 100644
index 0000000..960c93c
--- /dev/null
+++ b/src/core/file-paths.ts
@@ -0,0 +1,86 @@
+/** A lossless projection of plain learner text, not a Markdown parser. */
+export type FilePathPart =
+ | { kind: "text"; text: string; start: number; end: number }
+ | { kind: "path"; text: string; path: string; start: number; end: number };
+
+// No filesystem access or normalization: even punctuation and backslashes can
+// be legitimate POSIX filename characters. Only rooted syntax is required.
+function isFilePath(path: string): boolean {
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(path)) return false;
+ return path.startsWith("/")
+ || path.startsWith("~/")
+ || /^[a-z]:[\\/]/i.test(path)
+ || /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
+}
+
+/**
+ * Wrap a rooted path for storage/transmission as ordinary, lossless text.
+ * CommonMark's one-space padding prevents edge backticks joining delimiters.
+ */
+export function quoteFilePath(path: string): string {
+ if (!isFilePath(path)) throw new TypeError("Expected a single-line rooted file path");
+ let width = 1;
+ for (const run of path.matchAll(/`+/g)) width = Math.max(width, run[0].length + 1);
+ const delimiter = "`".repeat(width);
+ const padding = path.startsWith("`") || path.endsWith("`") ? " " : "";
+ return `${delimiter}${padding}${path}${padding}${delimiter}`;
+}
+
+/**
+ * Recognize every complete backtick-wrapped rooted path, even beside words.
+ * Delimiters are whole runs of exactly equal length; a run inside another
+ * code span is never reconsidered as a partial path token. Offsets are JS
+ * string (UTF-16) offsets and `text` always slices the exact original input.
+ */
+export function parseFilePaths(text: string): FilePathPart[] {
+ const runs = Array.from(text.matchAll(/`+/g), (match) => ({
+ start: match.index,
+ end: match.index + match[0].length,
+ width: match[0].length,
+ }));
+ // The next same-width run lets unmatched openers remain plain text without
+ // repeatedly scanning the rest of a long draft.
+ const next = new Map();
+ const closing = new Map();
+ for (let i = runs.length - 1; i >= 0; i--) {
+ const width = runs[i]!.width;
+ const match = next.get(width);
+ if (match !== undefined) closing.set(i, match);
+ next.set(width, i);
+ }
+
+ const parts: FilePathPart[] = [];
+ let plainStart = 0;
+ for (let i = 0; i < runs.length; i++) {
+ const closeIndex = closing.get(i);
+ if (closeIndex === undefined) continue;
+ const open = runs[i]!;
+ const close = runs[closeIndex]!;
+ let path = text.slice(open.end, close.start);
+ // CommonMark strips exactly one space at each edge, not all whitespace.
+ if (path.startsWith(" ") && path.endsWith(" ") && /[^ ]/.test(path)) {
+ path = path.slice(1, -1);
+ }
+ if (isFilePath(path)) {
+ if (plainStart < open.start) {
+ parts.push({ kind: "text", text: text.slice(plainStart, open.start), start: plainStart, end: open.start });
+ }
+ parts.push({ kind: "path", text: text.slice(open.start, close.end), path, start: open.start, end: close.end });
+ plainStart = close.end;
+ }
+ i = closeIndex;
+ }
+ if (plainStart < text.length) {
+ parts.push({ kind: "text", text: text.slice(plainStart), start: plainStart, end: text.length });
+ }
+ return parts;
+}
+
+/** Basename for display only; the original full path remains untouched. */
+export function filePathLabel(path: string): string {
+ const windows = /^[a-z]:[\\/]/i.test(path) || path.startsWith("\\\\");
+ const separator = windows ? /[\\/]/ : /\//;
+ const trimmed = windows ? path.replace(/[\\/]+$/, "") : path.replace(/\/+$/, "");
+ if (!trimmed || (windows && /^[a-z]:$/i.test(trimmed)) || trimmed === "~") return path;
+ return trimmed.split(separator).at(-1) || path;
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index c1d75f3..7b7d62d 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -42,22 +42,27 @@ export * from "./math-delims.ts";
export * from "./native-messages.ts";
export * from "./compaction.ts";
export * from "./input-events.ts";
+export { parseFilePaths, quoteFilePath, filePathLabel } from "./file-paths.ts";
+export type { FilePathPart } from "./file-paths.ts";
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
// canon (one continuous assistant document, step separators) ported from
// the deleted server projection to the client side. Pure, browser-safe.
export * from "./view.ts";
export * from "./tool-parts.ts";
+export * from "./answer-presentation.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
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
// view. Browser-safe — the Wave-4 panel imports these directly.
export * from "./settings.ts";
+export * from "./dictation.ts";
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port
// driven — the deletion RUNS server-side only. The shell needs exactly one
// two type-only things from it: the ChatDeleteMode union (the delete
diff --git a/src/core/input-events.ts b/src/core/input-events.ts
index 42d6985..714d7fb 100644
--- a/src/core/input-events.ts
+++ b/src/core/input-events.ts
@@ -16,6 +16,9 @@
* continue — run the next turn on the current record (mount
* continuations, job-settle re-syncs: the record ends at an
* answered question with no teacher reaction yet).
+ * retry — retry a failed model response (issue #110), without new
+ * input. Failed attempts stay in the record; only the retry's
+ * temporary agent history omits the trailing failures.
*
* (The plan verdict input died 2026-09-26 with the verdict itself — the
* learner's post-plan reply is a natural text message.)
@@ -45,6 +48,8 @@ export type ChatInput =
| { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "continue" }
+ /** The failed-turn retry (issue #110): the banner's Retry button. */
+ | { type: "retry" }
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
* whether it claims an answer is read from the text. One
@@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput {
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "continue":
return { type: "continue" };
+ case "retry":
+ return { type: "retry" };
case "thinking":
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
throw new ChatInputError("thinking input requires problemCallId");
@@ -210,6 +217,10 @@ export function inputAppends(
}
case "continue":
return dismissalConclusion(record);
+ case "retry":
+ // The input already landed write-ahead. retryDrops affects only
+ // the temporary agent history — nothing new appends here.
+ return [];
case "thinking": {
const active = activeProblemOf(record.messages);
if (!active || active.toolCallId !== input.problemCallId) {
@@ -233,6 +244,61 @@ export function inputAppends(
}
}
+/** The failure owning the ephemeral banner. Plan presentation and
+ * compaction do not resolve an error; substantive later input/output does.
+ * This is deliberately broader than Retry eligibility below. */
+export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract | undefined {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i]!;
+ if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue;
+ return m.role === "assistant" && m.stopReason === "error" ? m : undefined;
+ }
+ return undefined;
+}
+
+/** Does the record end in a failure with input that can be retried?
+ * Aborts are deliberate stops; compaction after a failure requires new
+ * learner input rather than a retry using a changed context. */
+export function endsInFailedTurn(messages: readonly LhMessage[]): boolean {
+ const { ids, before } = failedTail(messages);
+ return ids.size > 0 && before !== undefined && before.role !== "assistant";
+}
+
+/** The ids omitted ONLY from the retry's temporary agent history.
+ * Failed attempts (including partial text and their ids) stay on disk.
+ * Pi's provider conversion already skips error/aborted responses, but
+ * continue() rejects an assistant tail BEFORE conversion. Omitting the
+ * trailing failures lets that continuation start from the saved input or
+ * completed tool results without replaying tools or resubmitting input.
+ * Plan blocks may follow the failure and stay; later compaction blocks
+ * Retry without changing compaction itself. */
+export function retryDrops(messages: readonly LhMessage[]): Set {
+ const { ids, before } = failedTail(messages);
+ if (ids.size === 0) {
+ const failure = failedResponseAtEnd(messages);
+ if (failure) {
+ throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`);
+ }
+ throw new ChatInputError("retry input requires a failed turn to retry");
+ }
+ if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from");
+ return ids;
+}
+
+/** Walk back over failed assistants and UI-only plan blocks. A compaction
+ * is a boundary, not a marker Retry may cross. A failure AFTER a compaction
+ * can still retry: that compacted context was already its starting state. */
+function failedTail(messages: readonly LhMessage[]): { ids: Set; before: LhMessage | undefined } {
+ const ids = new Set();
+ let i = messages.length - 1;
+ for (; i >= 0; i--) {
+ const m = messages[i]!;
+ if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id);
+ else if (!(m.role === "custom" && m.customType === "plan-block")) break;
+ }
+ return { ids, before: messages[i] };
+}
+
/** The deferred conclude synthesis (owner ruling, 2026-10-04): a
* problem-dismissal marker waits for the user's next input — the pair
* (synthetic conclude call + ack) rides ahead of it, so the agent reads
diff --git a/src/core/locales/en.json b/src/core/locales/en.json
index a21f0fc..ba60509 100644
--- a/src/core/locales/en.json
+++ b/src/core/locales/en.json
@@ -715,6 +715,7 @@
"Other": "Other",
"Light": "Light",
"delivered": "delivered",
+ "finished": "finished",
"No such session": "No such session",
"A turn is running in this session — try again when it finishes": "A turn is running in this session — try again when it finishes",
"Background jobs are still running in this session — try again when they finish": "Background jobs are still running in this session — try again when they finish",
diff --git a/src/core/locales/ru.json b/src/core/locales/ru.json
index d876471..46a464a 100644
--- a/src/core/locales/ru.json
+++ b/src/core/locales/ru.json
@@ -746,6 +746,7 @@
"Other": "Другой вариант",
"Light": "Светлая",
"delivered": "доставлено",
+ "finished": "завершено",
"No such session": "Такого занятия нет",
"A turn is running in this session — try again when it finishes": "В этом занятии формируется ответ — дождитесь завершения и повторите попытку",
"Background jobs are still running in this session — try again when they finish": "В этом занятии ещё выполняются фоновые задачи — дождитесь их завершения",
diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts
index b939054..21d7bed 100644
--- a/src/core/math-delims.ts
+++ b/src/core/math-delims.ts
@@ -1,572 +1,121 @@
-/**
- * Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
- *
- * Current authoring uses `$...$` inline and `$$...$$` on separate lines for
- * display (owner, 2026-10-12). This pure transform keeps correctly formed
- * old backslash-delimited content readable; dollar math passes through to
- * Streamdown's native parser. Escape restoration below recognizes both
- * syntaxes while retaining the existing recovery rules.
- *
- * Rules:
- * - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
- * `\[...\]` → `$$\n...\n$$` (block form: flow math).
- * - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
- * - Nothing inside fenced code blocks or inline code spans is touched.
- * - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
- * an incomplete `\[` completes on a later chunk (the whole text is
- * re-transformed each render).
- *
- * Pure string → string. No markdown parsing — a single linear scan with a
- * code-state machine, cheap enough to run in the render path of a memo'd
- * component.
- */
+/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
+ * display-only tool-content recovery. Source arguments are never rewritten. */
+import {
+ canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
+ readContentSpans, recoverDocumentLayout, texRanges,
+} from "./content-reader.ts";
-const INLINE_OPEN = "\\(";
-const INLINE_CLOSE = "\\)";
-const DISPLAY_OPEN = "\\[";
-const DISPLAY_CLOSE = "\\]";
-
-const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
-
-/**
- * Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
- * backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
- * followed by a bracket — not a delimiter.
- */
-function isEscaped(text: string, i: number): boolean {
- let count = 0;
- for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
- return count % 2 === 1;
-}
-
-/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
-function backtickRun(text: string, i: number): number {
- let count = 0;
- while (text[i + count] === "`") count += 1;
- return count;
-}
-
-/**
- * Try to find the closing delimiter for the opening one at `i` (2 chars,
- * already verified unescaped). The close scan stops at inline code spans and
- * fence starts (barriers — math never contains them), and returns -1 when the
- * delimiter is unbalanced (mid-stream chunk).
- */
-function findClose(
- text: string,
- from: number,
- close: string,
-): number {
- for (let j = from; j < text.length - 1; j++) {
- if (text[j] === "`") return -1; // inline code barrier
- if (
- text[j] === "\\" &&
- text[j + 1] === close[1] &&
- !isEscaped(text, j)
- ) {
- return j;
- }
- }
- return -1;
-}
-
-/** True when `i` sits at the start of a line (position 0 or right after \n). */
-function atLineStart(text: string, i: number): boolean {
- return i === 0 || text[i - 1] === "\n";
-}
-
-/**
- * Copy a fenced code block starting at `i` (line start, fence regex matched)
- * verbatim to the output. Returns the index after the closing fence line —
- * or the end of the text when the fence is unclosed (mid-stream: everything
- * after is literal code until the chunk completes).
- */
-function copyFencedBlock(text: string, i: number, out: string[]): number {
- const open = FENCE_OPEN_RE.exec(text.slice(i));
- const marker = open?.[1] ?? "```";
- const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
- let j = i;
- // Copy lines until the matching closing fence.
- while (j < text.length) {
- const lineEnd = text.indexOf("\n", j);
- const next = lineEnd === -1 ? text.length : lineEnd + 1;
- const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
- out.push(text.slice(j, next));
- if (j > i && closeRe.test(line)) return next; // closing fence line
- j = next;
- }
- return text.length;
-}
-
-/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
-function displayIsBlockPositioned(text: string, i: number): boolean {
- for (let j = i - 1; j >= 0; j--) {
- const ch = text[j];
- if (ch === "\n") return true;
- if (ch !== " " && ch !== "\t") return false;
- }
- return true;
-}
-
-/** Is the `\[` at `i` indented (whitespace before it on the line)? */
-function displayIsIndented(text: string, i: number): boolean {
- let sawWhitespace = false;
- for (let j = i - 1; j >= 0; j--) {
- const ch = text[j];
- if (ch === "\n") return sawWhitespace;
- if (ch !== " " && ch !== "\t") return false; // mid-paragraph
- sawWhitespace = true;
- }
- return sawWhitespace;
-}
-
-/** Does the closing delimiter end its line (only \n or EOF after it)? */
-function closeEndsLine(text: string, afterClose: number): boolean {
- return afterClose >= text.length || text[afterClose] === "\n";
-}
-
-/**
- * Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
- * Everything else passes through byte-for-byte.
- */
-/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
- * mathText closer is code-span logic — ANY size-matched `$` run closes,
- * `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
- * own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
- * fine — the author's intent is unambiguous: an escaped dollar (odd number
- * of backslashes before it) never closes the span. Mirrors findDollarClose's
- * inline rules otherwise (exact size match, whole-run skip). */
-function findInlineDollarCloseEscapeAware(text: string, from: number): number {
- for (let j = from; j < text.length;) {
- if (text[j] !== "$") {
- j += 1;
- continue;
- }
- if (isEscaped(text, j)) {
- j += 1;
- continue; // `\$` never closes
- }
- const count = dollarRun(text, j);
- if (count === 1) return j;
- j += count;
- }
- return -1;
-}
-
-/** Replace `\$` (odd-backslash dollars) inside a math span with the
- * dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
- * `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
- * row break + dollar) are left alone. */
-function replaceEscapedDollars(content: string): string {
- let replaced = "";
- for (let j = 0; j < content.length;) {
- if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
- replaced += "\\text{\\textdollar}";
- j += 2;
- } else {
- replaced += content[j];
- j += 1;
- }
- }
- return replaced;
-}
-
-export function transformMathDelimiters(markdown: string): string {
- const out: string[] = [];
- let i = 0;
-
- while (i < markdown.length) {
- const ch = markdown[i]!;
-
- // Fenced code blocks: copied verbatim (only detectable at line start).
- if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
- i = copyFencedBlock(markdown, i, out);
- continue;
- }
-
- // Inline code spans: copied verbatim.
- if (ch === "`") {
- const run = backtickRun(markdown, i);
- let j = i + run;
- // Find the matching run (same length, per CommonMark).
- while (j < markdown.length) {
- const nextRun = backtickRun(markdown, j);
- if (nextRun === run) break;
- j += Math.max(nextRun, 1);
- }
- if (j < markdown.length) {
- out.push(markdown.slice(i, j + run));
- i = j + run;
- } else {
- // Unclosed run (mid-stream): literal, keep scanning after it.
- out.push(markdown.slice(i, i + run));
- i += run;
- }
- continue;
- }
-
- // Math delimiters.
- if (ch === "\\" && !isEscaped(markdown, i)) {
- const next = markdown[i + 1];
- if (next === "(" || next === "[") {
- const isDisplay = next === "[";
- const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
- const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
- const closeAt = findClose(markdown, i + open.length, close);
- if (closeAt !== -1) {
- const content = markdown.slice(i + open.length, closeAt).trim();
- if (content.length > 0) {
- if (isDisplay && displayIsIndented(markdown, i)) {
- // Indented display math (inside a list item, quote, …):
- // an indented $$ flow block half-parses inside list
- // continuations (empty math, eaten items), so degrade to
- // the inline form — renders inline, stays inside the item.
- // mathText cannot span line endings, so collapse them.
- out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
- } else if (isDisplay) {
- // Flow math ($$ alone on its lines) renders as display
- // KaTeX. Mid-paragraph \[...\] (or trailing text after the
- // closer) would half-parse into literal dollars + KaTeX
- // errors — pad with blank lines to force a clean block.
- // Block-positioned math passes through in place.
- const afterClose = closeAt + close.length;
- const padBefore =
- displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
- const padAfter =
- closeEndsLine(markdown, afterClose) ? "" : "\n\n";
- out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
- } else {
- // Preserve the legacy reader's two-dollar inline output
- // (renders inline, not display).
- out.push(`$$${content}$$`);
- }
- i = closeAt + close.length;
- continue;
- }
- }
- // Unbalanced or empty: leave both characters untouched.
- }
- }
-
- // Escaped dollars inside inline math (owner, 2026-10-12): normalize
- // BEFORE tokenization — inside a balanced inline span, `\$` becomes the
- // dollar-free `\text{\textdollar}` so the scanner's closer is the one
- // the author meant (micromark would otherwise close at the `\$`, error
- // out, and cascade). Display runs are copied whole and left untouched —
- // a lone `$` can't close `$$`, so `\$` already survives there — and
- // copying the run stops its second `$` posing as an inline opener.
- // Spans without escaped dollars emit byte-identical text; prose `\$`,
- // code, and unbalanced spans are untouched.
- if (ch === "$" && !isEscaped(markdown, i)) {
- const run = dollarRun(markdown, i);
- if (run === 1) {
- const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
- if (closeAt !== -1) {
- const content = markdown.slice(i + 1, closeAt);
- const replaced = replaceEscapedDollars(content);
- if (replaced !== content) {
- out.push(`$${replaced}$`);
- i = closeAt + 1;
- continue;
- }
- }
- } else {
- out.push(markdown.slice(i, i + run));
- i += run;
- continue;
- }
- }
-
- out.push(ch);
- i += 1;
- }
-
- return out.join("");
-}
-
-/**
- * LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
- *
- * When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
- * arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
- * resolution eats the command's first letter: `\f` → form feed + "orall",
- * `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
- * the persisted RAW args and renders as garbage. Inside a MATH SPAN these
- * control chars can never be legitimate content, so restoring the two-char
- * escape reconstructs the model's intent byte-perfectly. Outside spans the
- * same chars might be real prose (pasted tables, formatted text), so only
- * balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
- * unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
- * excluding code and escaped opening dollars. Unbalanced spans pass through.
- *
- * ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
- * restore all six debris chars — real newlines/tabs inside INLINE math
- * essentially never occur (options and inline math are single-line by
- * nature). Display spans restore five and KEEP real newlines — multi-line
- * display math (aligned environments, equation systems) is common, and a
- * real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
- * `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
- * legitimate content is strictly worse than failing to repair that rarest
- * slice, so display-span n-command sloppiness stays broken by design.
- */
-const ESCAPE_DEBRIS: Record = {
- "\b": "\\b",
- "\t": "\\t",
- "\n": "\\n",
- "\v": "\\v",
- "\f": "\\f",
- "\r": "\\r",
+const escaped = (text: string, i: number) => {
+ let n = 0; while (i > 0 && text[--i] === "\\") n++;
+ return n % 2 === 1;
};
-const DEBRIS_RE = /[\b\t\n\v\f\r]/;
-
-/** Display spans keep real newlines (multi-line equations) — debris there is
- * everything except \n (a lone real \r never occurs in authored text, and
- * `\right`/`\rho`/`\rangle` corruption is common in display math). */
-const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
-
-/** Shared display-only repair. Keep raw arguments and model history unchanged. */
-export function repairMathForDisplay(text: string): string {
- return healOverescapedCommands(restoreMathEscapes(text));
+/** Preserve the legacy list-continuation policy: indented display math
+ * degrades to inline rather than disrupting list structure. Literal code
+ * indentation is already protected by the shared reader. */
+function indented(text: string, i: number): boolean {
+ const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
+ return prefix.length > 0 && /^[ \t]+$/.test(prefix);
+}
+function blockPosition(text: string, i: number): boolean {
+ return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
}
+/** micromark closes native single-dollar spans at escaped dollars too.
+ * Replace an intended literal dollar with equivalent dollar-free TeX before
+ * tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
+function replaceEscapedDollars(content: string): string {
+ let out = "";
+ for (let i = 0; i < content.length; i++) {
+ if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
+ out += "\\text{\\textdollar}"; i++;
+ } else out += content[i];
+ }
+ return out;
+}
+
+/** Read old \(...\)/\[...\] syntax without touching literal content or
+ * reparsing native math as Markdown. Dollar authoring remains native. */
+export function transformMathDelimiters(markdown: string): string {
+ let offset = 0;
+ return readContentSpans(markdown).map(span => {
+ const start = offset; offset += span.raw.length;
+ if (span.kind !== "math") return span.raw;
+ if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
+ if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
+ const body = span.body.trim();
+ if (!body) return span.raw;
+ if (span.open === "\\(") return `$$${body}$$`;
+ if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
+ const before = blockPosition(markdown, start) ? "" : "\n\n";
+ const end = start + span.raw.length;
+ const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
+ return `${before}$$\n${body}\n$$${after}`;
+ }).join("");
+}
+
+const DEBRIS: Record = {
+ "\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
+};
+
+/** Recover JSON-swallowed command prefixes only in recognized math, never
+ * in code or genuine TeX comments. Restore all debris in one pass. Physical
+ * display newlines and CRLF remain source whitespace. Inline LF recovery is
+ * limited to complete known n-command names. Under the approved typical-
+ * model-authoring policy this includes newline+u→nu in inline math only;
+ * already-correct commands are never decoded into whitespace. */
export function restoreMathEscapes(text: string): string {
- if (!DEBRIS_RE.test(text)) return text;
- return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
+ if (!/[\b\t\n\v\f\r]/.test(text)) return text;
+ return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
+ const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
+ if (char === "\r" && part[offset + 1] === "\n") return char;
+ const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
+ return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
+ });
+ if (display) return restored;
+ return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
+ knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
+ }));
}
-/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
-function restoreLegacyMathEscapes(text: string): string {
- if (!DEBRIS_RE.test(text)) return text;
- const out: string[] = [];
- let i = 0;
- while (i < text.length) {
- const ch = text[i]!;
- if (ch === "\\" && !isEscaped(text, i)) {
- const next = text[i + 1];
- if (next === "(" || next === "[") {
- const isDisplay = next === "[";
- const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
- const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
- const closeAt = findClose(text, i + open.length, close);
- if (closeAt !== -1) {
- const inner = text.slice(i + open.length, closeAt);
- const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
- out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
- i = closeAt + close.length;
- continue;
- }
- }
- }
- out.push(ch);
- i += 1;
- }
- return out.join("");
-}
+const commandSymbol = /[,;:!|{}%]/;
-function dollarRun(text: string, i: number): number {
- let count = 0;
- while (text[i + count] === "$") count += 1;
- return count;
-}
-
-/** Native math fences start at a line's block position; one-line $$…$$
- * remains inline. A flow opener may have metadata, but never another $. */
-function dollarIsDisplay(text: string, i: number, size: number): boolean {
- if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
- const end = text.indexOf("\n", i + size);
- return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
-}
-
-/** Match micromark's delimiter runs: exact size inline, at least the
- * opener's size on an otherwise-empty line for display. Inside math,
- * backslashes are TeX data, not Markdown escapes for closing dollars. */
-function findDollarClose(
- text: string,
- from: number,
- size: number,
- isDisplay: boolean,
-): { at: number; after: number } | undefined {
- if (isDisplay) {
- let lineEnd = text.indexOf("\n", from);
- while (lineEnd !== -1) {
- const start = lineEnd + 1;
- lineEnd = text.indexOf("\n", start);
- const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
- const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
- if (close && close[2]!.length >= size) {
- const at = start + close[1]!.length;
- return { at, after: at + close[2]!.length };
- }
+/** Decode prefixes and row separators according to ORIGINAL TeX context.
+ * A native pair is literal; an encoded pair supplies a factor, so an encoded
+ * four-backslash row becomes TWO, never a command prefix. Unknown commands
+ * without that structural evidence are left alone. */
+function recoverCommandRuns(inner: string, display: boolean): string {
+ const ranges = texRanges(inner);
+ const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
+ const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
+ if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
+ const next = inner[offset + run.length] ?? "";
+ const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
+ const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
+ if (encoded) {
+ const factor = encoded.factor!;
+ const rows = run.length % (2 * factor) === 0;
+ const command = word ? knownTexCommand(word) : commandSymbol.test(next);
+ if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
+ return run;
}
- return undefined;
- }
- for (let j = from; j < text.length;) {
- if (text[j] !== "$") {
- j += 1;
- continue;
- }
- const count = dollarRun(text, j);
- if (count === size) return { at: j, after: j + count };
- j += count;
- }
- return undefined;
-}
-
-/** Dollar recognition only — the same six/five-character repair maps,
- * with no over-escaping normalization or mathematical validation. */
-function restoreDollarMathEscapes(text: string): string {
- if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
- const out: string[] = [];
- let i = 0;
- while (i < text.length) {
- if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
- i = copyFencedBlock(text, i, out);
- continue;
- }
- if (text[i] === "`") {
- const run = backtickRun(text, i);
- let j = i + run;
- while (j < text.length) {
- const nextRun = backtickRun(text, j);
- if (nextRun === run) break;
- j += Math.max(nextRun, 1);
- }
- const after = j < text.length ? j + run : i + run;
- out.push(text.slice(i, after));
- i = after;
- continue;
- }
- // Legacy spans have already received their own inline/display policy.
- // Do not reinterpret dollars inside them in this second pass.
- if (text[i] === "\\" && !isEscaped(text, i)) {
- const next = text[i + 1];
- if (next === "(" || next === "[") {
- const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
- if (closeAt !== -1) {
- out.push(text.slice(i, closeAt + 2));
- i = closeAt + 2;
- continue;
- }
- }
- }
- if (text[i] === "$" && !isEscaped(text, i)) {
- const size = dollarRun(text, i);
- const isDisplay = dollarIsDisplay(text, i, size);
- const close = findDollarClose(text, i + size, size, isDisplay);
- if (close) {
- const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
- out.push(
- text.slice(i, i + size) +
- text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
- text.slice(close.at, close.after),
- );
- i = close.after;
- continue;
- }
- out.push(text.slice(i, i + size));
- i += size;
- continue;
- }
- out.push(text[i]!);
- i += 1;
- }
- return out.join("");
-}
-
-/**
- * Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
- * backslash fix.
- *
- * Some models stochastically emit DOUBLED backslashes inside JSON tool-call
- * arguments: the saved record holds `\\nabla` (two real backslashes) where
- * `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
- * the command name as plain letters — the learner sees "the word nabla, not
- * the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
- * doubled per-call, per-field (one probe's question doubled while its
- * explanation and options in the SAME call stayed clean — authoring, not
- * transport), and deepseek-v4.1-flash once; quadrupling appeared when the
- * in-session agent "compensated" for its own doubled first attempt.
- *
- * THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
- * dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
- * backslash run (2, 4, …) immediately followed by a command start — a letter
- * (any command name; bare letters after a row break are never authored
- * content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
- * norm bars) — collapses to a single backslash. Even runs before anything
- * else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
- * stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
- * backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
- * so matrix content cannot be corrupted. Outside spans nothing is touched:
- * doubled backslashes in prose or code are not ours to judge. Unbalanced
- * spans pass through, exactly like the debris passes.
- */
-const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
-
-/** Collapse even backslash runs before a command start; keep odd runs. */
-function halveOverescapedCommandRuns(inner: string): string {
- return inner.replace(
- new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
- (run: string) => (run.length % 2 === 0 ? "\\" : run),
- );
+ if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
+ if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
+ return next && commandSymbol.test(next) ? "\\" : run;
+ });
+ // Structural evidence is primary (parse success cannot prove row
+ // preservation). As an additional guard, never turn parseable source into
+ // a parse failure through partial command recovery.
+ if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
+ return recovered;
}
export function healOverescapedCommands(text: string): string {
- if (!text.includes("\\\\")) return text;
- const out: string[] = [];
- let i = 0;
- while (i < text.length) {
- if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
- i = copyFencedBlock(text, i, out);
- continue;
- }
- if (text[i] === "`") {
- const run = backtickRun(text, i);
- let j = i + run;
- while (j < text.length) {
- const nextRun = backtickRun(text, j);
- if (nextRun === run) break;
- j += Math.max(nextRun, 1);
- }
- const after = j < text.length ? j + run : i + run;
- out.push(text.slice(i, after));
- i = after;
- continue;
- }
- if (text[i] === "\\" && !isEscaped(text, i)) {
- const next = text[i + 1];
- if (next === "(" || next === "[") {
- const isDisplay = next === "[";
- const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
- const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
- const closeAt = findClose(text, i + open.length, close);
- if (closeAt !== -1) {
- const inner = text.slice(i + open.length, closeAt);
- out.push(open + halveOverescapedCommandRuns(inner) + close);
- i = closeAt + close.length;
- continue;
- }
- }
- }
- if (text[i] === "$" && !isEscaped(text, i)) {
- const size = dollarRun(text, i);
- const isDisplay = dollarIsDisplay(text, i, size);
- const close = findDollarClose(text, i + size, size, isDisplay);
- if (close) {
- out.push(
- text.slice(i, i + size) +
- halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
- text.slice(close.at, close.after),
- );
- i = close.after;
- continue;
- }
- out.push(text.slice(i, i + size));
- i += size;
- continue;
- }
- out.push(text[i]!);
- i += 1;
- }
- return out.join("");
+ return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
+}
+
+/** Presentation-only, language-independent recovery. Shape parsing, saved
+ * identities, grading and model history must use the unprojected source. */
+export function repairMathForDisplay(text: string): string {
+ return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
}
diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts
index 2983e4f..5ec3a72 100644
--- a/src/core/native-messages.ts
+++ b/src/core/native-messages.ts
@@ -35,7 +35,8 @@
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 { QUESTION_TOOLS, type PendingQuestion } 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). */
@@ -98,6 +118,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[]
return messages as unknown as AgentMessage[];
}
+// ─── explicit learner-facing output ─────────────────────────────────────────
+
+export const SEND_TO_USER_TOOL = "send_to_user";
+
+/** Read the output tool's message without changing the native call. Also
+ * accepts partial arguments while streaming; malformed/empty inputs carry
+ * no prose. This projection is independent of the currently selected model. */
+export function sendToUserText(block: unknown): string | undefined {
+ if (!block || typeof block !== "object") return undefined;
+ const call = block as { type?: unknown; name?: unknown; arguments?: unknown };
+ if (call.type !== "toolCall" || call.name !== SEND_TO_USER_TOOL) return undefined;
+ if (!call.arguments || typeof call.arguments !== "object") return undefined;
+ const message = (call.arguments as { message?: unknown }).message;
+ return typeof message === "string" && message.trim() ? message : undefined;
+}
+
+/** Learner-facing prose, in content order. Only the explicit output tool
+ * joins ordinary text here — thinking and all other tool inputs stay out.
+ * Shared by coverage/extraction and Markdown; native history stays intact. */
+export function userFacingTextOf(content: unknown, failedOutputCalls?: ReadonlySet): string[] {
+ if (typeof content === "string") return content.trim() ? [content] : [];
+ if (!Array.isArray(content)) return [];
+ const out: string[] = [];
+ for (const block of content) {
+ if (!block || typeof block !== "object") continue;
+ if (block.type === "toolCall" && failedOutputCalls?.has(block.id)) continue;
+ const text = block.type === "text" ? block.text : sendToUserText(block);
+ if (typeof text === "string" && text.trim()) out.push(text);
+ }
+ return out;
+}
+
+/** Failed output calls remain retry context, not delivered teaching.
+ * Truncated calls can have valid-looking partial arguments. Failed/aborted
+ * generations do not execute their calls at all, so may have no result. */
+export function failedOutputCallIds(messages: readonly LhMessage[]): ReadonlySet {
+ const failed = new Set();
+ for (const message of messages) {
+ if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
+ failed.add(message.toolCallId);
+ } else if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
+ for (const block of message.content) {
+ if (block.type === "toolCall" && block.name === SEND_TO_USER_TOOL) failed.add(block.id);
+ }
+ }
+ }
+ return failed;
+}
+
// ─── id minting ──────────────────────────────────────────────────────────────
/** The app-wide message id shape (same as the UIMessage-era mint). */
@@ -502,9 +571,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set {
* - markers flatten to a user message carrying their content (the
* canonical text single-sourced in core/contracts); plan blocks are
* UI-only and filtered.
- * - dangling tool calls (an aborted turn's unanswered questions) get the
- * synthesized interrupted result — the provider protocol's hard
- * invariant: a tool call must be followed by its result.
+ * - dangling calls from successfully generated assistant messages (for
+ * example, an unanswered question) get a synthesized interrupted result.
+ * Error/aborted generations do not: Pi drops those assistant messages,
+ * so manufacturing their results would leave orphan results on the wire.
*
* Contract (pi-agent-core): never throws — an unknown shape passes
* through untouched rather than killing the loop.
@@ -513,8 +583,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
// (the newest — chained compactions rule last) names the first kept
// message; everything before it is replaced by the marker's content
- // (header + summary) rendered ONCE, up top. The marker itself is then
- // skipped in place — it must never render twice. A cut naming a
+ // (header + summary) rendered ONCE, up top. All compaction markers
+ // in the retained tail are then skipped, including older summaries
+ // whose stored positions still lie after the cut. A cut naming a
// missing id degrades honestly (everything renders — never throw).
const lhMessages = messages as readonly LhMessage[];
const active = activeCompactionOf(lhMessages);
@@ -555,11 +626,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// UI-only markers: plan blocks, and problem dismissals (the
// synthesized conclude pair carries the model's face).
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
- // The ACTIVE compaction marker already rendered as the head —
- // never twice. (Stale markers before the cut are unreachable; a
- // marker whose cut could not resolve has no head and renders as
- // its user text — honest degradation.)
- if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
+ // The head supersedes every compaction marker, not only its own
+ // stored position. Without a resolved head, preserve the existing
+ // full-history fallback, including the summary text.
+ if (marker.customType === "compaction" && head !== null) continue;
out.push({
role: "user",
content: [{ type: "text", text: marker.content }],
@@ -567,14 +637,15 @@ 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);
- // Dangling-call synthesis: an assistant message whose toolCalls have
- // no results in the remaining tail (abort mid-question) — the
- // provider rejects unanswered calls outright.
+ // Repair unanswered calls from completed generations, not calls in
+ // failed generations. Pi filters error/aborted assistants itself;
+ // adding results for their unexecuted calls would leave orphans.
if (role === "assistant") {
const assistant = m as AssistantMessage;
+ if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
const calls = assistant.content.filter((b) => b.type === "toolCall");
if (calls.length === 0) continue;
const answered = resultIdsFrom(messages.slice(i + 1));
@@ -602,31 +673,95 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
export function danglingQuestionOf(
messages: readonly LhMessage[],
): { toolCallId: string; tool: string; input: unknown } | null {
+ const question = questionOccurrenceOf(messages);
+ return question ? { toolCallId: question.toolCallId, tool: question.tool, input: question.input } : null;
+}
+
+/** The same authority, retaining native boundaries for the client dock.
+ * Unique call ids are stable even when a live message's local id is replaced
+ * by its persisted stamp. Only colliding ids need an occurrence-qualified
+ * draft/reset key; this never changes stored ids or the answer wire. */
+export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuestion | null {
+ // turn_end includes the temporary questionResult acknowledgement. The
+ // view ignores it too: it is NOT the learner's answer. Exclude it only
+ // from client authority, leaving persisted/server result policy intact.
+ const question = questionOccurrenceOf(messages.filter(message => !isLiveQuestionPlaceholder(message)));
+ if (!question) return null;
+ let occurrences = 0;
+ for (const message of messages) {
+ // A streamed c_ assistant can temporarily coexist with its saved
+ // copy during append-only adoption. New live calls are already
+ // uniquified; only settled history establishes reuse.
+ if (message.role !== "assistant" || message.id.startsWith("c_")) continue;
+ for (const call of message.content) {
+ if (call.type === "toolCall" && call.id === question.toolCallId) occurrences++;
+ }
+ }
+ return {
+ ...question,
+ key: occurrences > 1 ? JSON.stringify([question.messageId, question.contentIndex, question.toolCallId]) : question.toolCallId,
+ };
+}
+
+/** Only the transient c_ result with the questionResult contract qualifies.
+ * Real answers have details; errors/aborts remain resolving results. A saved
+ * null result is not reinterpreted by this client-only compatibility seam. */
+function isLiveQuestionPlaceholder(message: LhMessage): boolean {
+ return message.role === "toolResult" && message.id.startsWith("c_") &&
+ !message.isError && message.details === null &&
+ (QUESTION_TOOLS as readonly string[]).includes(message.toolName);
+}
+
+/** Shared native authority: newest assistant only, siblings in call order. */
+function questionOccurrenceOf(messages: readonly LhMessage[]): Omit | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage & { id: string };
const answered = resultIdsFrom(messages.slice(mi + 1));
- for (const block of assistant.content) {
+ for (const [contentIndex, block] of assistant.content.entries()) {
if (block.type !== "toolCall") continue;
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
if (answered.has(block.id)) continue;
- return { toolCallId: block.id, tool: block.name, input: block.arguments };
+ return {
+ toolCallId: block.id, tool: block.name, input: block.arguments,
+ messageId: assistant.id, contentIndex,
+ };
}
return null; // the newest assistant message's questions are all answered
}
return null;
}
-/** The probe→teach construction seam (chat-handler): a probe_done call in
- * the transcript — any state — means the probe is concluded: teach. */
-export function hasProbeDone(messages: readonly LhMessage[]): boolean {
+/** Successful probe_done result MESSAGE ids, in transcript order. A call
+ * from a failed/aborted generation grants no completion authority. Its
+ * later result must match both call id and tool name, must not be an
+ * error, and must not explicitly refuse with details.ok:false. Older
+ * clean acknowledgments without ok:true still count. No history changes. */
+export function probeDoneResultIds(messages: readonly LhMessage[]): Set {
+ const calls = new Set();
+ const results = new Set();
for (const m of messages) {
- if ((m as { role?: string }).role !== "assistant") continue;
- const assistant = m as AssistantMessage;
- if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
+ if (m.role === "assistant") {
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
+ for (const block of m.content) {
+ if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
+ }
+ } else if (
+ m.role === "toolResult" && m.toolName === "probe_done" &&
+ calls.has(m.toolCallId) && !m.isError &&
+ (m.details as { ok?: unknown } | null | undefined)?.ok !== false
+ ) {
+ results.add(m.id);
+ }
}
- return false;
+ return results;
+}
+
+/** The probe→teach construction seam (chat-handler and the tool guard):
+ * only a concluded call plus a matching successful result means teach. */
+export function hasProbeDone(messages: readonly LhMessage[]): boolean {
+ return probeDoneResultIds(messages).size > 0;
}
/** Every toolResult id in the record — answer-event validation (the call
diff --git a/src/core/pending.ts b/src/core/pending.ts
index b1ffd1a..923e1d6 100644
--- a/src/core/pending.ts
+++ b/src/core/pending.ts
@@ -38,10 +38,18 @@ export interface PendingToolCall {
input: unknown;
}
+/** Client-only identity: projection may group several native assistants.
+ * Never persisted or sent as part of an answer event. */
+export interface PendingQuestion extends PendingToolCall {
+ messageId: string;
+ contentIndex: number;
+ /** Unique call ids stay stable across live → persisted message stamps.
+ * Reused ids include their native occurrence instead. */
+ key: string;
+}
+
/** Tools that render in the question dock and gate the composer. The
- * probe-phase names (probe/recall, owner 2026-10-09) join their teach
- * twins — old records keep the teach names; new probe-phase calls carry
- * the phase names. */
+ * probe-phase names join their teach twins; old records keep teach names. */
export const QUESTION_TOOLS = [
"quiz",
"probe",
@@ -105,14 +113,20 @@ export function filterEnabledTools(tools: Record, enabled?: Enable
return out;
}
-function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null {
+function findPending(messages: UIMessage[], names: readonly string[], authority?: string | PendingQuestion): PendingToolCall | null {
for (let m = messages.length - 1; m >= 0; m--) {
const msg = messages[m]!;
for (let p = 0; p < msg.parts.length; p++) {
const part = msg.parts[p]!;
if (isToolUIPart(part) && part.state === "input-available") {
const tool = part.type.slice("tool-".length);
- if (names.includes(tool)) {
+ if (!names.includes(tool)) continue;
+ if (typeof authority === "object") {
+ if (part.toolCallId !== authority.toolCallId || tool !== authority.tool ||
+ part.sourceMessageId !== authority.messageId || part.sourceContentIndex !== authority.contentIndex) continue;
+ return authority;
+ }
+ if (authority === undefined || part.toolCallId === authority) {
return { toolCallId: part.toolCallId as string, tool, input: part.input };
}
}
@@ -121,8 +135,16 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo
return null;
}
-export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
- return findPending(messages, QUESTION_TOOLS);
+/** Grouped view messages lose native assistant boundaries. Native callers
+ * supply the exact occurrence, not just an id: its OWN part must have cleared
+ * validation. Never fall back to an older available question.
+ * null means no native pending question; undefined keeps view-only callers.
+ * The string overload is retained for view-only/id-filter consumers. */
+export function findPendingToolCall(messages: UIMessage[], authority: PendingQuestion | null): PendingQuestion | null;
+export function findPendingToolCall(messages: UIMessage[], authority?: string | null): PendingToolCall | null;
+export function findPendingToolCall(messages: UIMessage[], authority?: string | PendingQuestion | null): PendingToolCall | null {
+ if (authority === null) return null;
+ return findPending(messages, QUESTION_TOOLS, authority);
}
// ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ──────
diff --git a/src/core/phases.ts b/src/core/phases.ts
index 4a6e0f4..0ecd870 100644
--- a/src/core/phases.ts
+++ b/src/core/phases.ts
@@ -33,7 +33,7 @@
* Both are strictly sequential; the two derivations agree on every settled
* record (a call's part projects into the same view boundary).
*
- * Pure: no imports, no runtime deps — safe on server, client, and tests.
+ * Pure: only browser-safe core helpers — safe on server, client, and tests.
*/
/** The three phases of a session, as the picker / header / dividers see them. */
@@ -80,7 +80,10 @@ function advance(
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
// not its answer — an errored-and-retried call still proves teaching
// began, and the retry lands in the same group anyway).
- if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
+ if (
+ phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" &&
+ (part.output as { ok?: unknown } | null | undefined)?.ok !== false
+ ) {
phase = "plan";
}
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
@@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
-import type { LhMessage } from "./native-messages.ts";
+import { probeDoneResultIds, type LhMessage } from "./native-messages.ts";
/** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean
@@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts";
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
+ const concluded = probeDoneResultIds(messages.slice(0, last + 1));
for (let i = 0; i <= last; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "toolResult") {
- const result = m as { toolName?: string; isError?: boolean };
- if (
- phase === "probe" &&
- result.toolName === "probe_done" &&
- !result.isError
- ) {
- phase = "plan";
- }
+ if (phase === "probe" && concluded.has(m.id)) phase = "plan";
} else if (role === "assistant") {
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") {
diff --git a/src/core/ports.ts b/src/core/ports.ts
index 7fbbccc..6c4c63a 100644
--- a/src/core/ports.ts
+++ b/src/core/ports.ts
@@ -138,6 +138,8 @@ export interface CreateJobInit {
toolCallId: string;
/** First activity line (defaults to "starting"). */
activity?: string;
+ /** Saved on the job before its worker is spawned. */
+ input?: unknown;
}
/** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */
diff --git a/src/core/question-core.ts b/src/core/question-core.ts
index f0b3214..8ba6c87 100644
--- a/src/core/question-core.ts
+++ b/src/core/question-core.ts
@@ -511,6 +511,21 @@ export interface DockSelection {
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
+/** In-memory submission snapshot: survives the question form's optimistic
+ * unmount, never written to storage. Indices use the call's stable shuffle. */
+export interface QuestionFormDraft {
+ selection: DockSelection;
+ thinking: string;
+ other: string;
+ text: string;
+ confidenceIndex: number;
+}
+
+export interface QuestionDraft {
+ form?: QuestionFormDraft;
+ readingThinking?: string;
+}
+
/**
* Toggling a listed option. Single-select: the option becomes THE answer and
* displaces "Other". Multi-select: the option toggles alongside its siblings
diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts
index a9a0255..2b141d4 100644
--- a/src/core/session-markdown.ts
+++ b/src/core/session-markdown.ts
@@ -23,22 +23,15 @@ import { translator, type Language } from "./i18n.ts";
import type { LhMessage } from "./native-messages.ts";
import {
+ failedOutputCallIds,
PRACTICE_CONCLUDE_TOOL,
PRACTICE_PROBLEM_TOOL,
problemInputForConclude,
+ SEND_TO_USER_TOOL,
+ userFacingTextOf,
} from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
-/** The user-facing text blocks of a native message (string or text parts). */
-function textOf(content: unknown): string[] {
- if (typeof content === "string") return content.trim() ? [content] : [];
- if (!Array.isArray(content)) return [];
- return content
- .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
- .map((c) => c.text)
- .filter((t) => t.trim().length > 0);
-}
-
/** The practice join context (owner-approved, 2026-10-06 export round):
* practice tool RESULTS are acks — the statement, outcome, and verdict
* all live in the assistant's call arguments, and the model solution
@@ -49,6 +42,7 @@ export interface MarkdownContext {
messages: ReadonlyArray;
/** toolCallId → the call part's name + arguments. */
calls: Map | undefined }>;
+ failedOutputCalls?: ReadonlySet;
}
function markdownContext(messages: ReadonlyArray): MarkdownContext {
@@ -67,7 +61,7 @@ function markdownContext(messages: ReadonlyArray): MarkdownContext {
}
}
}
- return { messages, calls };
+ return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) };
}
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
@@ -176,16 +170,16 @@ export function messageMarkdown(
const parts: string[] = [];
if (role === "user") {
- for (const text of textOf((message as { content: unknown }).content)) {
+ for (const text of userFacingTextOf((message as { content: unknown }).content)) {
parts.push(`### ${t("Learner")}${anchor}\n\n${text.trim()}`);
}
} else if (role === "assistant") {
- for (const text of textOf((message as { content: unknown }).content)) {
+ for (const text of userFacingTextOf((message as { content: unknown }).content, ctx?.failedOutputCalls ?? failedOutputCallIds([message]))) {
parts.push(`### ${t("Teacher")}${anchor}\n\n${text.trim()}`);
}
} else if (role === "toolResult") {
const result = message as ToolResultMessage;
- if (result.isError) return null; // retry context, not learning evidence
+ if (result.isError || result.toolName === SEND_TO_USER_TOOL) return null; // retry context or output ack, not learning evidence
const { heading, body } = toolResultBody(result, ctx, language);
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
}
diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts
index 0438272..043c1a3 100644
--- a/src/core/settings-resolution.ts
+++ b/src/core/settings-resolution.ts
@@ -35,7 +35,10 @@ import {
type BackgroundModelRole,
type FactorySettings,
type GeneralSettings,
+ type DictationSettings,
type CompactionSettings,
+ type CustomModel,
+ type CustomModelOverride,
type GraphSettings,
type ModelSlot,
type ModelSlotName,
@@ -71,6 +74,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
/** The concrete view: every knob resolved, no absent fields. */
export interface EffectiveSettings {
general: GeneralSettings & { autoUpdate: boolean };
+ dictation: Required;
agent: {
models: Record;
/** Delta map as stored — consumers resolve absent key → enabled. */
@@ -95,6 +99,9 @@ export interface EffectiveSettings {
/** Compaction (owner round 14): resolved concrete — absent record
* section resolves to the factory 150k. */
compaction: Required;
+ /** Custom endpoint, resolved concrete: baseUrl defaults "", models and
+ * user pins default to empty. */
+ customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record; manualModels: string[] };
}
/** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own
@@ -187,6 +194,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
...(record?.general?.sttModel ? { sttModel: record.general.sttModel } : {}),
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
},
+ dictation: {
+ language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
+ },
agent: {
models,
tools: agent.tools ?? {},
@@ -227,6 +237,12 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
compaction: {
thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens,
},
+ customProvider: {
+ baseUrl: record?.customProvider?.baseUrl ?? "",
+ models: record?.customProvider?.models ?? [],
+ overrides: record?.customProvider?.overrides ?? {},
+ manualModels: record?.customProvider?.manualModels ?? [],
+ },
};
}
diff --git a/src/core/settings.ts b/src/core/settings.ts
index aba20db..8a12677 100644
--- a/src/core/settings.ts
+++ b/src/core/settings.ts
@@ -29,11 +29,13 @@
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";
import { isLanguage, isSttLanguage, type Language, type SttLanguage } from "./i18n.ts";
import { isSttModel, type SttModel } from "./stt.ts";
+import { isDictationLanguage, type DictationLanguage } from "./dictation.ts";
/** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */
@@ -157,10 +159,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,
@@ -171,7 +174,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
@@ -209,6 +212,98 @@ export interface ModelsSettings {
vision?: ModelSlot;
}
+// ─── Custom OpenAI-compatible endpoint ───────────────────────────────────────
+// ONE user-configured OpenAI-compatible server (vLLM / Ollama / a proxy). It is
+// one more provider in the Keys list, keyed by a base URL instead of an env var.
+// NON-SECRET config only (base URL + discovered model ids); the API key is a
+// secret in the auth store, never here. Registered into pi-ai as a normal
+// provider (server/custom-provider.ts).
+
+/** Budget fallbacks only; custom endpoint pricing is unknown. */
+export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
+export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
+
+/** Custom budgets accept every positive integer, including small output caps. */
+export const CUSTOM_MODEL_PIN_FLOOR = 1;
+
+/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
+ * detected from the endpoint's /models metadata; anything it omits falls back
+ * to a default. */
+export interface CustomModel {
+ id: string;
+ /** Accepts image input (Vision-capable). Default false (text-only). */
+ image?: boolean;
+ /** Accepts a reasoning effort (thinking). Default false → the only
+ * thinking level is "off", exactly like a non-reasoning built-in. */
+ reasoning?: boolean;
+ /** Context window in tokens, from the endpoint. Absent → the default. */
+ context?: number;
+ /** Max output tokens, from the endpoint. Absent → the default. */
+ maxTokens?: number;
+}
+
+/** A user pin for one custom model: it beats what the endpoint publishes
+ * (owner ruling — explicit user intent wins; a local server's number is
+ * often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
+ * true declares reasoning support, false removes that declaration, absent
+ * uses discovery. This is NOT a backend execution/disable switch. */
+export interface CustomModelOverride {
+ context?: number;
+ maxTokens?: number;
+ reasoning?: boolean;
+}
+
+/** The single custom endpoint's stored config (delta section). */
+export interface CustomProviderSettings {
+ /** Server-owned random authorization nonce; never a credential hash. */
+ credentialBindingId?: string;
+ /** Explicit authentication intent; never inferred from a saved key. */
+ authMode?: "keyless" | "apiKey";
+ /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
+ baseUrl?: string;
+ /** Models this endpoint offers: advertised ids merged with the typed
+ * manual ones (rewritten on connect; a failed probe keeps the same
+ * endpoint's last snapshot). */
+ models?: CustomModel[];
+ /** User pins keyed by model id — pure user intent, never rewritten by
+ * discovery, pruned on connect to the ids that connect offers. */
+ overrides?: Record;
+ /** Model ids the user typed by hand, for an endpoint that publishes no
+ * usable /models list. Kept apart from the snapshot so a reconnect can
+ * re-merge them after discovery refreshes. */
+ manualModels?: string[];
+}
+
+export function parseModelIds(raw: unknown): string[] {
+ const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
+ const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean);
+ return [...new Set(ids)];
+}
+
+/** Canonical base-URL form (single source): trims, requires absolute http(s),
+ * drops trailing slashes. Returns "" for anything invalid ("not configured"). */
+export function normalizeBaseUrl(raw: string): string {
+ const value = raw.trim();
+ if (!value) return "";
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return "";
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "";
+ if (url.username || url.password || url.search || url.hash || value.includes("?") || value.includes("#")) return "";
+ return url.toString().replace(/\/+$/, "");
+}
+
+/** THE single source of "is the custom endpoint usable?": a non-empty base URL.
+ * Key presence is deliberately excluded (keyless local servers are allowed). */
+export function customProviderUsable(
+ cp: CustomProviderSettings | undefined,
+): cp is CustomProviderSettings & { baseUrl: string } {
+ return Boolean(cp?.baseUrl);
+}
+
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
* to start chats until the user has actively picked all three slots —
* there ARE no defaults for 33 providers; a configured app is one whose
@@ -235,7 +330,7 @@ export const ROLE_SLOT: Readonly> = {
/** General settings — app-level behavior. Version DISPLAY and plans login
* live outside the record (package.json / auth.json) by owner decision. */
export interface GeneralSettings {
- /** Auto-update the app (default ON). Mechanism ships in a later wave. */
+ /** Automatic startup/24h update checks (default ON). Manual checks bypass this. */
autoUpdate?: boolean;
/** Absent = browser/system language (English for unsupported languages). */
language?: Language;
@@ -247,6 +342,11 @@ export interface GeneralSettings {
sttPreferredLanguages?: Language[];
}
+/** One app-wide dictation preference, shared by every composer. */
+export interface DictationSettings {
+ language?: DictationLanguage;
+}
+
/** Agent settings — everything about the non-conversation machinery. */
export interface AgentSettings {
/** Background-role model/thinking overrides (absent role = config pin). */
@@ -373,9 +473,14 @@ export interface CompactionSettings {
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;
+ /** The custom OpenAI-compatible endpoint (non-secret config only). */
+ customProvider?: CustomProviderSettings;
appearance?: AppearanceSettings;
graph?: GraphSettings;
compaction?: CompactionSettings;
@@ -390,6 +495,7 @@ export interface UserSettings {
export interface FactorySettings {
version: number;
general: GeneralSettings & { autoUpdate: boolean };
+ dictation: Required;
appearance: {
mode: "dark" | "light";
background: string | null;
@@ -409,6 +515,7 @@ export interface FactorySettings {
export const FACTORY_SETTINGS: FactorySettings = {
version: CURRENT_SETTINGS_VERSION,
general: { autoUpdate: true },
+ dictation: { language: "en" },
appearance: {
mode: "dark",
background: null, // the dark default (#141414) lives in the mode's derivation curve
@@ -510,6 +617,80 @@ function sanitizeModels(raw: Record): ModelsSettings {
return out;
}
+/** A positive integer, or undefined — keeps only meaningful token counts. */
+function positiveInt(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
+}
+
+/** One pin, clamped up to the floor (clamp-never-reject on read). */
+function sanitizeCustomModelOverride(raw: unknown): CustomModelOverride | undefined {
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
+ const rec = raw as Record;
+ const out: CustomModelOverride = {};
+ const context = positiveInt(rec.context);
+ if (context) out.context = Math.max(CUSTOM_MODEL_PIN_FLOOR, context);
+ const maxTokens = positiveInt(rec.maxTokens);
+ if (maxTokens) out.maxTokens = Math.max(CUSTOM_MODEL_PIN_FLOOR, maxTokens);
+ if (typeof rec.reasoning === "boolean") out.reasoning = rec.reasoning;
+ return Object.keys(out).length ? out : undefined;
+}
+
+/** THE pins map + THE prune law: well-formed entries only, ids trimmed, and
+ * when `liveIds` is given every entry for a model the endpoint no longer
+ * advertises drops. POST passes the discovered ids, PATCH the stored list. */
+export function sanitizeCustomModelOverrides(
+ raw: unknown,
+ liveIds?: Iterable,
+): Record {
+ const out: Record = {};
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out;
+ const live = liveIds ? new Set(liveIds) : undefined;
+ for (const [key, value] of Object.entries(raw)) {
+ const id = key.trim();
+ if (!id || (live && !live.has(id))) continue;
+ const pin = sanitizeCustomModelOverride(value);
+ if (pin) Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
+ }
+ return out;
+}
+
+/** The customProvider section: normalized base URL (invalid → dropped) and
+ * discovered models (ids trimmed, dupes/empties dropped; image kept only when
+ * true; token counts only as positive ints). Clamp-never-reject. */
+function sanitizeCustomProvider(raw: Record): CustomProviderSettings {
+ const out: CustomProviderSettings = {};
+ if (typeof raw.credentialBindingId === "string" && /^[a-f0-9]{32}$/.test(raw.credentialBindingId)) out.credentialBindingId = raw.credentialBindingId;
+ if (raw.authMode === "keyless" || raw.authMode === "apiKey") out.authMode = raw.authMode;
+ if (typeof raw.baseUrl === "string") {
+ const base = normalizeBaseUrl(raw.baseUrl);
+ if (base) out.baseUrl = base;
+ }
+ if (Array.isArray(raw.models)) {
+ const byId = new Map();
+ for (const m of raw.models) {
+ if (typeof m !== "object" || m === null) continue;
+ const rec = m as Record;
+ if (typeof rec.id !== "string") continue;
+ const id = rec.id.trim();
+ if (!id) continue;
+ const model: CustomModel = { id };
+ if (rec.image === true) model.image = true;
+ if (rec.reasoning === true) model.reasoning = true;
+ const context = positiveInt(rec.context);
+ if (context) model.context = context;
+ const maxTokens = positiveInt(rec.maxTokens);
+ if (maxTokens) model.maxTokens = maxTokens;
+ byId.set(id, model);
+ }
+ out.models = [...byId.values()];
+ }
+ const overrides = sanitizeCustomModelOverrides(raw.overrides);
+ if (Object.keys(overrides).length) out.overrides = overrides;
+ const manualModels = parseModelIds(raw.manualModels);
+ if (manualModels.length) out.manualModels = manualModels;
+ return out;
+}
+
function sanitizeBooleanMap(raw: unknown): Record | undefined {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const out: Record = {};
@@ -637,6 +818,19 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
out.general = sanitizeGeneral(r.general as Record);
}
+ if (r.dictation !== undefined) {
+ const raw = r.dictation;
+ out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) &&
+ isDictationLanguage((raw as Record).language)
+ ? { 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);
}
@@ -647,6 +841,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
const models = sanitizeModels(r.models as Record);
if (Object.keys(models).length) out.models = models;
}
+ if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) {
+ out.customProvider = sanitizeCustomProvider(r.customProvider as Record);
+ }
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
out.graph = sanitizeGraph(r.graph as Record);
if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") {
diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts
index cb5585d..db4305c 100644
--- a/src/core/tool-parts.ts
+++ b/src/core/tool-parts.ts
@@ -30,7 +30,6 @@
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
-import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -67,28 +66,9 @@ function boundaryParse(schema: TSchema, input: unknown): T | undefined {
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
* widens to "") renders as a quiet fallback, not a broken quiz.
*/
-/** LaTeX-escape restoration, deep over every string field (the JSON-debris
- * fix, owner ruling 2026-09-26): records persist RAW call args, so
- * single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
- * \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
- * display path. This is the ONE seam every rendered question passes
- * through — new turns and already-poisoned records alike heal here.
- * healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
- * the same models also stochastically DOUBLE command backslashes
- * (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
- * plus "nabla" as letters), so even runs before a command start inside
- * balanced math spans collapse to one backslash. */
-function restoreMathEscapesDeep(value: unknown): unknown {
- if (typeof value === "string") return repairMathForDisplay(value);
- if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
- if (typeof value === "object" && value !== null) {
- const out: Record = {};
- for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
- return out;
- }
- return value;
-}
-
+/** Shape parsing never repairs authored text. The dock builds its answer
+ * data from this source copy; recovery belongs exclusively to presentation
+ * (ToolContentResponse), never option identities, grading, or snapshots. */
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
// OPTIONAL `type` — new teach-phase records carry predict|check and the
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// wobble must heal HERE too or a posed quiz renders invisible (the
// tool-parts law). healEncodedArguments is the same pure pass the
// boundary's withArgumentHeal runs.
- const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
+ const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
const cleaned = boundaryParse(quizDisplayInputSchema, healedInput);
if (!cleaned) return undefined;
if (quizInputProblems(cleaned).length > 0) return undefined;
@@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
* keying, so they parse to undefined exactly like the zod seam did).
*/
export function parseAskInput(input: unknown): AskToolInput | undefined {
- const cleaned = boundaryParse(askInputSchema, restoreMathEscapesDeep(input));
+ const cleaned = boundaryParse(askInputSchema, input);
if (!cleaned) return undefined;
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
return cleaned;
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
* variants share the same fields; the teacher's nodeId is an unknown
* key Value.Clean strips, same as the quiz seam. */
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
- const cleaned = boundaryParse(explainDisplayInputSchema, restoreMathEscapesDeep(input));
+ const cleaned = boundaryParse(explainDisplayInputSchema, input);
if (!cleaned) return undefined;
if (explainInputProblems(cleaned).length > 0) return undefined;
return cleaned;
diff --git a/src/core/view.ts b/src/core/view.ts
index 609de06..74e4c8d 100644
--- a/src/core/view.ts
+++ b/src/core/view.ts
@@ -34,7 +34,7 @@
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
-import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts";
+import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude, SEND_TO_USER_TOOL, sendToUserText } from "./native-messages.ts";
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
import { QUESTION_TOOLS } from "./pending.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
@@ -52,6 +52,9 @@ export interface LhViewPart extends Record {
text?: string;
state?: string;
toolCallId?: string;
+ /** Native location, retained across display grouping (client-only). */
+ sourceMessageId?: string;
+ sourceContentIndex?: number;
input?: unknown;
output?: unknown;
rawInput?: unknown;
@@ -124,6 +127,14 @@ function lastSuccessfulPlanInit(parts: LhViewPart[]): number {
* view messages. Pure: the same input always projects to the same list. */
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
const out: LhViewMessage[] = [];
+ // Output calls become text rather than tool parts, so read settled
+ // failures up front: a failed call must retain the normal error path.
+ const outputErrors = new Map();
+ for (const message of messages) {
+ if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
+ outputErrors.set(message.toolCallId, message);
+ }
+ }
const lastAssistant = (): LhViewMessage | undefined => {
const last = out[out.length - 1];
@@ -211,13 +222,35 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
// back to a spinner mid-transcript; live cards belong at the
// bottom, and this gate is what puts them there by construction).
const streamingMessage = live?.streamingIds.has(m.id) === true;
- for (const block of assistant.content) {
+ for (const [contentIndex, block] of assistant.content.entries()) {
if (block.type === "text") {
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
} else if (block.type === "thinking") {
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
} else if (block.type === "toolCall") {
const part = toolPartOf(block, live, streamingMessage);
+ if ((QUESTION_TOOLS as readonly string[]).includes(block.name)) {
+ part.sourceMessageId = m.id;
+ part.sourceContentIndex = contentIndex;
+ }
+ if (block.name === SEND_TO_USER_TOOL) {
+ // Failed/aborted generations never execute their calls;
+ // the provider error/abort UI already owns that outcome.
+ if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
+ const error = outputErrors.get(block.id);
+ if (error) applyResult(part, error);
+ if (part.state === "output-error") {
+ rest.push(part);
+ } else {
+ const text = sendToUserText(block);
+ if (text !== undefined) {
+ rest.push({ type: "text", id: `${m.id}-t${textN++}`, text, state: "done" });
+ }
+ }
+ // No live tool card or acknowledgement: this channel is
+ // ordinary prose, including while its arguments stream.
+ continue;
+ }
// The conclusion's reveal join (owner round, 2026-10-05): the
// hero of a conclude card is the modelSolution committed at POSE
// time — a record fold, enriched here so the card renders pure.
diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts
index 7167da6..f95e7b9 100644
--- a/src/server/chat-handler.ts
+++ b/src/server/chat-handler.ts
@@ -9,7 +9,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";
@@ -19,7 +19,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
import {
deliverJobResult,
- noteSteerVisible,
+ noteSteersDurable,
reconcileUserSteers,
registerLiveAgent,
restoreUncommittedAutomatic,
@@ -35,6 +35,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,
@@ -44,8 +45,8 @@ import {
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { applyParkedDismissal } from "./dismissal-park.ts";
-import { registerTurn, unregisterTurn } from "./turn-runs.ts";
-import { persistTurn, triggerExtraction } from "./turn-service.ts";
+import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
+import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { composeInstructions } from "../agent/teacher-agent.ts";
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
@@ -57,10 +58,11 @@ import {
ChatInputError,
inputAppends,
parseChatInput,
+ retryDrops,
type TurnInput,
} from "../core/input-events.ts";
import { steerDelivered } from "../core/contracts.ts";
-import type {CallUsage, SteerMessage} from "../core/contracts.ts";
+import type {CallUsage} from "../core/contracts.ts";
/**
* One chat turn over the server-owned native record. Framework-free (plain
@@ -81,6 +83,16 @@ import type {CallUsage, SteerMessage} 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.";
+}
+
const jobRunner = createJobRunner({
languageForChat: chatLanguage,
chatStore: stores.chatStore,
@@ -187,6 +199,17 @@ export async function handleChatInput(
return Response.json({ error: "Unknown chat" }, { status: 404 });
}
+ // Refuse an ineligible Retry before catalog refresh, compaction, or any
+ // turn/write effects. Revalidate after the awaited gates below too.
+ if (input.type === "retry") {
+ try {
+ retryDrops(record.messages);
+ } catch (err) {
+ if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
+ throw err;
+ }
+ }
+
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
// CONVERSATION chain — the chat's own model override over the strong
// slot — and there is no factory floor; when both are empty the turn
@@ -261,7 +284,20 @@ export async function handleChatInput(
chatId,
);
const compactedRecord = stores.chatStore.getChat(chatId);
- if (compactedRecord) record = compactedRecord;
+ if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 });
+ record = compactedRecord;
+ // Recompute Retry eligibility on current truth, still before beginTurn.
+ // A compaction marker or another writer may have landed during awaits.
+ // These omissions affect ONLY the agent context, never the saved record.
+ let retryOmissions = new Set();
+ if (input.type === "retry") {
+ try {
+ retryOmissions = retryDrops(record.messages);
+ } catch (err) {
+ if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
+ throw err;
+ }
+ }
// The input's native appends (pure; the honest 400 for an answer with no
// pending question call). Computed BEFORE beginTurn so a refused input
@@ -295,7 +331,11 @@ export async function handleChatInput(
// the composer gates), so adjacency holds by construction.
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
const merged = [...record.messages, ...appends];
- stores.chatStore.saveMessages(chatId, merged);
+ try { stores.chatStore.saveMessages(chatId, merged); }
+ catch (err) {
+ console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
+ return new Response("Your input could not be saved. Please try again.", { status: 500 });
+ }
emit("record-changed", { chatId });
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
}
@@ -315,19 +355,27 @@ export async function handleChatInput(
// ride this same write-ahead - the pump de-queued them at fire, so this
// is the ONLY copy ever appended.)
const merged = [...record.messages, ...appends];
+ // Retry preserves every persisted attempt. Pi needs a non-assistant
+ // tail to start continue(), so its temporary history omits the trailing
+ // failures only. Count that SAME temporary prefix at settlement, or the
+ // retained failures would make persistTurn skip genuinely new output.
+ const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged;
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
- let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
+ let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length;
try {
stores.chatStore.saveLanguage?.(chatId, language);
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
- // Dock fix (owner ruling 2026-09-26): persisted markers are visible
- // in the record — flip their jobs now (the wake's steers ride this
- // write-ahead; the delivering turn may run for minutes).
- for (const appended of stagedAutomatic ? [] : appends) {
- if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
- }
} catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
+ endTurn(chatId);
+ return new Response("Your input could not be saved. Please try again.", { status: 500 });
+ }
+ // Visibility bookkeeping is not the canonical save. Its failure must
+ // neither misreport a saved input as lost nor prevent the turn running.
+ try {
+ if (!stagedAutomatic) noteSteersDurable(chatId, merged);
+ } catch (err) {
+ console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
}
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
@@ -353,7 +401,7 @@ export async function handleChatInput(
// the session is still probing: build the PROBER (quiz/ask + probe_done).
// Both share this whole composition (write-ahead persist, liveness,
// steers, usage, extraction) — only the agent differs.
- const teaching = hasProbeDone(merged);
+ const teaching = hasProbeDone(agentMessages);
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
// turn gate above (chat.model override → strong slot → refused) — the
// engine build is pure from here; thinking levels clamp natively.
@@ -372,12 +420,16 @@ export async function handleChatInput(
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// user: quiz/ask/fs/plan/probe_done are always-on).
if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({
- initialMessages: asAgentMessages(merged),
+ initialMessages: asAgentMessages(agentMessages),
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
foregroundOwed: !callPurpose.background,
purpose: callPurpose,
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
- commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
+ commitStaged: () => {
+ stores.chatStore.saveMessages(chatId, merged);
+ persistBaseCount = merged.length;
+ noteSteersDurable(chatId, merged);
+ },
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
const agentOpts: AgentBuildOptions = {
@@ -385,7 +437,7 @@ export async function handleChatInput(
engine,
callPurpose,
requestCycle,
- messages: asAgentMessages(merged),
+ messages: asAgentMessages(agentMessages),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
// Debug (owner, 2026-10-04): teacher/prober raw provider events under
@@ -476,11 +528,12 @@ export async function handleChatInput(
// run's first LLM call (the native replacement for continuation
// coalescing). Settlement still cannot beat the flow below: it needs
// a streamed response, which never happens inside the constructor.
- registerLiveAgent(chatId, constructed);
+ registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue",
+ input.type === "steers" ? new Set(appends.map((message) => message.id)) : undefined);
// Debug recorder run marker (owner, 2026-10-04): correlates the
// JSONL event stream with the record's turns; no-op unless
// LH_PROVIDER_EVENTS=1.
- providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
+ providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`);
const handle = runPiTurn({
agent: constructed,
requestCycle,
@@ -504,10 +557,26 @@ export async function handleChatInput(
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
+ onSettleError: settlementErrorText,
+ onSettleFailure: (error, reportedErrorText) => {
+ // POST only acknowledges acceptance; the submitter may attach
+ // after this turn has already failed and left the registry.
+ rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
+ endTurn(chatId);
+ unregisterTurn(chatId);
+ unregisterLiveAgent(chatId);
+ // The canonical save may have succeeded before later bookkeeping
+ // threw. Reconcile against actual durability, never replay input
+ // merely because usage/extraction/other settlement work failed.
+ const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
+ reconcileUserSteers(chatId, persisted);
+ settleSteers(chatId, persisted, true, constructed);
+ },
onFinish: (turnMessages, isAborted) => {
- // Wave B: the record write is the agent's own final state —
- // the new messages are the slice past the merged base.
+ // Wave B: append only messages past the temporary agent prefix.
+ // Retry's saved record still includes its earlier failed attempts.
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
+ noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped);
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. The
// provenance message is the turn's FIRST new assistant message.
@@ -553,7 +622,7 @@ export async function handleChatInput(
// resolve; stranded markers return to pending), then let
// the pump take any stragglers down the idle wake.
unregisterLiveAgent(chatId);
- settleSteers(chatId, stamped);
+ settleSteers(chatId, stamped, false, constructed);
// Composer steers (2026-10-03): delivered ids dropped, stranded
// ones stay parked for the chat's next run (the registry is the
// stranding safety net — the agent's undrained copy died above).
diff --git a/src/server/chats.ts b/src/server/chats.ts
index f90d950..857d3d1 100644
--- a/src/server/chats.ts
+++ b/src/server/chats.ts
@@ -1,10 +1,13 @@
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
+import { isJobLive } from "../agent/subagents/aborts.ts";
import { coverageOf } from "../core/coverage.ts";
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
import { titleFromPrompt } from "../core/contracts.ts";
import { graphRevision } from "./graph.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
+import { clearTurnFailure } from "./turn-runs.ts";
+import { discardChatDeliveries } from "./jobs.ts";
/**
* Server-side chat entries for the transport layer (app/api/chats*,
@@ -65,10 +68,7 @@ export type ChatDeleteOutcome =
* the wind-down finds the chat gone at its merge and aborts with
* nothing merged.
*/
-export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise {
- if (!stores.chatStore.getChat(chatId)) {
- return { ok: false, status: 404, code: "not_found", error: "No such session" };
- }
+function runningWorkBlocker(chatId: string): Extract | null {
if (isTurnRunning(chatId)) {
return {
ok: false,
@@ -78,7 +78,11 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
jobIds: [],
};
}
- const runningJobs = stores.jobStore.listJobs(chatId).filter((job) => job.status === "running");
+ // Legacy records kept completed workers running until delivery. A saved
+ // result without a live loop is pending session data, not computing work;
+ // deletion must not depend on a prior recovery poll to recognize it.
+ const runningJobs = stores.jobStore.listJobs(chatId).filter((job) =>
+ job.status === "running" && (!job.result || isJobLive(job.id)));
if (runningJobs.length > 0) {
return {
ok: false,
@@ -88,6 +92,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
jobIds: runningJobs.map((job) => job.id),
};
}
+ return null;
+}
+
+export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise {
+ if (!stores.chatStore.getChat(chatId)) {
+ return { ok: false, status: 404, code: "not_found", error: "No such session" };
+ }
+ const blocker = runningWorkBlocker(chatId);
+ if (blocker) return blocker;
if (extractionStatuses().some((run) => run.chatId === chatId)) {
abortExtraction(chatId); // true cancellation — spend stops, nothing merges
const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS;
@@ -104,11 +117,19 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
}
}
+ // A result wake or a worker may have started during extraction wind-down.
+ // Recheck immediately before the synchronous purge, with no await gap.
+ const lateBlocker = runningWorkBlocker(chatId);
+ if (lateBlocker) return lateBlocker;
+
// THE SYNC BLOCK — no awaits below (see the doc comment). Order:
// ledgers → jobs → the chat record.
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
- stores.chatStore.deleteChat(chatId);
+ if (stores.chatStore.deleteChat(chatId)) {
+ discardChatDeliveries(chatId);
+ clearTurnFailure(chatId);
+ }
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
}
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/compaction.ts b/src/server/compaction.ts
index cc62aa6..865f9ca 100644
--- a/src/server/compaction.ts
+++ b/src/server/compaction.ts
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
- const { text } = await runSubagent([], {
+ const { text, newMessages } = await runSubagent([], {
// The engine override is always present (the chat's own conversation
// engine — owner ruling), so the role label never resolves an
// engine; "researcher" is the least-wrong existing ModelRole label
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
usage = u;
},
});
+ // A provider-declared incomplete response is not a usable checkpoint,
+ // even when it contains nonempty text. Match Pi's compaction failure
+ // semantics: leave the existing history and prior summary untouched.
+ if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) {
+ throw new Error("Compaction summary hit the output limit and is incomplete");
+ }
return { summary: text, usage };
}
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise {
spanStart = prevKept;
priorSummary = previous.content;
}
- const spanText = serializeForSummary(messages.slice(spanStart, cut));
+ const span = messages.slice(spanStart, cut);
+ // The separately supplied prior summary replaces all summary markers
+ // in this span. Without a resolved prior boundary, retain the existing
+ // legacy fallback rather than silently discarding summary-only context.
+ const summaryMessages = priorSummary
+ ? span.filter((m) => m.role !== "custom" || m.customType !== "compaction")
+ : span;
+ const spanText = serializeForSummary(summaryMessages);
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
const prompt = buildCompactionPrompt({
diff --git a/src/server/custom-provider-auth.ts b/src/server/custom-provider-auth.ts
new file mode 100644
index 0000000..aa04025
--- /dev/null
+++ b/src/server/custom-provider-auth.ts
@@ -0,0 +1,43 @@
+/** Custom-only credential composition. File credentials and their authorization
+ * are read from the same atomic record, never a stale fill-gapped env copy. */
+import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
+import { authEnvRecord } from "../core/auth-env.ts";
+import { customBindingMatches, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
+import type { CustomProviderSettings } from "../core/settings.ts";
+import { readCustomAuth, writeCustomAuth } from "../store/auth.ts";
+
+export interface CustomAuthState {
+ readable: boolean;
+ binding?: CustomAuthBinding;
+ key?: string;
+ fileKey?: string;
+ source: "env" | "file" | "none";
+}
+/** requiredConfig prevents even credential resolution until startup/runtime
+ * authorization matches. Explicit UI inspection/probe authorization omits it. */
+export function customAuthState(inspectKey = false, requiredConfig?: CustomProviderSettings): CustomAuthState {
+ try {
+ const snapshot = readCustomAuth();
+ const result: CustomAuthState = { readable: true, binding: snapshot.binding, source: "none" };
+ if (!inspectKey || (requiredConfig && !customBindingMatches(requiredConfig, snapshot.binding))) return result;
+ result.fileKey = snapshot.fileKey();
+ const record = authEnvRecord();
+ const envKey = process.env.LH_IGNORE_ENV_KEYS !== "1" && record.realEnv.has(CUSTOM_PROVIDER.envVar) ? process.env[CUSTOM_PROVIDER.envVar] : undefined;
+ result.source = envKey !== undefined ? "env" : result.fileKey !== undefined ? "file" : "none";
+ result.key = (envKey ?? result.fileKey)?.trim() || undefined;
+ return result;
+ } catch { return { readable: false, source: "none" }; }
+}
+export function customKeyAuthorized(cp: CustomProviderSettings | undefined, state: CustomAuthState): boolean {
+ return cp?.authMode === "apiKey" && state.readable && customBindingMatches(cp, state.binding) && Boolean(state.key) && (state.source === "env" || state.binding?.fileKeyBaseUrl === cp?.baseUrl);
+}
+/** Reflect only after the atomic pair is committed. Runtime file resolution
+ * does not rely on this compatibility mirror, including after a failed write. */
+export function persistCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
+ writeCustomAuth(binding, fileKey);
+ const record = authEnvRecord();
+ if (!record.realEnv.has(CUSTOM_PROVIDER.envVar)) {
+ if (fileKey === undefined) { delete process.env[CUSTOM_PROVIDER.envVar]; record.fillGap.delete(CUSTOM_PROVIDER.envVar); }
+ else { process.env[CUSTOM_PROVIDER.envVar] = fileKey; record.fillGap.add(CUSTOM_PROVIDER.envVar); }
+ }
+}
diff --git a/src/server/custom-provider-body.ts b/src/server/custom-provider-body.ts
new file mode 100644
index 0000000..4abac5e
--- /dev/null
+++ b/src/server/custom-provider-body.ts
@@ -0,0 +1,31 @@
+/** Byte-bounded JSON for the custom endpoint's request/discovery surfaces. */
+export class CustomBodyLimitError extends Error {}
+
+export async function readBoundedJson(message: Request | Response, limit: number, allowEmptyObject = false): Promise {
+ if (Number(message.headers.get("content-length")) > limit) {
+ await message.body?.cancel();
+ throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
+ }
+ const reader = message.body?.getReader();
+ if (!reader) {
+ if (allowEmptyObject) return {};
+ throw new SyntaxError("Missing JSON body");
+ }
+ const decoder = new TextDecoder();
+ let text = "";
+ let bytes = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > limit) throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
+ text += decoder.decode(value, { stream: true });
+ }
+ if (bytes === 0 && allowEmptyObject) return {};
+ return JSON.parse(text + decoder.decode());
+ } finally {
+ await reader.cancel().catch(() => undefined);
+ reader.releaseLock();
+ }
+}
diff --git a/src/server/custom-provider-http.ts b/src/server/custom-provider-http.ts
new file mode 100644
index 0000000..ad240cb
--- /dev/null
+++ b/src/server/custom-provider-http.ts
@@ -0,0 +1,149 @@
+/** PR #62's connect/manual-model/offline-pin workflow, with host request,
+ * credential-binding and optimistic-write boundaries. No secret reaches GET. */
+import { createHmac, randomBytes } from "node:crypto";
+import { normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides, type CustomProviderSettings } from "../core/settings.ts";
+import { readSettings, writeSettings } from "../store/settings.ts";
+import { readCustomAuth } from "../store/auth.ts";
+import { customAuthState, customKeyAuthorized, persistCustomAuth } from "./custom-provider-auth.ts";
+import type { CustomAuthBinding } from "../core/custom-provider-auth.ts";
+import { desktopAPIAuthorized } from "./desktop-access.ts";
+import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
+import { discoverModels, mergeManualModels, syncCustomProvider } from "./custom-provider.ts";
+import { parseCustomModelOverridesBody } from "./settings-body.ts";
+import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
+
+const revisionKey = Symbol.for("learnharness.custom-provider-revision");
+const globals = globalThis as typeof globalThis & { [revisionKey]?: Buffer };
+const secret = globals[revisionKey] ??= randomBytes(32);
+const failure = (error: string, status = 400) => Response.json({ error }, { status });
+const stale = () => failure("Custom configuration or credentials changed. Reload configuration and retry.", 409);
+
+function snapshot(inspectKey = false) {
+ const record = readSettings() ?? { version: 1 };
+ const cp = record.customProvider;
+ // Keyless never looks up a key for authentication or revision generation.
+ // An explicit API-key inspection is used only after the user selects that mode.
+ const auth = customAuthState(inspectKey);
+ const key = auth.key;
+ // Public revisions contain configuration only, never credential material.
+ // Effective credentials are compared privately across the awaited probe.
+ const etag = `"${createHmac("sha256", secret).update(JSON.stringify([cp ?? null, auth.binding ?? null, auth.readable])).digest("base64url")}"`;
+ return { record, cp, auth, key, etag };
+}
+
+function response(body: object) {
+ return Response.json(body, { headers: { etag: snapshot().etag, "cache-control": "no-store" } });
+}
+
+export function customProviderGet(request: Request): Response {
+ const inspect = new URL(request.url).searchParams.get("authMode") === "apiKey";
+ const current = snapshot();
+ const s = snapshot(inspect || current.cp?.authMode === "apiKey");
+ return response({
+ baseUrl: s.cp?.baseUrl ?? "", authMode: s.cp?.authMode ?? "keyless",
+ models: s.cp?.models ?? [], overrides: s.cp?.overrides ?? {}, manualModels: s.cp?.manualModels ?? [],
+ keyPresent: Boolean(s.key), keySource: s.auth.source,
+ keyReuseRequired: Boolean(s.key) && !customKeyAuthorized(s.cp, s.auth),
+ });
+}
+
+function authorized(request: Request): boolean {
+ const desktop = process.env.LH_DESKTOP_SERVER === "1" || Boolean(process.env.LH_DESKTOP_API_SECRET);
+ if (desktop) return desktopAPIAuthorized(request.headers);
+ const origin = request.headers.get("origin");
+ if (!origin || origin === "null") return false;
+ try {
+ // Next's standalone Request URL can use its internal hostname. Match
+ // the actual addressed Host through the app's existing origin seam;
+ // never trust forwarded-origin headers.
+ return origin === subscriptionRequestOrigin(request)
+ && (!request.headers.has("sec-fetch-site") || request.headers.get("sec-fetch-site") === "same-origin");
+ } catch { return false; }
+}
+
+/** Auth pair FIRST, matching config nonce SECOND. Any interrupted/failed
+ * cross-file publication is durably unusable, including with a real env key.
+ * Best-effort rollback restores the entire old pair, never just its secret. */
+function commit(cp: CustomProviderSettings | undefined, binding?: CustomAuthBinding, fileKey?: string): void {
+ const latest = readSettings() ?? { version: 1 };
+ const oldAuth = binding ? readCustomAuth() : undefined;
+ let published = false;
+ try {
+ if (binding) { persistCustomAuth(binding, fileKey); published = true; }
+ const { customProvider: _old, ...rest } = latest;
+ writeSettings(cp ? { ...rest, customProvider: cp } : rest);
+ } catch {
+ if (published && oldAuth) {
+ try { persistCustomAuth(oldAuth.binding, oldAuth.fileKey()); } catch { /* durable nonce mismatch fails closed after restart */ }
+ }
+ throw new Error("Unable to save custom configuration. Reload configuration and retry.");
+ }
+ syncCustomProvider();
+}
+
+export async function customProviderMutation(request: Request): Promise {
+ if (!authorized(request)) return failure("Custom configuration requires a trusted same-origin request.", 403);
+ if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") return failure("JSON content-type is required.", 415);
+ try {
+ // Next can represent an empty DELETE as a zero-byte stream rather
+ // than null. Bound it either way; only DELETE permits an empty body.
+ const body = await readBoundedJson(request, 64 * 1024, request.method === "DELETE");
+ if (!body || typeof body !== "object" || Array.isArray(body)) return failure("Invalid JSON body");
+ const raw = body as Record;
+ const before = snapshot(raw.authMode === "apiKey");
+ if (!before.auth.readable) return failure("Custom authorization storage is unavailable. No changes were saved.", 500);
+ if (request.headers.get("if-match") !== before.etag) return stale();
+ if (request.method === "DELETE") {
+ commit(undefined, { version: 1, id: randomBytes(16).toString("hex"), authMode: "disabled" });
+ return response({ ok: true });
+ }
+ if (request.method === "PATCH") {
+ const parsed = parseCustomModelOverridesBody(raw);
+ if (!parsed.ok) return failure(parsed.error);
+ if (!before.cp?.baseUrl) return failure("No custom endpoint configured", 409);
+ const overrides = sanitizeCustomModelOverrides(parsed.settings, before.cp.models?.map((m) => m.id));
+ commit({ ...before.cp, overrides });
+ return response({ ok: true, overrides });
+ }
+ if (request.method !== "POST") return failure("Unsupported method", 405);
+ const base = normalizeBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : "");
+ if (!base) return failure("Base URL must be http(s), with no credentials, query or fragment.");
+ if (raw.authMode !== "keyless" && raw.authMode !== "apiKey") return failure("Choose Keyless or API key authentication.");
+ const authMode = raw.authMode;
+ if (raw.key !== undefined && typeof raw.key !== "string") return failure("API key must be a string.");
+ const supplied = typeof raw.key === "string" ? raw.key.trim() : undefined;
+ if (authMode === "apiKey" && supplied === "") return failure("Choose Keyless to clear authentication.");
+ const envOwned = before.auth.source === "env";
+ if (envOwned && supplied) return failure("Environment owns the effective key. Change the environment or explicitly reuse that key.");
+ // A shadowed file key is not the key that will run. Require consent for
+ // the actual environment key when authorizing a different endpoint.
+ const hasNewEffectiveKey = Boolean(supplied) && !envOwned;
+ const changedTarget = before.cp?.baseUrl !== base;
+ if (authMode === "apiKey" && (changedTarget || !customKeyAuthorized(before.cp, before.auth)) && !hasNewEffectiveKey && raw.reuseKey !== true) {
+ return failure("Enter a new key or explicitly allow reuse of the effective existing key for this URL.");
+ }
+ const effectiveKey = authMode === "apiKey" ? (envOwned ? before.key : supplied || before.key) : undefined;
+ if (authMode === "apiKey" && !effectiveKey) return failure("API key mode requires an effective key.");
+ const manualModels = parseModelIds(raw.manualModels);
+ if (manualModels.length > 10_000) return failure("Model list exceeds 10000 entries");
+ const found = await discoverModels(base, effectiveKey);
+ if (!found.ok && (found.fatal || found.status === 401 || found.status === 403)) return failure(found.error, 502);
+ if (!found.ok && !manualModels.length) return failure(`${found.error}. If this endpoint has no /models list, add model IDs manually.`, 502);
+ // Discovery yields; both the custom record and the credential must still
+ // match. Unrelated settings edits are preserved by commit's fresh merge.
+ const now = snapshot(authMode === "apiKey");
+ if (before.etag !== now.etag || JSON.stringify(before.auth) !== JSON.stringify(now.auth)) return stale();
+ const discovered = found.ok ? found.models : !changedTarget ? before.cp?.models ?? [] : [];
+ const models = mergeManualModels(discovered, manualModels);
+ if (models.length > 10_000) return failure("Model list exceeds 10000 entries");
+ const overrides = sanitizeCustomModelOverrides(!changedTarget ? before.cp?.overrides : undefined, models.map((m) => m.id));
+ const id = randomBytes(16).toString("hex");
+ const fileKey = authMode === "keyless" ? undefined : supplied && !envOwned ? supplied : now.auth.fileKey;
+ const fileKeyBaseUrl = authMode === "apiKey" ? envOwned ? now.auth.binding?.fileKeyBaseUrl : base : undefined;
+ const binding: CustomAuthBinding = { version: 1, id, baseUrl: base, authMode, ...(fileKey && fileKeyBaseUrl ? { fileKeyBaseUrl } : {}) };
+ commit({ baseUrl: base, authMode, credentialBindingId: id, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, binding, fileKey);
+ return response({ ok: true, models });
+ } catch (error) {
+ return failure(error instanceof CustomBodyLimitError ? "Configuration body exceeds 64 KiB." : error instanceof SyntaxError ? "Invalid JSON body." : "Unable to save custom configuration. Reload configuration and retry.", error instanceof CustomBodyLimitError ? 413 : error instanceof SyntaxError ? 400 : 500);
+ }
+}
diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts
new file mode 100644
index 0000000..b717022
--- /dev/null
+++ b/src/server/custom-provider.ts
@@ -0,0 +1,251 @@
+/**
+ * The custom OpenAI-compatible endpoint: turns the user's non-secret config
+ * (base URL + discovered models) into a pi-ai provider registered in
+ * `piModels()`, so its models fill the same slots as any built-in. Keyless
+ * local servers work via a placeholder key resolved at request time.
+ */
+
+import { createProvider, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
+import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
+import { configureProviderSource, piModels } from "../agent/models.ts";
+import { readSettings } from "../store/settings.ts";
+import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
+import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
+import { customBindingMatches } from "../core/custom-provider-auth.ts";
+import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts";
+import {
+ CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
+ CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
+ customProviderUsable,
+ normalizeBaseUrl,
+ type CustomModel,
+ type CustomModelOverride,
+} from "../core/settings.ts";
+
+
+/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
+const KEYLESS_PLACEHOLDER = "not-needed";
+
+/** Narrow an unknown to a plain object (not null, not an array). */
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+/** Install the sole custom source: donor settings + explicit authentication.
+ * Publication captures a consistent snapshot; an already-started operation
+ * cannot acquire a later endpoint's key. No file configuration source exists. */
+export function installCustomProviderSource(readAuth = customAuthState): void {
+ let fingerprint: string | undefined;
+ configureProviderSource({
+ isCustom: (id) => id === CUSTOM_PROVIDER.id,
+ sync(models) {
+ const cp = readSettings()?.customProvider;
+ // Validate the durable nonce/URL/mode before resolving ANY credential.
+ // Keyless reads only the nonsecret marker, never a file/env key.
+ const auth = readAuth(cp?.authMode === "apiKey", cp);
+ const key = auth.key;
+ const ready = customProviderUsable(cp) && auth.readable && customBindingMatches(cp, auth.binding) && (cp.authMode === "keyless" || customKeyAuthorized(cp, auth));
+ const next = JSON.stringify([cp, auth]); // Private in-memory fingerprint, never a public revision.
+ if (next === fingerprint) return;
+ if (ready) models.setProvider(buildOpenAiCompatibleProvider({ ...cp, baseUrl: cp.baseUrl, models: cp.models ?? [], apiKey: key }));
+ else models.deleteProvider(CUSTOM_PROVIDER.id);
+ fingerprint = next;
+ },
+ });
+}
+
+export function customProviderAvailable(): boolean {
+ return piModels().getProvider(CUSTOM_PROVIDER.id) !== undefined;
+}
+
+/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
+ * levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
+ * the user's pin wins, then the endpoint's own metadata, then the compiled
+ * default — a local server that says nothing (or says 8k) can be corrected.
+ * baseUrl is stamped per-model. */
+function toPiModel(
+ model: CustomModel,
+ baseUrl: string,
+ overrides: Record,
+): Model {
+ const pin = overrides[model.id];
+ return {
+ id: model.id,
+ name: model.id,
+ api: "openai-completions",
+ provider: CUSTOM_PROVIDER.id,
+ baseUrl,
+ // Pin wins; else detected from the endpoint's metadata (see
+ // detectReasoning). true makes pi-ai offer the standard levels and send
+ // reasoning_effort on the wire; no thinkingLevelMap → the default
+ // off/minimal/low/medium/high set.
+ reasoning: pin?.reasoning ?? Boolean(model.reasoning),
+ input: model.image ? ["text", "image"] : ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
+ maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
+ type: "chat",
+ };
+}
+
+/** Build an OpenAI-compatible provider from parts. baseUrl must be normalized. */
+export function buildOpenAiCompatibleProvider(input: {
+ baseUrl: string;
+ models: readonly CustomModel[];
+ /** User pins by model id (see CustomModelOverride) — absent = none. */
+ overrides?: Record;
+ /** Already resolved for this publication; absent means explicit keyless. */
+ apiKey?: string;
+}): Provider {
+ const overrides = input.overrides ?? {};
+ return createProvider({
+ id: CUSTOM_PROVIDER.id,
+ name: CUSTOM_PROVIDER.label,
+ baseUrl: input.baseUrl,
+ auth: { apiKey: {
+ name: `${CUSTOM_PROVIDER.label} API key`,
+ resolve: async () => ({ auth: { apiKey: input.apiKey ?? KEYLESS_PLACEHOLDER }, source: input.apiKey ? CUSTOM_PROVIDER.envVar : "keyless" }),
+ } },
+ models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
+ api: openAICompletionsApi(),
+ });
+}
+
+/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
+ * not usable → remove. Never throws. */
+export function syncCustomProvider(): void {
+ piModels();
+}
+
+/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
+ * when it answered at all, so callers can tell a rejected key from a missing
+ * model list. */
+export type DiscoverResult =
+ | { ok: true; models: CustomModel[] }
+ | { ok: false; error: string; status?: number; fatal?: boolean };
+
+/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
+ * only); keyless endpoints answer without a bearer. Non-2xx or unparseable
+ * body → honest error, never a silent empty list. */
+export async function discoverModels(baseUrl: string, apiKey?: string): Promise {
+ const base = normalizeBaseUrl(baseUrl);
+ if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
+ const key = apiKey?.trim();
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 15_000);
+ try {
+ const res = await fetch(`${base}/models`, {
+ headers: key ? { authorization: `Bearer ${key}` } : {},
+ signal: controller.signal,
+ redirect: "manual",
+ });
+ if (res.status >= 300 && res.status < 400) {
+ await res.body?.cancel();
+ return { ok: false, error: "Discovery redirects are not allowed", fatal: true };
+ }
+ if (!res.ok) { await res.body?.cancel(); return { ok: false, error: `Endpoint returned HTTP ${res.status}`, status: res.status }; }
+ const body = await readBoundedJson(res, 8 * 1024 * 1024);
+ const list = extractModels(body);
+ if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
+ return { ok: true, models: list };
+ } catch (err) {
+ return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** The connect's model list: what the endpoint advertises, plus every typed id
+ * it does not advertise (at default limits, so the pins can correct them).
+ * Discovered metadata always wins over a typed id of the same name. */
+export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] {
+ const byId = new Map(discovered.map((m) => [m.id, m]));
+ for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id });
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
+}
+
+/** Model rows from a list response ({data:[…]} | {models:[…]} | bare array),
+ * or null if unrecognized. */
+function modelRows(body: unknown): unknown[] | null {
+ if (Array.isArray(body)) return body;
+ if (isRecord(body)) {
+ if (Array.isArray(body.data)) return body.data;
+ if (Array.isArray(body.models)) return body.models;
+ }
+ return null;
+}
+
+/** Does a /models row advertise image input? Scans the common modality/
+ * capability fields; absent or unrecognized → false (text-only). */
+function detectImage(row: Record): boolean {
+ const architecture = isRecord(row.architecture) ? row.architecture : undefined;
+ const candidates = [
+ architecture?.input_modalities,
+ row.input_modalities,
+ row.modalities,
+ row.supported_modalities,
+ row.capabilities,
+ ];
+ for (const c of candidates) {
+ if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true;
+ }
+ return false;
+}
+
+/** Does a /models row advertise reasoning? Metadata only — no name guessing,
+ * because a false positive sends reasoning_effort an endpoint will reject.
+ * Trusted signals: an explicit boolean (reasoning / supports_reasoning /
+ * thinking) or a reasoning token in the capability/parameter lists servers
+ * publish (vLLM + OpenRouter's supported_parameters, capabilities, tags).
+ * Absent or unrecognized → false (thinking stays "off"). */
+function detectReasoning(row: Record): boolean {
+ for (const key of ["reasoning", "supports_reasoning", "thinking"] as const) {
+ if (row[key] === true) return true;
+ }
+ const architecture = isRecord(row.architecture) ? row.architecture : undefined;
+ const candidates = [row.supported_parameters, row.capabilities, row.tags, architecture?.output_modalities];
+ return candidates.some(
+ (list) =>
+ Array.isArray(list) &&
+ list.some((v) => typeof v === "string" && (/reasoning/i.test(v) || v.toLowerCase() === "thinking")),
+ );
+}
+
+/** First positive integer among `keys` on a row (endpoints name limits
+ * inconsistently). */
+function firstPositive(row: Record, keys: readonly string[]): number | undefined {
+ for (const key of keys) {
+ const value = row[key];
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
+ }
+ return undefined;
+}
+
+// Field names seen in the wild for each limit (first present wins).
+const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const;
+const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
+
+/** Pull models from a list response (objects with `id` or bare strings), each
+ * with detected image + token limits. Null if the shape is unrecognized. */
+function extractModels(body: unknown): CustomModel[] | null {
+ const rows = modelRows(body);
+ if (rows === null) return null;
+ if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries");
+ const byId = new Map();
+ for (const row of rows) {
+ if (typeof row === "string") {
+ if (row.trim()) byId.set(row, { id: row });
+ continue;
+ }
+ if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
+ const model: CustomModel = { id: row.id };
+ if (detectImage(row)) model.image = true;
+ if (detectReasoning(row)) model.reasoning = true;
+ const context = firstPositive(row, CONTEXT_KEYS);
+ if (context) model.context = context;
+ const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
+ if (maxTokens) model.maxTokens = maxTokens;
+ byId.set(row.id, model);
+ }
+ }
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index a27e889..c58c1fc 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -43,7 +43,13 @@ export * from "./compaction.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
+export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts";
export { applicationLanguage, chatLanguage } from "./language.ts";
export * from "./stt.ts";
+export { claudeCodeInstallationResponse } from "./claude-code.ts";
+// The custom endpoint module (registers into piModels() + probes for models).
+// No import side effects; the boot sync lives in stores.ts.
+export * from "./custom-provider.ts";
+export { customProviderGet, customProviderMutation } from "./custom-provider-http.ts";
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 7424d41..f4b2cd2 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -1,7 +1,7 @@
/**
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
- * delivery rides pi's OWN follow-up queue when a run is live, and the
+ * delivery rides pi's OWN steering queue when a run is live, and the
* idle wake otherwise. One registry, two vehicles.
*
* deliverJobResult(steer) — the runner calls this on job completion.
@@ -40,15 +40,17 @@
*
* settleSteers(chatId, persisted) — the handler calls this at turn
* settlement (after unregisterLiveAgent): entries the run DELIVERED
- * resolve + flip their jobs; entries that were handed to the dying
+ * resolve + acknowledge delivery; entries handed to the dying
* run but never injected (stopper exit, abort, a missed final poll)
* return to pending and the pump restarts — no stranding, no loss.
*
- * Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
- * "running" with a result (the wake never fired) or without one (the
- * sub-agent loop itself died). The sweep re-delivers finished results
- * (idempotently) and re-spawns unfinished loops, both via injected
- * seams. It runs lazily off the /api/jobs poll, throttled.
+ * Worker status ends at computation completion; JobRecord.delivery owns
+ * the pending result or abort notice independently. Registry entries
+ * survive in-flight wakes, so duplicate submissions share one promise.
+ * Failed starts stay pending without timers or worker errors. Recovery
+ * restores known pending deliveries and re-spawns unfinished workers;
+ * registration also restores pending delivery before the first poll.
+ * Legacy terminal jobs without delivery metadata are never replayed.
*
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
* gives each route entry its own module instances — a module-level Map would
@@ -83,6 +85,14 @@ export type JobRespawn = (jobId: string) => void;
interface QueuedSteer {
steer: SteerMessage;
+ promise: Promise;
+ /** Wake cargo remains owned here, but must not steer into its own run. */
+ waking?: boolean;
+ /** The constructed vehicle, once startup claims a turn. Until then only
+ * the pump owns waking: an unrelated foreground settlement cannot free it. */
+ wakeAgent?: Agent;
+ /** A failed start waits for a learner run or process restart, not a timer. */
+ deferred?: boolean;
resolve: () => void;
reject: (reason: Error) => void;
/** The live agent this marker was steered into (agent.steer) — set by
@@ -166,18 +176,37 @@ function jobIdOf(steer: SteerMessage): string | null {
return typeof details?.jobId === "string" ? details.jobId : null;
}
-/** Dock bookkeeping: the marker reached the learner's view — flip its job
- * NOW instead of waiting for the delivering turn to settle. Called from
- * BOTH visibility seams: the marker's message_end wire event (live steer
- * injection — the card is on screen while the teacher keeps teaching) and
- * the write-ahead persist in handleChatInput (the wake's input appends —
- * the card is durable in the record). The registry's delivery promise
- * still settles via settleSteers / the runner-200 path; finalizeJob is
- * idempotent, so the later flip is a no-op. Owner ruling (2026-09-26): a
- * delivered subagent leaves the background dock the moment its result is
- * visible, not when the teacher's turn ends. */
-export function noteSteerVisible(steer: SteerMessage): void {
- finalizeSteer(steer);
+/** Call only after canonical conversation persistence. Streaming visibility
+ * is not durable acceptance. The saved marker wins over delivery-bookkeeping
+ * failures; acknowledging delivery never changes the worker's outcome. */
+export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void {
+ for (const message of persisted) {
+ if (message.role === "custom") finalizeSteer(message);
+ }
+ for (const entry of [...(state().queues.get(chatId) ?? [])]) {
+ if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry);
+ }
+}
+
+function removeEntry(chatId: string, entry: QueuedSteer): void {
+ const remaining = (state().queues.get(chatId) ?? []).filter((candidate) => candidate !== entry);
+ if (remaining.length > 0) state().queues.set(chatId, remaining);
+ else state().queues.delete(chatId);
+}
+
+function completeEntry(chatId: string, entry: QueuedSteer): void {
+ finalizeSteer(entry.steer);
+ removeEntry(chatId, entry);
+ entry.resolve();
+}
+
+/** Deletion discards the session's obligations, never wakes a deleted chat. */
+export function discardChatDeliveries(chatId: string): void {
+ const s = state();
+ const queue = s.queues.get(chatId) ?? [];
+ s.queues.delete(chatId);
+ s.userSteers.delete(chatId);
+ for (const entry of queue) entry.resolve();
}
/** Finalize a delivered steer's job bookkeeping — job steers only. */
@@ -186,24 +215,30 @@ function finalizeSteer(steer: SteerMessage): void {
if (jobId) finalizeJob(jobId);
}
-/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
-function failSteer(steer: SteerMessage, err: Error): void {
- const jobId = jobIdOf(steer);
- if (jobId) failJob(jobId, err);
+/** Queue one logical delivery per ID, including in-flight wake cargo.
+ * Job promises resolve on durable marker acceptance (or session deletion);
+ * failed starts stay pending. Non-job probe handoffs keep their existing
+ * wake-acceptance/error semantics. Workers never await this promise. */
+export function deliverJobResult(steer: SteerMessage): Promise {
+ const chatId = chatIdOf(steer);
+ const entry = queueSteer(chatId, steer);
+ void pump(chatId);
+ return entry.promise;
}
-/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
- * Resolves when the wake turn has completed; rejects when delivery fails
- * unrecoverably. Never rejects for queueing itself. */
-export function deliverJobResult(steer: SteerMessage): Promise {
- return new Promise((resolve, reject) => {
- const s = state();
- const chatId = chatIdOf(steer);
- const queue = s.queues.get(chatId) ?? [];
- queue.push({ steer, resolve, reject });
- s.queues.set(chatId, queue);
- void pump(chatId);
- });
+function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer {
+ const queue = state().queues.get(chatId) ?? [];
+ const existing = queue.find((entry) => entry.steer.id === steer.id);
+ if (existing) return existing;
+ let resolve!: () => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
+ // Recovery/registration may own an entry with no waiting caller.
+ void promise.catch(() => undefined);
+ const entry: QueuedSteer = { steer, promise, resolve, reject };
+ queue.push(entry);
+ state().queues.set(chatId, queue);
+ return entry;
}
/** Hand every eligible pending marker to a live agent's steering queue
@@ -227,7 +262,7 @@ function handOffEligible(chatId: string, agent: Agent): void {
// remain deliverable, including the first poll of an answer run.
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
for (const entry of queue) {
- if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
+ if (!entry.waking && !entry.deferred && entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
agent.steer(entry.steer);
entry.handedTo = agent;
}
@@ -254,8 +289,13 @@ function handOffEligible(chatId: string, agent: Agent): void {
* markers are steered into the run right here. The settle seam cannot
* beat the registration: settlement needs a streamed response, which
* never happens inside the constructor call. */
-export function registerLiveAgent(chatId: string, agent: Agent): void {
+export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true, wakeSteerIds?: ReadonlySet): void {
state().liveAgents.set(chatId, agent);
+ restorePendingDeliveries(chatId);
+ for (const entry of state().queues.get(chatId) ?? []) {
+ if (allowDeferred) entry.deferred = false;
+ if (entry.waking && wakeSteerIds?.has(entry.steer.id)) entry.wakeAgent = agent;
+ }
handOffEligible(chatId, agent);
}
@@ -326,7 +366,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
* - entries whose marker the run DELIVERED (in-run follow-up injection —
- * their ids are in the persisted slice) resolve and flip their jobs;
+ * their ids are in the persisted slice) resolve and acknowledge delivery;
* - entries handed to the dying run but never injected (the question
* stopper exited before the follow-up poll; an abort; a delivery that
* raced the final poll) return to PENDING (handedTo cleared) and the
@@ -335,7 +375,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
* This is what makes the live hand-off safe: pi owns no retry for a marker
* stranded in a settled agent's queue, so the registry stays the truth and
* the dead agent's copy is simply abandoned (GC'd with the agent). */
-export function settleSteers(chatId: string, persisted: { id?: string }[]): void {
+export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false, settledAgent?: Agent): void {
const queue = state().queues.get(chatId);
if (!queue || queue.length === 0) return;
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
@@ -345,7 +385,11 @@ export function settleSteers(chatId: string, persisted: { id?: string }[]): void
finalizeSteer(entry.steer);
entry.resolve();
} else {
- entry.handedTo = undefined;
+ const handedHere = entry.handedTo !== undefined && (!settledAgent || entry.handedTo === settledAgent);
+ const wakeHere = settledAgent !== undefined && entry.wakeAgent === settledAgent;
+ if (handedHere) entry.handedTo = undefined;
+ if (wakeHere) { entry.waking = false; entry.wakeAgent = undefined; }
+ if (failed && (handedHere || wakeHere) && jobIdOf(entry.steer)) entry.deferred = true;
remaining.push(entry);
}
}
@@ -373,16 +417,10 @@ export interface AbortOutcome {
* runner registered it at loop start) — the in-flight model call
* aborts, not just the next loop check. The runner unwinds silently
* (its catch checks abortSignal.aborted; finishJob checks status).
- * 2. DROP any queued result steer for this job (the delivering-result
- * phase): resolve its entry without firing the wake. If the wake
- * turn is ALREADY in flight (the pump shifted the entry), it's too
- * late — the result steer lands, and the cancellation steer dedupes
- * against it via steerDelivered (same jobId metadata).
- * 3. FLIP the record to error "Aborted by learner" immediately — the
- * poll reflects the user's action at once (the user chose
- * error-with-reason over a distinct cancelled status). finalizeJob
- * never overwrites a non-running status, so the flip is terminal no
- * matter what the wake does.
+ * 2. REFUSE if computation already finished. Queued results belong to
+ * the session, not to an abortable background worker.
+ * 3. SAVE error "Aborted by learner" and a pending abort delivery together.
+ * Delivery acknowledgement never overwrites the worker's outcome.
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
* the same wake pipeline, so the teacher's promise of a result
* doesn't hang silently in the lesson.
@@ -394,28 +432,18 @@ export function abortJob(jobId: string): AbortOutcome {
const job = stores.jobStore.getJob(jobId);
if (!job) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" };
-
- fireJobAbort(jobId); // no-op when the loop already returned (delivering)
-
- // Drop any queued steers for this job (their deliver() resolves unfired).
- // HANDED entries (already in a live run's follow-up queue) are past
- // recall — same ruling as a fired wake: the result lands, and the abort
- // note below dedupes against it via steerDelivered (same jobId).
- const queue = state().queues.get(job.chatId);
- if (queue) {
- const remaining = queue.filter(
- (e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined,
- );
- if (remaining.length === 0) state().queues.delete(job.chatId);
- else state().queues.set(job.chatId, remaining);
- for (const entry of queue) {
- if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve();
- }
+ if (job.result) {
+ normalizeLegacyResult(job);
+ return { ok: false, reason: "not_running" };
}
- stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
+ fireJobAbort(jobId);
+ stores.jobStore.updateJob(jobId, {
+ status: "error", error: "Aborted by learner", activity: "aborted",
+ delivery: { kind: "abort", status: "pending" },
+ });
void deliverJobResult(buildAbortSteer(job)).catch(() => {
- /* deliverJobResult marks the job error on unrecoverable failure */
+ /* The saved pending abort notice remains owned by the session. */
});
return { ok: true };
}
@@ -470,18 +498,9 @@ async function pump(chatId: string): Promise {
// Idempotent delivery: the transcript already carries these steers
// (an in-run injection persisted, a wake completed, or a duplicate
// deliver raced in). Resolve and flip without re-firing anything.
- const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id));
- for (const entry of queue) {
- if (!stillPending.includes(entry)) {
- finalizeSteer(entry.steer);
- entry.resolve();
- }
- }
- if (stillPending.length === 0) {
- s.queues.delete(chatId);
- return;
- }
- s.queues.set(chatId, stillPending);
+ noteSteersDurable(chatId, record.messages);
+ const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred);
+ if (stillPending.length === 0) { parked = true; return; }
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
@@ -526,28 +545,17 @@ async function pump(chatId: string): Promise {
// The runner is required — chat-handler sets it at init. If some
// module instance missed the wiring, fail loudly rather than hang.
if (!s.turnRunner) {
- s.queues.delete(chatId);
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
- for (const entry of stillPending) {
- failSteer(entry.steer, err);
- entry.reject(err);
- }
+ for (const entry of stillPending) deferOrReject(chatId, entry, err);
+ parked = true;
return;
}
// A live run may have delivered some markers while the gates were
// closed — re-check: only still-missing markers ride the wake.
- const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id));
- for (const entry of stillPending) {
- if (!fire.includes(entry)) {
- finalizeSteer(entry.steer);
- entry.resolve();
- }
- }
- if (fire.length === 0) {
- s.queues.delete(chatId);
- return;
- }
+ noteSteersDurable(chatId, fresh.messages);
+ const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo);
+ if (fire.length === 0) return;
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
// ends in a user dismissal — the session waits for the USER. Hold
@@ -561,50 +569,37 @@ async function pump(chatId: string): Promise {
return;
}
- // DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
- // the carried markers leave the registry BEFORE the turn runs —
- // the wake run's OWN follow-up poll must never see its own cargo,
- // and a 409 (client raced in) must be able to push them back.
- s.queues.delete(chatId);
+ // Keep ownership through the wake. The flag excludes cargo from
+ // its own live-agent handoff while duplicate callers share it.
+ for (const entry of fire) entry.waking = true;
const steers = fire.map((e) => e.steer);
- // Fire the wake (every marker as the turn's input appends). A 409 =
- // a client turn raced in between idle and firing — push the cargo
- // back and retry after backoff (the racing turn's own settlement
- // re-pumps if it beats us to the re-queue).
+ // Fire the wake with cargo still registered. A 409 releases its
+ // vehicle flag and retries after the existing backoff, without
+ // appending another registry entry.
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
return -1;
});
- if (status === 403) {
- // Consent may have changed after the last gate. Preserve completed
- // job/result ownership and wait for an explicit connection change.
- const back = s.queues.get(chatId) ?? [];
- back.push(...fire);
- s.queues.set(chatId, back);
- parked = true;
- return;
- }
- if (status === 409) {
- const back = s.queues.get(chatId) ?? [];
- back.push(...fire);
- s.queues.set(chatId, back);
+ const saved = stores.chatStore.getChat(chatId);
+ if (saved) noteSteersDurable(chatId, saved.messages);
+ const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry));
+ if (status === 403 || status === 409) {
+ for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; }
+ if (status === 403) { parked = true; return; }
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
continue;
}
attempt = 0;
-
- if (status === 200) {
- for (const entry of fire) {
- finalizeSteer(entry.steer);
- entry.resolve();
- }
- } else {
- const err = new Error(`wake turn failed with status ${status}`);
- for (const entry of fire) {
- failSteer(entry.steer, err);
- entry.reject(err);
+ for (const entry of owned) {
+ if (status === 200 && !jobIdOf(entry.steer)) {
+ // Preserve the non-job probe handoff's acceptance semantics.
+ completeEntry(chatId, entry);
+ } else if (status !== 200 || !isTurnRunning(chatId)) {
+ deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`));
}
+ // An accepted live turn still owns its cargo until canonical save
+ // or settlement. HTTP 200 alone is not a durable marker receipt.
}
}
} finally {
@@ -614,7 +609,7 @@ async function pump(chatId: string): Promise {
// a live run are NOT stragglers (settleSteers owns their resurrection)
// — restarting for them would just park in waitForIdle forever.
const q = state().queues.get(chatId) ?? [];
- if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
+ if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined && !e.waking && !e.deferred)) void pump(chatId);
}
}
@@ -638,25 +633,56 @@ state().stopConnectionWatch = onSubscriptionChange(() => {
}
});
-/** Flip the job to its final status now that the lesson has resumed. */
-function finalizeJob(jobId: string): void {
- const job = stores.jobStore.getJob(jobId);
- if (!job || job.status !== "running") return;
- const result = job.result;
- const failed = result ? isSubagentFailure(job.kind, result as never) : true;
- stores.jobStore.updateJob(jobId, {
- status: failed ? "error" : "done",
- activity: failed ? "failed" : "delivered",
- });
+function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void {
+ entry.waking = false;
+ entry.wakeAgent = undefined;
+ if (jobIdOf(entry.steer)) {
+ entry.deferred = true;
+ console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message);
+ } else {
+ removeEntry(chatId, entry);
+ entry.reject(error);
+ }
}
-function failJob(jobId: string, err: Error): void {
- // An aborted job stays aborted even when its (abort) steer
- // delivery fails — the user's action is the terminal fact, not the
- // delivery error. Non-running records are never overwritten here.
- const job = stores.jobStore.getJob(jobId);
- if (!job || job.status !== "running") return;
- stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
+/** Acknowledge session delivery without changing the worker outcome. */
+function finalizeJob(jobId: string): void {
+ try {
+ let job = stores.jobStore.getJob(jobId);
+ if (!job) return;
+ if (!job.delivery) job = normalizeLegacyResult(job);
+ if (job.delivery?.status !== "pending") return;
+ stores.jobStore.updateJob(jobId, { delivery: { ...job.delivery, status: "delivered" } });
+ } catch (error) {
+ // The conversation is already durable. A bookkeeping failure must
+ // never replay that marker or turn a successful worker into a failure.
+ console.error(`[job ${jobId}] delivery acknowledgement failed:`, error);
+ }
+}
+
+function normalizeLegacyResult(job: JobRecord): JobRecord {
+ if (job.delivery || job.status !== "running" || !job.result) return job;
+ const failed = isSubagentFailure(job.kind, job.result as never);
+ return stores.jobStore.updateJob(job.id, {
+ status: failed ? "error" : "done", activity: failed ? "failed" : "finished",
+ delivery: { kind: "result", status: "pending" },
+ }) ?? job;
+}
+
+/** Restore known obligations only; old terminal records are never replayed. */
+function restorePendingDeliveries(chatId: string): void {
+ const record = stores.chatStore.getChat(chatId);
+ if (!record) return;
+ noteSteersDurable(chatId, record.messages);
+ for (const stored of stores.jobStore.listJobs(chatId)) {
+ const job = isJobLive(stored.id) ? stored : normalizeLegacyResult(stored);
+ if (job.delivery?.status !== "pending") continue;
+ const steer = job.delivery.kind === "abort" ? buildAbortSteer(job)
+ : job.result ? buildSubagentSteer(job, job.result as never) : undefined;
+ if (!steer) continue;
+ if (steerDelivered(record.messages, steer.id)) finalizeSteer(steer);
+ else queueSteer(chatId, steer);
+ }
}
/**
@@ -669,7 +695,7 @@ function failJob(jobId: string, err: Error): void {
async function waitForIdle(chatId: string): Promise {
for (;;) {
const record = stores.chatStore.getChat(chatId);
- if (!record) return;
+ if (!record || !(state().queues.get(chatId) ?? []).some((entry) => !entry.waking && !entry.deferred && !entry.handedTo)) return;
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
if (!blocked) {
await delay(GRACE_MS);
@@ -699,9 +725,9 @@ const SWEEP_THROTTLE_MS = 30_000;
* hung-but-alive loops beside themselves — the figure-maker incident,
* 2026-09-18). A record WITH a live registration is provably executing
* in THIS process — never touched, however old: a hung first model call
- * (zero usage ever billed) stays honestly "running" in the dock until
- * END kills it. The abort registration spans loop start to delivery
- * completion, so a live registration can never belong to a dead run.
+ * stays honestly running until END kills it. Terminal workers with known
+ * pending delivery are restored independently; old terminal records without
+ * delivery metadata are never inferred to owe a delivery.
*/
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
@@ -713,18 +739,14 @@ export function recoverStaleJobs(): void {
try {
for (const chat of stores.chatStore.listChats()) {
for (const job of stores.jobStore.listJobs(chat.id)) {
- if (job.status !== "running") continue;
- if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
+ if (isJobLive(job.id)) continue; // never re-run beside live computation
if (!stores.chatStore.getChat(job.chatId)) continue;
- if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
- if (job.result) {
- // The wake never fired (or crashed mid-flight): re-deliver.
- const steer = buildSubagentSteer(job, job.result as never);
- stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
- void deliverJobResult(steer).catch(() => {
- /* deliverJobResult marks the job error on failure */
- });
- } else {
+ if (isTurnRunning(job.chatId)) continue;
+ const recovered = normalizeLegacyResult(job);
+ if (recovered.delivery?.status === "pending") {
+ restorePendingDeliveries(job.chatId);
+ void pump(job.chatId);
+ } else if (job.status === "running" && !job.result) {
// The sub-agent loop itself died with the process: re-spawn.
if (!s.respawn) continue;
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
diff --git a/src/server/keys.ts b/src/server/keys.ts
index 706de52..3e197f1 100644
--- a/src/server/keys.ts
+++ b/src/server/keys.ts
@@ -43,6 +43,7 @@ import {
AUTH_KEYS,
AUTH_PROVIDER_KEYS,
AUTH_PROVIDER_ENV_VARS,
+ CUSTOM_PROVIDER,
type AuthKeyInfo,
type AuthKeyStatus,
genericProviderKeyGuard,
@@ -53,29 +54,19 @@ import {
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
+import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts";
+import { claudeCodeAvailable } from "./claude-code-config.ts";
+import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
+import { CUSTOM_AUTH_BINDING } from "../core/custom-provider-auth.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");
+// 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";
-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];
-}
-
-/** Is `id` a WRITABLE key name — curated catalog or model-provider env
- * var (provider-keys interlude: PUT/DELETE accept both). */
+/** Ordinary writable key names. Custom auth writes must use the atomic
+ * endpoint-binding seam, never the generic single-key API. */
export function isWritableAuthKey(id: string): boolean {
- return isKnownAuthKey(id) || isProviderEnvVar(id);
+ return id !== CUSTOM_AUTH_BINDING && id !== CUSTOM_PROVIDER.envVar && (isKnownAuthKey(id) || isProviderEnvVar(id));
}
/** The settings page's provider dropdown: every pi-ai provider that takes
@@ -101,7 +92,7 @@ export function envKeysIgnored(): boolean {
*/
export function initAuthEnv(): void {
const record = boot();
- const fileVars = authStore.authVars();
+ const fileVars = Object.fromEntries(Object.entries(authStore.authVars()).filter(([name]) => name !== CUSTOM_AUTH_BINDING));
const managed = new Set([
...Object.keys(fileVars),
...AUTH_KEYS.map((k) => k.id),
@@ -109,6 +100,7 @@ export function initAuthEnv(): void {
// an env-exported OPENAI_API_KEY with a file copy gets honest shadow
// semantics, exactly like the curated vars.
...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
+ CUSTOM_PROVIDER.envVar,
]);
if (envKeysIgnored()) {
// THE FRESH-USER SWITCH: delete every managed env var (re-run on
@@ -136,7 +128,7 @@ export function initAuthEnv(): void {
/** Is this env-var name one the keys system manages (curated, provider,
* or held in the file)? */
function isManagedName(name: string): boolean {
- return isWritableAuthKey(name) || name in authStore.authVars();
+ return name !== CUSTOM_AUTH_BINDING && (isWritableAuthKey(name) || name in authStore.authVars());
}
/**
@@ -158,6 +150,7 @@ export function resolveKeyStatuses(
}
const rows: AuthKeyStatus[] = [];
for (const id of ids) {
+ if (id === CUSTOM_AUTH_BINDING) continue;
const envValue = env[id];
const fileValue = fileVars[id];
const isRealEnv = realEnv.has(id);
@@ -169,7 +162,9 @@ export function resolveKeyStatuses(
id,
info: AUTH_KEYS.find((k) => k.id === id) ?? null,
present: live !== undefined,
- masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null,
+ // Custom keys may be one character: even a masked suffix can expose
+ // the entire credential. Preserve inventory, return no key characters.
+ masked: live !== undefined ? id === CUSTOM_PROVIDER.envVar ? "••••" : `${maskKey(live)} (${live.trim().length} chars)` : null,
source: isRealEnv ? "env" : live !== undefined ? "file" : "none",
shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue,
});
@@ -179,7 +174,7 @@ export function resolveKeyStatuses(
/** The GET view: every curated key (present or not) + unknown file vars. */
export function keyStatuses(): AuthKeyStatus[] {
- return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS);
+ return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, new Set([...AUTH_PROVIDER_ENV_VARS, CUSTOM_PROVIDER.envVar]));
}
/** The live value for one var, env-first (the env-wins rule) — the canonical
@@ -187,6 +182,7 @@ export function keyStatuses(): AuthKeyStatus[] {
* STT route was the one cold-boot hole: a first-ever request to it would
* otherwise read env before any fill-gap ran). */
export function keyValue(id: string): string | undefined {
+ if (id === CUSTOM_AUTH_BINDING) return undefined;
// The fresh-user switch: ONLY the file store counts (belt-and-
// suspenders — the boot deletion already emptied the env; this guards
// any env key reintroduced after boot).
@@ -201,6 +197,10 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
+ if (provider === CUSTOM_PROVIDER.id) return customProviderAvailable();
+ // 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;
@@ -213,6 +213,7 @@ export function providerKeyPresent(provider: string): boolean {
* reports shadowedByEnv so the page can explain it).
*/
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
+ if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" };
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
if (!shape.ok) return { ok: false, error: shape.error };
const trimmed = value.trim();
@@ -232,6 +233,7 @@ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyS
* wins) and the status shows the key still present from env.
*/
export function deleteKey(id: string): AuthKeyStatus {
+ if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) throw new Error("Use the custom endpoint editor");
authStore.deleteAuthVar(id);
const record = boot();
if (record.fillGap.has(id) && !record.realEnv.has(id)) {
@@ -244,3 +246,4 @@ export function deleteKey(id: string): AuthKeyStatus {
// Reflect the file into the env at first import (idempotent; the module is
// imported by every route/handler path that touches keys).
initAuthEnv();
+installCustomProviderSource();
diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts
index ad41dca..1aa4e7a 100644
--- a/src/server/model-catalog.ts
+++ b/src/server/model-catalog.ts
@@ -20,6 +20,7 @@
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { piModels } from "../agent/models.ts";
+import { customProviderAvailable } from "./custom-provider.ts";
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts";
@@ -31,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)]; }
@@ -47,8 +49,17 @@ import type {
SlotPins,
} from "../core/contracts/models.ts";
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
-import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
-import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
+import { AUTH_PROVIDER_KEYS, CUSTOM_PROVIDER } 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,
+ customProviderUsable,
+ type BackgroundModelRole,
+ type ModelSlotName,
+ type ModelsSettings,
+} from "../core/settings.ts";
/** The picker's client-safe model entry (contract: core/contracts/models.ts). */
export type ModelDto = ModelCatalogEntry;
@@ -135,13 +146,20 @@ export function catalogSlots(): SlotPins {
* with keyPresent read env-wins through the keys seam, fresh per call —
* saving a key immediately federates the provider into the picker. */
export function catalogProviders(): CatalogProvider[] {
+ piModels();
return AUTH_PROVIDER_KEYS.map((p) => ({
id: p.id,
envVar: p.envVar,
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) })),
+ // The custom endpoint: a row only when usable (base URL set). keyPresent
+ // reflects usability, not key presence (keyless local servers are allowed).
+ ...(customProviderUsable(effectiveSettings().customProvider)
+ ? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: customProviderAvailable() }]
+ : []),
]).sort((a, b) => a.id.localeCompare(b.id));
}
@@ -161,21 +179,22 @@ export function toModelDto(model: Model): ModelDto {
reasoning: model.reasoning,
input: [...model.input],
supportedLevels: getSupportedThinkingLevels(model),
- cost: {
+ cost: model.provider === CLAUDE_CODE_PROVIDER || model.provider === CUSTOM_PROVIDER.id ? 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). */
@@ -183,7 +202,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);
}
@@ -245,10 +264,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
return { ok: true };
}
-export function validateModelsSettings(settings: ModelsSettings): SettingsValidation {
+/** Validate introduced/replaced model selections, not unchanged saved pins.
+ * A disconnected subscription loses its available catalog, but its stored
+ * selections must not prevent repairing another slot or editing thinking.
+ * Only the SAME slot's provider+id can pass through; runtime admission stays
+ * separate. Callers without a stored section retain strict catalog checking. */
+export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation {
for (const name of MODEL_SLOTS) {
const slot = settings[name];
if (!slot) continue;
+ const previous = stored?.[name]?.model;
+ if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue;
const verdict = validateSlotSpec(name, slot);
if (!verdict.ok) return verdict;
}
@@ -280,9 +306,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
// runtime — and the clamped value is what lands in the record. The
// server is the truth: the record shows the clamped level after write.
-/** Clamp one level against one model (identity when the model is unknown
- * or the level already supported — the existence gate rejects unknown
- * models separately, so this only ever sees real ones in practice). */
+/** Clamp one level against one model. Preserve the preference when metadata
+ * is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
const model = connectionModel(provider, id);
return model ? clampThinkingLevel(model, level) : level;
diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts
index b75a570..40a831d 100644
--- a/src/server/pi/ui-stream.ts
+++ b/src/server/pi/ui-stream.ts
@@ -1,6 +1,4 @@
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
-import type { SteerMessage } from "../../core/contracts.ts";
-import { noteSteerVisible } from "../jobs.ts";
import { ToolCallIdUniquifier } from "./call-ids.ts";
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
@@ -79,6 +77,10 @@ export interface PiTurnSpec {
* event stream itself is verbatim — provider errors ride their
* message's stopReason/errorMessage through untouched). */
onError?: (error: unknown) => string;
+ /** Report settlement failure while the views are still open. */
+ onSettleError?: (error: unknown) => string;
+ /** Release liveness even if the normal finish seam throws. */
+ onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
* Receives the agent's FULL final state (native messages) — the caller
* persists the slice past its own base count. */
@@ -119,11 +121,12 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
- const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
+ const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
let sawAbort = false;
+ let reportedErrorText: string | undefined;
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
@@ -221,11 +224,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// messages of its own (the prompt is composed per turn), so dropping
// the role wholesale is exact; revisit if prompt-updates ever land.
const settleWith = () => {
- const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
- onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
+ try {
+ const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
+ onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
+ } catch (err) {
+ console.error("[turn] settlement failed:", err);
+ try { onSettleFailure?.(err, reportedErrorText); }
+ catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
+ if (reportedErrorText === undefined) {
+ reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
+ send({ type: "error", errorText: reportedErrorText });
+ }
+ }
};
const handle = (event: AgentEvent) => {
+ if (settled) return;
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
// every consumer downstream shares. No uniquifier → verbatim.
const uniqued = uniquifier ? uniquifier.event(event) : event;
@@ -239,15 +253,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
if (event.type === "message_end") {
const m = event.message as { role?: string; stopReason?: string } | undefined;
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
- // Dock fix (owner ruling 2026-09-26): an injected steer marker's
- // message_end = the card is visible to the learner — flip the
- // job's bookkeeping now (the turn may keep teaching for minutes;
- // settleSteers re-runs the flip as a no-op idempotently).
- if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
}
if (event.type === "agent_end") {
- closeAll();
+ // Persist/report before the terminal closes every view to new frames.
settleWith();
+ closeAll();
}
};
requestCycle?.bindRelease(handle);
@@ -273,11 +283,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// stream as stopReason "error"): surface, close, persist. If the
// agent already settled (agent_end reached before the throw),
// the seam has run — never settle twice.
- if (settled) return;
+ if (settled) {
+ console.error("[turn] error after settlement:", err);
+ return;
+ }
console.error("[turn] agent loop failed:", err);
- send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
- closeAll();
+ reportedErrorText = onError?.(err) ?? "An error occurred.";
+ send({ type: "error", errorText: reportedErrorText });
settleWith();
+ closeAll();
}
})();
diff --git a/src/server/pricing.ts b/src/server/pricing.ts
index eaec42d..097669c 100644
--- a/src/server/pricing.ts
+++ b/src/server/pricing.ts
@@ -4,12 +4,16 @@
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
+import { CUSTOM_PROVIDER } from "../core/auth-keys.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) || [CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER.id].includes(id.slice(0, colon))) 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 142e1fe..87e097d 100644
--- a/src/server/settings-body.ts
+++ b/src/server/settings-body.ts
@@ -18,13 +18,16 @@ import type {
AppearanceSettings,
BackgroundModelRole,
CompactionSettings,
+ CustomModelOverride,
GeneralSettings,
+ DictationSettings,
GraphSettings,
ModelsSettings,
RoleModelOverride,
} from "../core/settings.ts";
-import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
+import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, CUSTOM_MODEL_PIN_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
+import { isDictationLanguage } from "../core/dictation.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult =
@@ -105,6 +108,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 } {
@@ -401,8 +413,56 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult {
+ const raw = asRecord(body);
+ if (!raw) return { ok: false, error: "Invalid JSON body" };
+ if (raw.language === undefined) return { ok: true, settings: {} };
+ if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" };
+ return { ok: true, settings: { language: raw.language } };
+}
+
+/** The custom endpoint's per-model pins (PATCH /api/custom-provider): the
+ * whole desired map, replace-whole like every section. Strict write side — a
+ * token count must be a whole number at or above the floor (the read side
+ * clamps, this rejects — the compaction discipline). `reasoning` is the
+ * tri-state capability pin, not a backend execution toggle. */
+export function parseCustomModelOverridesBody(
+ body: unknown,
+): SettingsBodyResult> {
+ const raw = asRecord(body);
+ if (!raw) return { ok: false, error: "Invalid JSON body" };
+ const map = asRecord(raw.overrides);
+ if (!map) return { ok: false, error: "overrides must be a { modelId: { context?, maxTokens?, reasoning? } } map" };
+ const out: Record = {};
+ for (const [key, value] of Object.entries(map)) {
+ const id = key.trim();
+ if (!id) return { ok: false, error: "override keys must be non-empty model ids" };
+ const entry = asRecord(value);
+ if (!entry) return { ok: false, error: `override for ${id} must be an object` };
+ const pin: CustomModelOverride = {};
+ for (const field of ["context", "maxTokens"] as const) {
+ const v = entry[field];
+ if (v === undefined) continue;
+ if (typeof v !== "number" || !Number.isInteger(v) || v < CUSTOM_MODEL_PIN_FLOOR) {
+ return {
+ ok: false,
+ error: `${field} for ${id} must be a whole number of tokens at least ${CUSTOM_MODEL_PIN_FLOOR}`,
+ };
+ }
+ pin[field] = v;
+ }
+ if (entry.reasoning !== undefined) {
+ if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
+ pin.reasoning = entry.reasoning;
+ }
+ Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
+ }
+ return { ok: true, settings: out };
+}
+
+/** The section→parser map for PUT /api/settings/[section]. The custom endpoint
+ * is NOT a section — it connects via POST /api/custom-provider. */
export const SETTINGS_SECTIONS = {
general: parseGeneralSettingsBody,
chat: parseSettingsBody,
@@ -411,6 +471,8 @@ export const SETTINGS_SECTIONS = {
graph: parseGraphSettingsBody,
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
+ dictation: parseDictationSettingsBody,
+ claudeCode: parseClaudeCodeSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
diff --git a/src/server/stores.ts b/src/server/stores.ts
index c63f546..2e543b1 100644
--- a/src/server/stores.ts
+++ b/src/server/stores.ts
@@ -11,6 +11,7 @@ import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts";
import { settingsStore } from "../store/settings.ts";
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
+import { syncCustomProvider } from "./custom-provider.ts";
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
export const stores = {
chatStore,
@@ -32,3 +33,7 @@ export type Stores = typeof stores;
export function effectiveSettings(): EffectiveSettings {
return resolveSettings(settingsStore.readSettings());
}
+
+// Boot: register the custom endpoint (when configured) into piModels(); the
+// POST /api/custom-provider route re-syncs after every connect.
+syncCustomProvider();
diff --git a/src/server/turn-liveness.ts b/src/server/turn-liveness.ts
index 27c8cc1..b5821fc 100644
--- a/src/server/turn-liveness.ts
+++ b/src/server/turn-liveness.ts
@@ -21,6 +21,7 @@
*/
import { emit } from "../core/events.ts";
+import { clearTurnFailure } from "./turn-runs.ts";
interface LhGlobal {
__lhTurnRegistry?: Map;
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
*/
export function beginTurn(chatId: string): boolean {
if (isTurnRunning(chatId)) return false;
+ clearTurnFailure(chatId);
inFlight.set(chatId, Date.now());
emit("turn-started", { chatId });
return true;
}
/**
- * Mark the chat's turn as finished (runs in onFinish, aborted or not —
- * AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
- * durable truth). Emits only when an entry was actually present: a
+ * Mark the chat's turn as finished after normal settlement, or after the
+ * failure notice has been retained by settlement cleanup. A subsequent
+ * fetch reads durable truth, never a promise that an unsaved reply landed.
+ * Emits only when an entry was actually present: a
* defensive no-op endTurn must not fire a spurious settle.
*/
export function endTurn(chatId: string): void {
diff --git a/src/server/turn-runs.ts b/src/server/turn-runs.ts
index 29bf49a..3fbf9e7 100644
--- a/src/server/turn-runs.ts
+++ b/src/server/turn-runs.ts
@@ -13,7 +13,9 @@
* Registration lifecycle: chat-handler registers the handle the moment
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
* — after persist, usage, extraction, and endTurn — so anything that sees
- * "no running turn" also sees an already-persisted record. The per-chat
+ * "no running turn" sees durable record truth. Failed settlement also
+ * retains its small notice before unregistering, so late attachments do
+ * not mistake an unsaved reply for success. The per-chat
* 409 guard means a chatId can hold at most one handle, and beginTurn's
* 30-minute TTL bounds the pathological never-settling case the same way
* it does for the liveness map: if onFinish never fires, the stale entry
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
interface LhTurnRunsGlobal {
__lhTurnRuns?: Map;
+ __lhTurnFailures?: Map;
}
function runs(): Map {
@@ -37,6 +40,25 @@ function runs(): Map {
return (g.__lhTurnRuns ??= new Map());
}
+/** One small, transient notice per failed session. Never stores messages.
+ * Cleared on the next accepted begin, session deletion, or process exit. */
+function failures(): Map {
+ const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
+ return (g.__lhTurnFailures ??= new Map());
+}
+
+export function rememberTurnFailure(chatId: string, errorText: string): void {
+ failures().set(chatId, errorText);
+}
+
+export function turnFailure(chatId: string): string | undefined {
+ return failures().get(chatId);
+}
+
+export function clearTurnFailure(chatId: string): void {
+ failures().delete(chatId);
+}
+
/** Register the chat's running turn (chat-handler, right after the turn
* starts). One per chat by construction — the 409 guard. */
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
* turn (raced settle, or the client checked turnInFlight a beat too
* late): a same-dialect SSE response whose single chunk tells the client
- * "nothing to attach to — the record is current, refetch it". Chosen as
- * an EVENT (not a status code) so the reattach path consumes ONE wire
+ * "nothing to attach to — refetch durable truth". A retained settlement
+ * failure is delivered first, including when no view attached in time.
+ * Chosen as an EVENT (not a status code), consuming ONE wire
* protocol end to end. */
-export function settledTurnResponse(): Response {
- return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
+export function settledTurnResponse(chatId?: string): Response {
+ const errorText = chatId ? turnFailure(chatId) : undefined;
+ const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
+ return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts
index b369697..ef7728a 100644
--- a/src/server/turn-service.ts
+++ b/src/server/turn-service.ts
@@ -1,5 +1,5 @@
/**
- * The chat-turn services — one named, error-contained seam per turn
+ * The chat-turn services — one named seam per turn
* responsibility, extracted from the chat-handler so it composes instead of
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
+/** Distinguish a failed canonical save from later bookkeeping failures. */
+export class TurnPersistenceError extends Error {
+ constructor(cause: unknown) {
+ super("The reply could not be saved.", { cause });
+ this.name = "TurnPersistenceError";
+ }
+}
+
/**
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
* turn's own messages — the agent loop's native history, provider/model/
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
* - Plan blocks: one frozen snapshot per successful plan_init call,
* idempotent (persist replays are safe).
*
- * Returns the stamped new messages (usage provenance reads them).
+ * Returns the stamped new messages (usage provenance reads them). Throws
+ * TurnPersistenceError on failure so the runner can report it before DONE.
*/
export function persistTurn(
chatStore: ChatStore,
@@ -56,7 +65,7 @@ export function persistTurn(
): LhMessage[] {
try {
const record = chatStore.getChat(chatId);
- if (!record) return [];
+ if (!record) throw new Error(`Chat not found: ${chatId}`);
const stamped: LhMessage[] = [];
for (const m of turnMessages.slice(baseCount)) {
const message = m as LhMessage;
@@ -79,7 +88,7 @@ export function persistTurn(
return stamped;
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
- return [];
+ throw new TurnPersistenceError(err);
}
}
diff --git a/src/server/web-subscription-http.ts b/src/server/web-subscription-http.ts
new file mode 100644
index 0000000..3d5d7a9
--- /dev/null
+++ b/src/server/web-subscription-http.ts
@@ -0,0 +1,68 @@
+import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
+import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
+
+const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
+/** Same-origin browser controls, not user authentication. Web/dev deliberately
+ * retains the existing shared-server/API-key trust model. Never trust forwarded
+ * origins or add CORS grants here. A public deployment needs its own access gate. */
+export function subscriptionRequestOrigin(request: Request): string | undefined {
+ try {
+ const url = new URL(request.url);
+ const host = request.headers.get("host") ?? url.host;
+ const addressed = new URL(`${url.protocol}//${host}`);
+ if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
+ return addressed.origin;
+ } catch { return; }
+}
+function authorized(request: Request): boolean {
+ const origin = subscriptionRequestOrigin(request);
+ if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
+ if (request.headers.get("sec-fetch-site") === "cross-site") return false;
+ const supplied = request.headers.get("origin");
+ return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
+}
+function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
+export async function subscriptionControlsResponse(request: Request): Promise {
+ if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
+ if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
+ try {
+ if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
+ if (request.method !== "POST") return error("Unsupported method.", 405);
+ if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
+ // Bound both advertised and streamed input before parsing commands.
+ if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
+ const reader = request.body?.getReader();
+ if (!reader) return error("Expected a command.", 400);
+ const chunks: Uint8Array[] = []; let size = 0;
+ try {
+ for (;;) {
+ const next = await reader.read(); if (next.done) break;
+ size += next.value.byteLength;
+ if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
+ chunks.push(next.value);
+ }
+ } finally { reader.releaseLock(); }
+ const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
+ const fields = command as Record;
+ const providerId = fields.providerId;
+ if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
+ const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
+ if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
+ return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
+ } catch {
+ return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
+ }
+}
+/** One-use same-origin navigation. Full provider authorization URLs go only to
+ * a top-level browser redirect, never status JSON, the SSE bus or application JS. */
+export async function subscriptionBrowserResponse(request: Request): Promise {
+ if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
+ const origin = subscriptionRequestOrigin(request);
+ if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
+ const id = new URL(request.url).searchParams.get("id");
+ if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
+ const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
+ if (!target) return error("This sign-in action is no longer available.", 410);
+ return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
+}
diff --git a/src/server/web-subscriptions.ts b/src/server/web-subscriptions.ts
new file mode 100644
index 0000000..4e40538
--- /dev/null
+++ b/src/server/web-subscriptions.ts
@@ -0,0 +1,127 @@
+/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
+ * its existing main-process owner; a broken desktop IPC channel cannot fall
+ * back to these files. One server process must own each configured auth dir. */
+import { AsyncLocalStorage } from "node:async_hooks";
+import { randomUUID } from "node:crypto";
+import path from "node:path";
+import { AUTH_FILE } from "../../config.ts";
+import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
+import { createSubscriptions } from "../../electron/subscriptions.cjs";
+import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
+import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
+import { emit } from "../core/events.ts";
+import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
+import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
+import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
+export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
+interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise }
+const KEY = Symbol.for("lh.webSubscriptions");
+interface Registry {
+ owner?: ReturnType;
+}
+function registry(): Registry {
+ const global = globalThis as typeof globalThis & { [KEY]?: Registry };
+ return global[KEY] ??= {};
+}
+function publish(channel: Channel, packet: Packet) {
+ publishWebSubscriptionPacket(channel, packet);
+ emit("subscriptions-changed", {});
+}
+async function loadSDK() {
+ const [sdk, copilot, xai] = await Promise.all([
+ import("@earendil-works/pi-ai"),
+ import("@earendil-works/pi-ai/providers/github-copilot"),
+ import("@earendil-works/pi-ai/providers/xai"),
+ ]);
+ return { ...sdk, ...copilot, ...xai };
+}
+
+/** Injectable factories/stores keep all test authentication offline. */
+export function createWebSubscriptionOwner(options: {
+ directory: string;
+ chatgptStore?: Parameters[0]["store"];
+ sdk?: unknown;
+ onPacket?: (channel: Channel, packet: Packet) => void;
+} ) {
+ const notify = options.onPacket ?? (() => {});
+ const launches = new AsyncLocalStorage();
+ const actions = new Map();
+ let chatGPTLaunch = 0;
+ const openExternal = async (target: string) => {
+ const captured = launches.getStore();
+ if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
+ captured.push(target);
+ };
+ const chatgpt = createChatGPT({
+ store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
+ openExternal,
+ onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
+ });
+ const native = createSubscriptions({
+ directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
+ onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
+ });
+ return {
+ chatgpt, native,
+ async control(providerId: string, command: Record, origin: string) {
+ const targets: string[] = [];
+ const value = await launches.run(targets, () => providerId === "chatgpt"
+ ? chatGPTControl(command, chatgpt)
+ : subscriptionControl({ ...command, providerId }, native));
+ let browserAction: string | undefined;
+ const target = targets[0];
+ if (target) {
+ const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
+ for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
+ const id = randomUUID();
+ const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
+ const current = providerId === "chatgpt"
+ ? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
+ : async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
+ actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
+ browserAction = `/api/subscriptions/browser?id=${id}`;
+ }
+ return { value, ...(browserAction ? { browserAction } : {}) };
+ },
+ async takeBrowserAction(id: string, origin: string): Promise {
+ const action = actions.get(id);
+ if (!action || action.origin !== origin) return;
+ actions.delete(id);
+ if (action.expires <= Date.now() || !await action.current()) return;
+ return action.target;
+ },
+ close() { actions.clear(); chatgpt.close(); native.close(); },
+ };
+}
+export function webSubscriptionOwner() {
+ if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
+ const state = registry();
+ if (!state.owner) {
+ state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
+ process.once("exit", () => state.owner?.close());
+ }
+ return state.owner;
+}
+/** Private runtime calls only: no HTTP lease/token operation exists. */
+export async function webSubscriptionRequest(channel: Channel, op: string, fields: Record): Promise {
+ const owner = webSubscriptionOwner();
+ let value: unknown;
+ if (channel === "lh:chatgpt") {
+ if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
+ else if (op === "models") value = await owner.chatgpt.models();
+ else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
+ else throw new Error("Invalid private operation.");
+ } else {
+ if (op === "snapshot") value = await owner.native.snapshot();
+ else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
+ else throw new Error("Invalid private operation.");
+ }
+ return value as T;
+}
+configureWebSubscriptionRuntime(webSubscriptionRequest);
+
+export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
+ const owner = webSubscriptionOwner();
+ const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
+ return { chatgpt, native };
+}
diff --git a/src/store/auth.ts b/src/store/auth.ts
index 2fe6edd..4985cb4 100644
--- a/src/store/auth.ts
+++ b/src/store/auth.ts
@@ -7,9 +7,10 @@
*
* The server (src/server/keys.ts) is the only intended writer: it applies
* shape validation before setAuthVar and env precedence on top of
- * authVars(). This adapter persists what it is given, never throws
- * (absent/corrupt file = {} = "no keys yet", the documented first-boot
- * state, matching settings doctrine), and preserves unknown vars verbatim
+ * authVars(). Ordinary reads never throw (absent/corrupt file = {} =
+ * "no keys yet"); custom-only pair reads/writes below are deliberately
+ * strict so an unreadable private record is never overwritten as {}.
+ * Ordinary writes preserve unknown string vars verbatim
* (forward-compat: future entries — e.g. a plans-login token — ride along
* untouched until a catalog entry adopts them).
*/
@@ -18,6 +19,8 @@ import fs from "node:fs";
import path from "node:path";
import { AUTH_FILE } from "../../config.ts";
import type { AuthStore } from "../core/ports.ts";
+import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
+import { CUSTOM_AUTH_BINDING, parseCustomAuthBinding, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
/** The flat string→string map, or {} for absent/corrupt/non-object. */
function readMap(): Record {
@@ -36,7 +39,7 @@ function readMap(): Record {
* leave a 0644 file (secrets) or a truncated JSON (looks like "no keys").
* tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
* rename so the mode never exists relaxed on the final path. */
-function writeMap(map: Record): void {
+function writeMap(map: Record): void {
const dir = path.dirname(AUTH_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
@@ -50,14 +53,50 @@ export const authStore: AuthStore = {
return readMap();
},
setAuthVar(name, value) {
+ assertOrdinaryVar(name);
const map = readMap();
map[name] = value;
writeMap(map);
},
deleteAuthVar(name) {
+ assertOrdinaryVar(name);
const map = readMap();
if (!(name in map)) return; // absent = silent success, idempotent
delete map[name];
writeMap(map);
},
};
+
+/** Custom writes must never turn an unreadable/corrupt private file into {}.
+ * Other providers retain their existing adapter semantics. Unknown JSON values
+ * survive custom-only updates, not just the ordinary string projection. */
+function readCustomMap(): Record {
+ let text: string;
+ try { text = fs.readFileSync(AUTH_FILE, "utf8"); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Custom authorization storage unavailable"); }
+ let raw: unknown;
+ try { raw = JSON.parse(text); } catch { throw new Error("Custom authorization storage unavailable"); }
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Custom authorization storage unavailable");
+ const map = raw as Record