Compare commits

...
49 Commits
Author SHA1 Message Date
amosblomqvist b277ee3e0f test: await verified ChatGPT storage recovery completion 2026-10-25 02:45:13 +03:00
amosblomqvist 9171a4f49c chore: prepare 0.3.7 2026-10-25 02:27:57 +03:00
amosblomqvist 7fb9a1505c Merge native Copilot and Grok subscription connections 2026-10-25 02:13:00 +03:00
amosblomqvist 3cc598ded5 fix: distinguish ChatGPT authorization recovery from storage failures 2026-10-25 01:52:52 +03:00
amosblomqvist 55a6501a09 fix: streamline subscription connection dialogs and actions 2026-10-25 01:34:46 +03:00
amosblomqvist d6e317c6fe feat: add app-owned Copilot and Grok subscription connections 2026-10-25 00:27:52 +03:00
amosblomqvist 0a9dabfdc2 Merge saved chat error visibility fixes 2026-10-25 00:04:16 +03:00
amosblomqvist 5cf4e589be fix: keep current provider failures authoritative across reattach 2026-10-25 00:00:29 +03:00
amosblomqvist 671d2c8bb7 fix: restore final provider failure in chat error banner 2026-10-24 23:55:27 +03:00
amosblomqvist e8c81faad9 ci: notify website when a stable desktop release is published 2026-10-24 23:02:10 +03:00
amosblomqvist 480a29aaea fix: prevent macOS backend Dock registration
Preload a backend-only JS title accessor from an unpacked macOS resource before Next starts. Preserve Windows and Linux launch arguments, and test title semantics, process scope, argument order, and resource copying.

Based on the prevention approach proposed by marchellodev in PR #47.
2026-10-24 21:56:57 +03:00
amosblomqvist c549494488 test(chatgpt): await current reconnect completion deterministically 2026-10-24 20:23:42 +03:00
amosblomqvist 74b0c65843 release: prepare 0.3.6 2026-10-24 20:07:44 +03:00
amosblomqvist 04037e98d6 Merge compaction and native usage accounting fixes 2026-10-24 19:53:18 +03:00
amosblomqvist 36338b3b55 fix: distinguish desktop bridge from dev-server IPC 2026-10-24 19:42:05 +03:00
amosblomqvist 8946209f98 fix: preserve unknown graph costs and cache detail 2026-10-24 19:11:39 +03:00
amosblomqvist 624c91a437 fix: align compaction cuts and native usage accounting 2026-10-24 19:05:02 +03:00
amosblomqvist 24d1dda07d fix(chatgpt): use private JSON credential storage without Keychain 2026-10-24 18:41:03 +03:00
amosblomqvist 50a2db775d release: prepare 0.3.5 2026-10-24 16:23:39 +03:00
amosblomqvist 6788a01add Add accounts from ChatGPT management dialog 2026-10-24 15:58:26 +03:00
amosblomqvist 102aa6f7ae Fence stale ChatGPT cancellation replies and steady toggle cursor 2026-10-24 15:45:54 +03:00
amosblomqvist 42abeb65ae Unify ChatGPT background use and account management 2026-10-24 15:28:26 +03:00
amosblomqvist 8691470d03 Fix pending ChatGPT login lifecycle and IPC error handling 2026-10-24 14:22:44 +03:00
amosblomqvist b8abad2b4f Refine ChatGPT setup and model provider settings 2026-10-24 14:02:54 +03:00
amosblomqvist 0fc6d4ab94 feat: add verified desktop ChatGPT subscription connection 2026-10-24 12:17:08 +03:00
amosblomqvist ae2fad56c4 chore: prepare platform preview 0.3.5-preview.1 2026-10-24 10:43:51 +03:00
amosblomqvist b6bc9edcda Merge commit '4bd20be5291cfc3682761201d825d3635bcc9466' into integrate/platform-preview-1 2026-10-24 09:39:57 +03:00
amosblomqvist 480064b19e Merge commit '7fb891389d377d24d3a568b81c61169ab7334df5' into integrate/platform-preview-1 2026-10-24 09:39:57 +03:00
amosblomqvist 7fb891389d fix: retain Google provider dependencies in desktop builds 2026-10-24 08:15:04 +03:00
amosblomqvist 4bd20be529 fix: keep research available without search keys 2026-10-24 06:52:36 +03:00
amosblomqvist 6cba9c8c5a release: prepare 0.3.4 2026-10-23 20:29:02 +03:00
amosblomqvist cc657faa93 Merge native Pi identity and provider catalog upgrade 2026-10-23 17:25:10 +03:00
amosblomqvist 6685b3d09a fix: use native Pi identity and refresh provider catalogs 2026-10-23 17:25:10 +03:00
amosblomqvist b15db907ab Merge KaTeX security repair 2026-10-23 17:11:57 +03:00
amosblomqvist 93017d767e security: upgrade KaTeX to 0.18.2 across active renderers 2026-10-23 17:11:57 +03:00
amosblomqvist 388addff3e release: prepare 0.3.3 2026-10-23 06:18:51 +03:00
amosblomqvist c36c1c6b4d ui: simplify import feedback and shorten waiting status 2026-10-23 06:14:33 +03:00
amosblomqvist a67366005a Merge notes import reliability repair 2026-10-23 04:43:56 +03:00
amosblomqvist 2d67376b76 fix: always run explicit notes imports and surface failures 2026-10-23 04:43:56 +03:00
amosblomqvist aef01c44a0 Merge provider credential repair 2026-10-23 04:26:25 +03:00
amosblomqvist 261a9b9927 fix: honor selected-provider credentials in visual and PDF agents 2026-10-23 04:26:25 +03:00
amosblomqvist 1c5024c8c2 release: prepare 0.3.2 2026-10-21 00:41:53 +03:00
amosblomqvist 01e7b17fcd Merge persistent practice nudges for 0.3.2 2026-10-21 00:30:43 +03:00
amosblomqvist c4c15b8fd2 fix: keep practice problems active through failed attempts 2026-10-21 00:30:43 +03:00
amosblomqvist 7f95753cf2 fix: share question math repair with practice displays 2026-10-21 00:00:20 +03:00
amosblomqvist 0ebb88ec08 release: prepare 0.3.1 2026-10-20 16:40:43 +03:00
amosblomqvist 94b63c6ba7 fix: own demo browser lifetime through cross-platform IPC 2026-10-20 15:56:19 +03:00
amosblomqvist 3d70e0da86 fix: announce demo browser only after CDP is ready 2026-10-20 15:15:28 +03:00
amosblomqvist b39d5cd6ab fix: include packaged demo runtime and verify browser payload 2026-10-20 14:51:40 +03:00
154 changed files with 10085 additions and 2853 deletions
+2
View File
@@ -63,5 +63,7 @@ jobs:
NODE_ENV: test
ELECTRON_RUN_AS_NODE: "1"
- run: npm run build
# Check the fresh trace before any static copying/curation.
- run: node scripts/smoke-provider-runtime.mjs
- run: node scripts/build-server.mjs
- run: node scripts/smoke-server.mjs --electron
+49
View File
@@ -0,0 +1,49 @@
name: Notify website of public desktop release
on:
release:
types: [published]
# Use only the separately scoped website token; no app token or checkout is needed.
permissions: {}
jobs:
notify-website:
if: >-
github.repository == 'amosblomqvist/learnharness' &&
!github.event.release.draft && !github.event.release.prerelease
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Queue the website update for the published tag
env:
WEBSITE_DISPATCH_TOKEN: ${{ secrets.WEBSITE_DISPATCH_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
shell: bash
run: |
set -euo pipefail
if [[ -z "$WEBSITE_DISPATCH_TOKEN" ]]; then
echo '::error::Missing WEBSITE_DISPATCH_TOKEN.'
exit 1
fi
if [[ -z "$RELEASE_TAG" ]]; then
echo '::error::The published release has no tag.'
exit 1
fi
payload=$(jq -cn --arg tag "$RELEASE_TAG" \
'{event_type:"desktop-release-published",client_payload:{tag:$tag}}')
status=$(curl --fail-with-body --silent --show-error \
--connect-timeout 15 --max-time 60 \
--request POST \
--header 'Accept: application/vnd.github+json' \
--header "Authorization: Bearer $WEBSITE_DISPATCH_TOKEN" \
--header 'X-GitHub-Api-Version: 2022-11-28' \
--header 'Content-Type: application/json' \
--data "$payload" \
--output /dev/null --write-out '%{http_code}' \
https://api.github.com/repos/amosblomqvist/learnharness-web/dispatches)
if [[ "$status" != '204' ]]; then
printf '::error::Expected dispatch HTTP 204, received %s.\n' "$status"
exit 1
fi
echo 'Website update queued. This does not confirm website deployment.'
+35
View File
@@ -129,6 +129,8 @@ jobs:
NODE_ENV: test
ELECTRON_RUN_AS_NODE: "1"
- run: npm run build
# Check the fresh trace before any static copying/curation.
- run: node scripts/smoke-provider-runtime.mjs
- run: node scripts/build-server.mjs
- run: node scripts/smoke-server.mjs --electron
- name: Validate macOS release secrets
@@ -169,6 +171,39 @@ jobs:
- name: Windows/Linux rehearsal (no publish)
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
run: npx electron-builder ${{ matrix.builder_args }} --publish never
- name: Initialize packaged providers offline (host and packaged Electron Node)
run: |
case "$RUNNER_OS" in
macOS)
app="dist/mac-arm64/LearnHarness.app/Contents"
payload="$app/Resources/server/standalone"
runtime="$app/MacOS/LearnHarness"
;;
Windows)
payload="dist/win-unpacked/resources/server/standalone"
runtime="dist/win-unpacked/LearnHarness.exe"
;;
Linux)
payload="dist/linux-unpacked/resources/server/standalone"
runtime="dist/linux-unpacked/learnharness"
;;
esac
node scripts/smoke-provider-runtime.mjs --standalone "$payload"
node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime"
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
run: |
case "$RUNNER_OS" in
macOS)
app="dist/mac-arm64/LearnHarness.app/Contents"
node scripts/smoke-demo-runtime.mjs --standalone "$app/Resources/server/standalone" --runtime "$app/MacOS/LearnHarness" --browser
;;
Windows)
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
;;
Linux)
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
;;
esac
- name: Save installers and update metadata
uses: actions/upload-artifact@v7
with:
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* GET /api/activity — the dock's one poll: every running background
* process, every view (chat, home, everywhere). Composition only — the
* feed lives in server/observability.ts; cost folds client-side via
* core/prices.ts. See that module's header for why /api/jobs stays
* feed lives in server/observability.ts; costs are projected server-side
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
* chat-scoped and untouched.
*/
import { activityFeed } from "@/src/server";
+2 -1
View File
@@ -1,4 +1,4 @@
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
return Response.json({
...chat,
messages: chat.messages,
pricing: sessionCost(chat),
turnInFlight: isTurnRunning(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
+1 -1
View File
@@ -4,7 +4,7 @@
* three usage channels (chat extraction runs, notes-source runs,
* cluster-naming runs). Thin transport over src/server's graphCosts();
* the fold itself is pure core (core/cost-summary.ts). Dollars are
* inferred from PRICES at read time — never stored, so a price sync
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
* re-prices the whole history for free.
*
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
+16 -6
View File
@@ -13,7 +13,7 @@
* healthy. The dock's chips + graph refetch track progress live through
* /api/graph/status, as with session runs.
*/
import { extraction } from "@/src/server";
import { extraction, NotesImportError } from "@/src/server";
import {
ModelsNotConfiguredError,
PROVIDER_KEY_MISSING_CODE,
@@ -24,6 +24,8 @@ import {
export const dynamic = "force-dynamic";
interface ImportResult {
requestIndex: number;
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
filePath: string;
ok: boolean;
stats?: EmissionStats;
@@ -46,20 +48,22 @@ export async function POST(request: Request) {
}
const results: ImportResult[] = [];
const queue = [...files];
const queue = files.map((file, requestIndex) => ({ file, requestIndex }));
const workers = Array.from({ length: Math.min(3, files.length) }, async () => {
for (;;) {
const file = queue.shift();
if (!file) return;
const next = queue.shift();
if (!next) return;
const { file, requestIndex } = next;
try {
const text = await file.text();
const stats = await extraction.importNotes({ filePath: file.name, text });
results.push({ filePath: file.name, ok: true, stats });
results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats });
} catch (err) {
if (err instanceof ModelsNotConfiguredError) {
results.push({
filePath: file.name,
ok: false,
requestIndex, outcome: "failed",
error: err.envelope.error,
code: "models_not_configured",
missing: err.envelope.missing,
@@ -70,6 +74,7 @@ export async function POST(request: Request) {
results.push({
filePath: file.name,
ok: false,
requestIndex, outcome: "failed",
error: err.envelope.error,
code: PROVIDER_KEY_MISSING_CODE,
provider: err.envelope.provider,
@@ -77,7 +82,12 @@ export async function POST(request: Request) {
});
continue;
}
results.push({ filePath: file.name, ok: false, error: err instanceof Error ? err.message : String(err) });
results.push({
filePath: file.name, requestIndex, ok: false,
outcome: err instanceof NotesImportError ? err.outcome : "failed",
...(err instanceof NotesImportError ? { stats: err.partialStats } : {}),
error: err instanceof Error ? err.message : String(err),
});
}
}
});
+3 -2
View File
@@ -1,4 +1,4 @@
import { modelCatalog } from "@/src/server";
import { modelCatalog, refreshConnectionCatalog } from "@/src/server";
/**
* The model picker's data (model-control wave): pi-ai's configured-provider
@@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server";
* catalog: no network, stable within a pi-ai version.
*/
export async function GET() {
return Response.json(modelCatalog());
await refreshConnectionCatalog();
return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } });
}
+10 -8
View File
@@ -4,8 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format";
import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core";
import { formatTokens, planCostLabel } from "@/components/usage-format";
import { useServerEvent, useServerResync } from "@/components/server-events";
/**
@@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
* oldest-first + extraction runs), fetched when truth moves (events
* channel, S5) and folded client-side with core/prices.ts.
* Cost is INFERRED (jobCost); the context line is the LAST model call's
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
* input+output against the model's limit (JobUsage.lastStep — pending
* contract) — never a cumulative sum, which would be the wrong number.
* Absent data renders "—", never zero (the doctrine).
@@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
}
export interface FeedJob extends JobRecord {
/** Read-time server estimate; absent on older transport payloads. */
costUsd?: number | null;
title: string;
usage?: FeedUsage;
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
@@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
// blob shape's call — here the line simply hides).
const limit = job.contextLimit;
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
// Same metric as the session meter (prices.ts stepFootprint — the
// Same metric as the session meter (four normalized buckets — the
// context-meter audit, owner 2026-09-23): all four directions.
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
// alone reads wildly low on cache-heavy jobs.
@@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
title: job.title,
chatId: job.chatId,
startedAt: Date.parse(job.createdAt) || 0,
cost: jobCost(job),
cost: job.costUsd ?? null,
context,
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
@@ -348,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
Background agents
{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{totalCost === null ? "—" : formatUsd(totalCost)}
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
</span>
)}
</div>
@@ -423,7 +425,7 @@ function ActivityBlobRow({
)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
<span data-testid="activity-blob-cost">{blob.cost === null ? "—" : formatUsd(blob.cost)}</span>
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
</span>
</button>
@@ -462,7 +464,7 @@ function ActivityBlobRow({
<div className="flex justify-between gap-2">
<span>Cost</span>
<span className="tabular-nums text-card-foreground">
{blob.cost === null ? "—" : formatUsd(blob.cost)}
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
</span>
</div>
<div className="flex justify-between gap-2">
+17 -6
View File
@@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock";
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
import { TopBar } from "@/components/top-bar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
import {
@@ -236,7 +236,7 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
@@ -717,8 +717,9 @@ export function Chat({
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
// The continuation seam (contracts.ts protocol §4): a job result
@@ -845,12 +846,13 @@ export function Chat({
const planTree = useMemo(() => latestTree(messages), [messages]);
// ── Session usage (wave-4 stats bar) ──
// The money channel: the record's turns log, folded client-side by
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
// The money channel: the record's turns log and read-time server Pi
// pricing projection. Seeded once on mount (the page doesn't carry turns),
// refreshed when the local stream settles (each turn appends to the log
// server-side), and kept fresh by syncFromServer whenever it already
// fetches the record. No turns yet → the bar renders "—" everywhere.
const [turns, setTurns] = useState<TurnUsage[]>([]);
const [pricing, setPricing] = useState<SessionPricing | undefined>();
// The per-chat model control state (model-picker writes via PUT; the
// record is the source of truth, lifted here so the stats bar re-renders).
// The dock's reserved tail height (DockOverlay reports it; the spacer at
@@ -864,8 +866,9 @@ export function Chat({
try {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { turns?: TurnUsage[] };
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
if (Array.isArray(record.turns)) setTurns(record.turns);
setPricing(record.pricing);
} catch {
// transient blip: stats stay on the last known figures
}
@@ -1604,6 +1607,8 @@ export function Chat({
onReadingThinking={applyReadingThinking}
phase={phases.at(-1) ?? "probe"}
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
onReserve={setDockReserve}
dictationSlot={<DictationBar dictation={dictation} />}
dictationCancel={dictation.cancel}
@@ -1824,6 +1829,8 @@ function DockOverlay({
onReadingThinking,
phase,
turns,
contextTokens,
pricing,
composer,
onSubmitAnswer,
chatId,
@@ -1855,6 +1862,8 @@ function DockOverlay({
phase: Phase3;
/** The session's usage log — the stats bar beneath the composer folds it. */
turns: TurnUsage[];
contextTokens: number | null;
pricing?: SessionPricing;
composer: React.ReactNode;
onSubmitAnswer: (output: QuestionAnswerData) => void;
/** The model control's pass-through (the stats bar hosts it). */
@@ -2327,6 +2336,8 @@ function DockOverlay({
morphing shell so it never joins a height animation. */}
<StatsBar
turns={turns}
contextTokens={contextTokens}
pricing={pricing}
dictationSlot={dictationSlot}
chatId={chatId}
/>
+28 -25
View File
@@ -64,9 +64,11 @@
*/
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import Link from "next/link";
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
import { planCostLabel } from "@/components/usage-format";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock";
@@ -293,8 +295,18 @@ function timeAgo(startedAt: number): string {
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
* scale); headline totals use 2. Null (unpriced model) renders "—". */
function fmtRunCost(dollars: number | null): string {
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string {
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`);
}
function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []);
}
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
const stages = [costs.stages.extraction, costs.stages.audit];
const providers = [...new Set(stages.flatMap(graphProviders))];
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
const dollars = displayCostTotals(costs).totalDollars;
return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`);
}
/**
@@ -348,7 +360,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
${displayCostTotals(costs).totalDollars.toFixed(2)}
{graphUsageLabel(costs)}
</button>
</Hint>
{open && (
@@ -377,7 +389,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
];
// The displayed figures come from displayCostTotals (naming excluded —
// the pill and this panel must agree).
const { totalDollars, unpricedRuns } = displayCostTotals(costs);
const { unpricedRuns } = displayCostTotals(costs);
return (
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
<div className="mb-2 flex items-baseline justify-between">
@@ -385,7 +397,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
Graph pipeline costs
</span>
<span className="text-foreground tabular-nums">
${totalDollars.toFixed(2)}
{graphUsageLabel(costs)}
{unpricedRuns > 0 && (
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
)}
@@ -396,8 +408,8 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right tabular-nums">
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage))}
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
)}
<span className="text-muted-foreground">
@@ -421,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
</span>
)}
</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
</div>
))}
</div>
@@ -1088,29 +1100,19 @@ export function GraphView({
// /api/graph/import; the runs surface in the status chips below, and the
// graph refetches when they finish (the runs-empty transition).
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
// POST awaits the WHOLE pipeline server-side, so the run registers long
// before the response — nudge at click and again at +600ms to catch the
// start; from its first setPhase the run announces itself on the events
// channel (S5), and the dock rides those announcements thereafter.
const refreshActivity = useActivity().refresh;
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setImporting(true);
refreshActivity();
setTimeout(() => refreshActivity(), 600);
const form = new FormData();
for (const file of Array.from(files)) form.append("files", file);
fetch("/api/graph/import", { method: "POST", body: form })
.then(async (res) => {
const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] };
const failed = body.results.filter((r) => !r.ok);
if (failed.length > 0) console.error("notes import failed for:", failed);
})
.catch((err) => console.error("import failed:", err))
.finally(() => setImporting(false));
};
const notesImport = useNotesImport({
onStart: () => {
refreshActivity();
setTimeout(() => refreshActivity(), 600);
},
});
const { importing, onImportFiles } = notesImport;
// Background pipeline status: read /api/graph/status; whenever a run
// LEAVES the feed (finished / aborted / failed), refetch the graph.
@@ -1614,6 +1616,7 @@ export function GraphView({
/>
</div>
<NotesImportErrors errors={notesImport.errors} />
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
just a number, hover-highlighted, dropdown breakdown — then
the running-pipeline pills under it. Lives BELOW the actions
+5 -20
View File
@@ -22,6 +22,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ListTreeIcon, UploadIcon } from "lucide-react";
import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { GraphCostControl } from "@/components/graph-view.tsx";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import { Hint } from "@/components/ui/hint";
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
@@ -96,7 +97,6 @@ export function MinimalHome({
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [chainsOpen, setChainsOpen] = useState(false);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const readState = useCallback(() => {
@@ -123,25 +123,9 @@ export function MinimalHome({
if (chainSelectOpen) setChainsOpen(true);
}, [chainSelectOpen]);
// Notes import — the same front door as the graph home: POST awaits
// the whole pipeline server-side, then one refetch lands the numbers.
const onImportFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setImporting(true);
const form = new FormData();
for (const file of Array.from(files)) form.append("files", file);
fetch("/api/graph/import", { method: "POST", body: form })
.then(async (res) => {
const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] };
const failed = body.results.filter((r) => !r.ok);
if (failed.length > 0) console.error("notes import failed for:", failed);
})
.catch((err) => console.error("import failed:", err))
.finally(() => {
setImporting(false);
readState();
});
};
// Refetch even after incomplete imports: earlier writes and spend remain.
const notesImport = useNotesImport({ onFinish: readState });
const { importing, onImportFiles } = notesImport;
const teachSessions = chats.filter((c) => c.kind === "teach").length;
const practiceSessions = chats.filter((c) => c.kind === "practice").length;
@@ -210,6 +194,7 @@ export function MinimalHome({
</Hint>
</div>
</div>
<NotesImportErrors errors={notesImport.errors} />
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
</div>
+33 -41
View File
@@ -8,7 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
/** GET /api/models is cached per page load for instant menu opens, but
@@ -17,51 +17,40 @@ import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/t
* every model-menu open (stale-while-revalidate: the cached list shows
* instantly, the fresh one swaps in while the menu is open). */
let catalogCache: ModelCatalogResponse | undefined;
let catalogInflight: Promise<ModelCatalogResponse> | undefined;
function fetchCatalog(force = false): Promise<ModelCatalogResponse> {
if (force) {
catalogCache = undefined;
catalogInflight = undefined;
}
catalogInflight ??= fetch("/api/models")
.then((r) => {
if (!r.ok) throw new Error(`catalog request failed: ${r.status}`);
return r.json() as Promise<ModelCatalogResponse>;
})
.then((c) => {
catalogCache = c;
return c;
})
.catch((err) => {
catalogInflight = undefined; // retry on next mount/open
throw err;
});
return catalogInflight;
let catalogInflight: Promise<void> | undefined;
let catalogGeneration = 0;
let stopCatalogSignals: (() => void) | undefined;
const catalogReaders = new Set<() => void>();
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
function fetchCatalog(revalidate = false): void {
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
if (catalogInflight) return;
const generation = catalogGeneration;
catalogInflight = fetch("/api/models")
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
}
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
const [catalog, setCatalog] = useState(catalogCache);
useEffect(() => {
if (catalogCache) return;
let alive = true;
fetchCatalog()
.then((c) => alive && setCatalog(c))
.catch(() => {
/* the picker stays closed on failure; the buttons still
display the strong slot from the record fetch */
});
return () => {
alive = false;
};
}, []);
const refresh = useCallback(() => {
fetchCatalog(true)
.then(setCatalog)
.catch(() => {
/* refresh is best-effort: the cached list stays on failure */
const first = catalogReaders.size === 0;
const read = () => setCatalog(catalogCache);
catalogReaders.add(read);
if (first) {
stopCatalogSignals = onSettingsSaved((section) => {
if (section === "keys") {
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
publishCatalog(); fetchCatalog();
} else if (section === "models") fetchCatalog(true);
});
fetchCatalog(true);
} else fetchCatalog();
read();
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
}, []);
const refresh = useCallback(() => fetchCatalog(true), []);
return { catalog, refresh };
}
@@ -92,7 +81,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
* changing the new-session default. Unpicked reads "—" (the
* no-factory-fallback language: the UI never presents a default model).
*/
export function ModelControl({ chatId }: { chatId?: string }) {
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
@@ -147,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
: strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
useEffect(() => {
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
}, [effectiveProvider, effectiveId, onModelChange]);
const storedLevel = override?.thinkingLevel;
const entry = catalog?.models.find(
(m) => m.id === effectiveId && m.provider === effectiveProvider,
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useRef, useState } from "react";
// Transport-only types: never import the server's extraction/agent module here.
export interface NotesImportResult {
requestIndex: number;
filePath: string;
ok: boolean;
outcome: "completed" | "failed" | "partial" | "aborted" | "busy";
stats?: { nodes: number; chains?: number; droppedChains?: number };
error?: string;
code?: string;
missing?: string[];
provider?: string;
slots?: string[];
}
/** Validate the entire response before applying any row: a malformed index
* must never attribute another file's success to this file (same names included). */
export function parseNotesImportResults(body: unknown, files: readonly File[]): NotesImportResult[] {
const invalid = () => new Error("Invalid import response. Please retry the file.");
if (!body || typeof body !== "object" || !("results" in body) || !Array.isArray(body.results) || body.results.length !== files.length) throw invalid();
const seen = new Set<number>();
for (const raw of body.results) {
if (!raw || typeof raw !== "object") throw invalid();
const r = raw as NotesImportResult;
if (!Number.isInteger(r.requestIndex) || r.requestIndex < 0 || r.requestIndex >= files.length || seen.has(r.requestIndex) || r.filePath !== files[r.requestIndex]!.name) throw invalid();
seen.add(r.requestIndex);
if (!["completed", "failed", "partial", "aborted", "busy"].includes(r.outcome) || typeof r.ok !== "boolean" || r.ok !== (r.outcome === "completed")) throw invalid();
if (r.stats !== undefined && (!r.stats || typeof r.stats !== "object" || !Number.isFinite(r.stats.nodes) || r.stats.nodes < 0 || (r.stats.chains !== undefined && (!Number.isFinite(r.stats.chains) || r.stats.chains < 0)))) throw invalid();
if (r.ok && !r.stats) throw invalid();
if (!r.ok && typeof r.error !== "string") throw invalid();
for (const field of ["error", "code", "provider"] as const) if (r[field] !== undefined && typeof r[field] !== "string") throw invalid();
for (const field of ["missing", "slots"] as const) if (r[field] !== undefined && (!Array.isArray(r[field]) || !r[field]!.every((v) => typeof v === "string"))) throw invalid();
}
return body.results as NotesImportResult[];
}
export async function postNotesImport(files: readonly File[]): Promise<NotesImportResult[]> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch("/api/graph/import", { method: "POST", body: form });
let body: unknown;
try { body = await response.json(); }
catch { throw new Error("Invalid import response. Please retry the file."); }
if (!response.ok) {
const detail = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? `: ${body.error}` : "";
throw new Error(`Import request failed (HTTP ${response.status})${detail}. Please retry the file.`);
}
return parseNotesImportResults(body, files);
}
/** Only error text survives a request; selected Files stay request-local. */
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
const [errors, setErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const pending = useRef(false);
const submit = async (files: File[]) => {
if (pending.current || files.length === 0) return;
pending.current = true;
setImporting(true);
setErrors([]);
try {
callbacks.onStart?.();
const results = await postNotesImport(files);
setErrors(results
.filter((result) => !result.ok)
.sort((a, b) => a.requestIndex - b.requestIndex)
.map((r) => `${r.filePath}: ${r.error}${r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : ""}`));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrors(files.map((file) => `${file.name}: ${message}`));
} finally {
pending.current = false;
setImporting(false);
callbacks.onFinish?.();
}
};
return {
errors, importing,
onImportFiles: (files: FileList | null) => { if (files) void submit(Array.from(files)); },
};
}
export function NotesImportErrors({ errors }: { errors: readonly string[] }) {
return <>{errors.map((error, i) => (
<p key={i} role="alert" className="max-w-full whitespace-pre-wrap break-words text-xs text-red-400">{error}</p>
))}</>;
}
+4 -4
View File
@@ -2,7 +2,7 @@
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import type { ArtifactPartProps } from "@/src/core";
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
/**
* The practice transcript cards (practice sessions, owner design
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{statement}</MessageResponse>
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
</div>
</div>
);
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
(owner round 3): the same quiet 50% register as quiz explanations. */}
{verdict.length > 0 && (
<div className="text-sm text-foreground/50">
<MessageResponse>{verdict}</MessageResponse>
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
</div>
)}
{/* Then the divider, then the labeled model solution (Slice G: the
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{reveal}</MessageResponse>
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
</div>
</div>
</>
+2 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { cn } from "@/lib/utils";
import { repairMathForDisplay } from "@/src/core";
/**
* The practice dock (practice sessions, owner design 2026-10-04): an
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{input.statement}</MessageResponse>
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
</div>
</div>
+50 -19
View File
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
measuredContextTokens,
newAnswerResult,
newNextMessage,
newProblemDismissedMarker,
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
/** Completed conversation usage; null after compaction or before first usage. */
contextTokens: number | null;
status: SessionStatus;
error: { message: string } | null;
/** Steers sent but not yet drained (composer steering, 2026-10-03):
@@ -220,10 +223,14 @@ export class SessionModel {
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
private incompleteUsageIds = new Set<string>();
/** The in-flight assistant message's client id (see applyWireEvent). */
private streamingMessageId: string | null = null;
private statusValue: SessionStatus = "ready";
/** Request/loop failures are transient; provider failures derive from messages. */
private errorValue: { message: string } | null = null;
/** A completed failure from THIS consume, not the previous attempt's record. */
private liveFailureId: string | null = null;
// The single live consume (POST response or reattach stream): a newer
// consume cancels the older — one reader at a time.
private consumeAbort: AbortController | null = null;
@@ -254,11 +261,22 @@ export class SessionModel {
this.statusValue === "submitted" || this.statusValue === "streaming"
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
: undefined;
const messages = [...this.base, ...this.tail];
const latest = messages.at(-1);
// One rule for live and restored failures: only the final completed
// assistant can own the banner. While trying again, hide the old
// failure from the first Thinking frame, but show a new failure at end.
const providerError =
latest?.role === "assistant" && latest.stopReason === "error" &&
!this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId)
? { message: latest.errorMessage ?? "An error occurred." }
: null;
return {
messages: projectView([...this.base, ...this.tail], live),
nativeMessages: [...this.base, ...this.tail],
messages: projectView(messages, live),
nativeMessages: messages,
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
status: this.statusValue,
error: this.errorValue,
error: this.errorValue ?? providerError,
steerQueue: this.steerQueueValue,
};
}
@@ -274,6 +292,7 @@ export class SessionModel {
}
private setStatus(status: SessionStatus): void {
if (status === "submitted") this.liveFailureId = null;
this.statusValue = status;
this.readyRef = status === "ready";
this.onChange?.();
@@ -364,6 +383,15 @@ export class SessionModel {
// the wire) still wins.
const id = message.id ?? this.streamingMessageId ?? localId();
const withId = { ...message, id } as LhMessage;
if (agent.type === "message_end") {
this.incompleteUsageIds.delete(id);
if (message.role === "assistant" && message.stopReason === "error") {
this.liveFailureId = id;
// A newer provider failure replaces an earlier connection/loop
// error, just as the live message_end did before restoration.
this.errorValue = null;
}
} else this.incompleteUsageIds.add(id);
if (agent.type === "message_start") {
this.streamingMessageId = id;
this.streamingIds.add(id);
@@ -378,15 +406,6 @@ export class SessionModel {
}
if (agent.type === "message_end") {
this.streamingMessageId = null; // slot done — next start mints fresh
// (fallthrough: returns true below — the message landed)
const stop = (message as { stopReason?: string }).stopReason;
if (stop === "error") {
// Provider error, verbatim (owner ruling) — the record
// persists the same message; no masking anywhere.
this.setError(
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
);
}
}
return true; // a message event is view work, even empty content
}
@@ -426,17 +445,18 @@ export class SessionModel {
/** Consume one SSE body to its [DONE] terminal. The turn-settled
* terminal (stream-route only) fires the settle hook and ends. */
async consumeStream(res: Response): Promise<void> {
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
if (!res.body) throw new Error("The turn stream body is empty.");
const reader = res.body.getReader();
let sawEvent = false;
try {
for await (const event of sseEvents(reader)) {
if (this.consumeAbort !== controller) return;
if (event.type === "turn-settled") {
// The raced-settle terminal: no turn to attach to — the
// record is current; the settle hook refetches.
await reader.cancel().catch(() => {});
this.onReattachSettled?.();
if (this.consumeAbort === controller) this.onReattachSettled?.();
return;
}
const acted = this.applyWireEvent(event);
@@ -446,9 +466,13 @@ export class SessionModel {
}
}
} finally {
this.streamingIds = new Set();
this.executions = new Map();
if (this.statusValue !== "error") this.setStatus("ready");
// A cancelled consume may finish after its replacement has entered
// Thinking. Only the current owner can settle shared state.
if (this.consumeAbort === controller) {
this.streamingIds = new Set();
this.executions = new Map();
if (this.statusValue !== "error") this.setStatus("ready");
}
}
}
@@ -487,6 +511,7 @@ export class SessionModel {
// every path that actually replays onto a stale tail).
if (!refetchRecordFirst) {
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -497,11 +522,14 @@ export class SessionModel {
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
signal: controller.signal,
});
if (this.consumeAbort !== controller) return;
if (recordRes.ok) {
const record = (await recordRes.json()) as { messages?: LhMessage[] };
if (this.consumeAbort !== controller) return;
if (Array.isArray(record.messages)) {
this.mutate(() => {
this.base = record.messages!;
this.incompleteUsageIds.clear();
this.tail = [];
});
}
@@ -509,6 +537,7 @@ export class SessionModel {
// The record did not land — restore the clean-slate guard
// before the replay (no atomic swap is coming).
this.mutate(() => {
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null;
});
@@ -517,10 +546,11 @@ export class SessionModel {
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
signal: controller.signal,
});
if (this.consumeAbort !== controller) return;
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
await this.consumeStream(res);
await this.consumeStream(res, controller);
} catch (err) {
if ((err as Error).name === "AbortError") return; // a newer consume won
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
this.setError((err as Error).message);
this.setStatus("error");
}
@@ -714,6 +744,7 @@ export class SessionModel {
setMessages(record: LhMessage[]): void {
this.mutate(() => {
this.base = record;
this.incompleteUsageIds.clear();
this.tail = [];
this.streamingMessageId = null; // the tail (and its slot) is gone
});
+1
View File
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
contextTokens: state.contextTokens,
status: state.status,
error: state.error,
steerQueue: state.steerQueue,
+27 -39
View File
@@ -72,6 +72,8 @@ import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
/** One slot's display identity — the tier's roster as the row hint rides
* with the resolved default (owner: the hint ALWAYS names the default). */
@@ -131,19 +133,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean)
return { ...agent, graphTools: { enabled: on } };
}
/** The research tool runs on the search port — the teacher registry DROPS
* it when no engine is live (chat-handler's searchAvailable, the same
* /api/search signal the section fetches). Pure — exported for tests.
* Teacher-stream flag 2026-09-22: the settings row grays on the SAME
* signal, so the row and the actual registry availability can never
* disagree. `search === null` (loading/failed) grays NOTHING. */
export function researchToolUnavailable(
toolId: string,
search: { available: boolean } | null,
): boolean {
return toolId === "research" && search !== null && !search.available;
}
/** Compose: one slot's model/thinking override. Empty model = absent
* override (Default — falls to the factory pin); empty thinking = the
* tier keeps its resolved thinking budget. Per-field independence, same
@@ -357,18 +346,23 @@ export function AgentSection({
// fetch each per section mount. The models read ALSO re-runs on the
// cross-surface signal (fix wave: another surface's write re-reads
// into the slot rows live).
const readModels = useCallback(() => {
const modelsRequest = useRef(0);
const modelsMounted = useRef(false);
const readModels = useCallback((connectionChanged = false) => {
const request = ++modelsRequest.current;
if (connectionChanged) setCatalog(null);
fetch("/api/models")
.then((r) => r.json())
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((body: ModelsBody) => {
if (!modelsMounted.current || request !== modelsRequest.current) return;
setCatalog(body.models);
setSlots(body.slots);
})
.catch(() => setCatalog(null));
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
}, []);
useSettingsSaved((section) => {
if (section === "models") readModels();
if (section === "models" || section === "keys") readModels(section === "keys");
// THE state-echo root cause: the prompt switches write through the
// agent section, but this view (the toggles' truth) never re-read —
// the settings-saved law (every reader re-reads) was missing this
@@ -377,6 +371,7 @@ export function AgentSection({
});
useEffect(() => {
modelsMounted.current = true;
readModels();
fetch("/api/settings/prompts")
.then((r) => r.json())
@@ -393,6 +388,7 @@ export function AgentSection({
setEngineKeys(present);
})
.catch(() => {});
return () => { modelsMounted.current = false; modelsRequest.current++; };
}, []);
// The reason line re-reads /api/search AFTER an engine write lands —
@@ -653,6 +649,7 @@ export function AgentSection({
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
@@ -792,28 +789,19 @@ export function AgentSection({
</SettingsRow>
);
})()}
{TOGGLE_TOOLS.map((id) => {
// Research needs a live search engine; the row + toggle gray
// on the same signal the registry drops the tool on (see
// researchToolUnavailable). The reason rides as the hint.
const searchDown = researchToolUnavailable(id, search);
return (
<SettingsRow
key={id}
label={`Tool · ${id}`}
hint={searchDown ? search?.reason : undefined}
testid={`tool-row-${id}`}
dimmed={searchDown}
>
<Toggle
checked={agent?.tools?.[id] ?? true}
disabled={searchDown}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
);
})}
{TOGGLE_TOOLS.map((id) => (
<SettingsRow
key={id}
label={`Tool · ${id}`}
testid={`tool-row-${id}`}
>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
))}
{(() => {
const engine = agent?.search?.engine ?? null;
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
+351
View File
@@ -0,0 +1,351 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ChatGPTStatus } from "@/src/core";
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { Button } from "@/components/ui/button";
import { Toggle } from "./controls";
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." };
const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
type Account = ChatGPTStatus["accounts"][number];
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" };
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
function isStatus(value: unknown): value is ChatGPTStatus {
if (!value || typeof value !== "object") return false;
const s = value as ChatGPTStatus;
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean") && (a.canSignInAgain === undefined || typeof a.canSignInAgain === "boolean"));
}
function replyError(value: unknown): string {
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
}
const Context = createContext({
status: empty,
busy: false,
error: null as string | null,
inlineError: null as string | null,
warning: null as string | null,
connect: () => {},
manage: (_id: string) => {},
select: (_id: string) => {},
dismissWarning: () => {},
});
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
* are used; account IDs remain control values, never displayed account labels. */
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<ChatGPTStatus>(empty);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [background, setBackground] = useState(true);
const [backgroundDirty, setBackgroundDirty] = useState(false);
const [registration, setRegistration] = useState("");
const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null);
const generation = useRef(0);
const statusSequence = useRef(0);
const mounted = useRef(false);
const dialogRef = useRef<Dialog | null>(null);
const statusRef = useRef(status);
const loginAttempt = useRef<LoginAttempt | null>(null);
const receive = useRef<(value: unknown) => void>(() => {});
function showDialog(next: Dialog | null) { dialogRef.current = next; setDialog(next); }
function publishStatus(value: unknown): ChatGPTStatus | null {
if (!isStatus(value)) { setError(replyError(value)); return null; }
statusSequence.current++; statusRef.current = value; setStatus(value);
return value;
}
function owns(attempt: LoginAttempt) { return mounted.current && loginAttempt.current === attempt && generation.current === attempt.generation; }
function finish(attempt: LoginAttempt) {
if (!owns(attempt)) return;
loginAttempt.current = null; operation.current = null; setBusy(false);
}
async function complete(attempt: LoginAttempt, next: ChatGPTStatus) {
const account = next.accounts.find((a) => a.id === next.activeId && a.connected);
if (next.error || !next.available || !account || (attempt.registration && account.id !== attempt.registration)) {
finish(attempt); setError(next.error || CONTROL_ERROR); return;
}
attempt.phase = "consent"; attempt.accountId = account.id;
let replyFailure: string | null = null;
try {
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
operation.current = "change";
const request = ++statusSequence.current;
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
if (!owns(attempt)) return;
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
replyFailure = result.error;
let saved: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const value: unknown = await window.lh!.chatgpt!.status();
if (!owns(attempt)) return;
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
} else saved = publishStatus(result);
if (!saved) { finish(attempt); return; }
next = saved;
}
if (!owns(attempt)) return;
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR);
else if (dialogRef.current === attempt.dialog) showDialog(null);
} catch { if (owns(attempt)) setError(CONTROL_ERROR); }
finally { finish(attempt); }
}
function reconcile(next: ChatGPTStatus, loginReply = false) {
const attempt = loginAttempt.current;
if (!attempt || !owns(attempt)) return;
if (attempt.phase === "consent") {
if (next.pending || next.error || !next.available || next.activeId !== attempt.accountId || !next.accounts.some((a) => a.id === attempt.accountId && a.connected)) {
finish(attempt); setError(next.error || CONTROL_ERROR);
}
return;
}
if (next.pending) { attempt.pending = true; attempt.phase = "pending"; return; }
// login() normally acknowledges pending immediately; verified completion is
// a later status notification. The acknowledgement itself is not success.
if (attempt.pending || loginReply) void complete(attempt, next);
}
receive.current = (value) => {
const next = publishStatus(value);
if (next) reconcile(next);
};
useEffect(() => {
mounted.current = true;
const bridge = window.lh?.chatgpt;
const refresh = async () => {
const request = ++statusSequence.current;
try {
const next: unknown = await bridge?.status();
if (next && mounted.current && request === statusSequence.current) receive.current(next);
} catch { if (mounted.current && request === statusSequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
};
void refresh();
const off = bridge?.onChange(() => void refresh());
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
}, []);
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
function openConnect(accountId = "") {
if (operation.current) return;
setBackground(statusRef.current.allowBackgroundUse);
setBackgroundDirty(false);
setRegistration(accountId);
setError(null);
setDismissedError(status.error);
showDialog({ kind: "connect" });
}
function close() {
if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return;
const current = dialogRef.current;
if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; }
const cancel = loginAttempt.current !== null || statusRef.current.pending;
const intent = ++generation.current;
const owner = dialogRef.current;
loginAttempt.current = null;
if (!cancel) showDialog(null);
setError(null);
setDismissedError(status.error);
if (cancel) {
// Keep cancellation serialized even after login's pending IPC reply.
operation.current = "cancel"; setBusy(true);
const request = ++statusSequence.current;
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
if (!mounted.current || generation.current !== intent) return;
if (!isStatus(value)) { setError(replyError(value)); return; }
let next: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const current: unknown = await window.lh!.chatgpt!.status();
if (!mounted.current || generation.current !== intent) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
if (next && !next.pending) {
setDismissedError(value.error); // Dismiss only this cancellation's error, not a newer failure.
if (dialogRef.current === owner) showDialog(null);
} else if (next) setError(CONTROL_ERROR);
}).catch(() => { if (mounted.current && generation.current === intent) setError(CONTROL_ERROR); }).finally(() => {
if (mounted.current && generation.current === intent) { operation.current = null; setBusy(false); }
});
}
}
async function control(kind: "change" | "disconnect", action: () => Promise<ChatGPTStatus>, target?: string) {
if (operation.current) return;
const intent = ++generation.current;
const request = ++statusSequence.current;
const owner = dialogRef.current;
operation.current = kind;
setBusy(true); setError(null);
if (kind === "disconnect") setDismissedError(null);
try {
const value: unknown = await action();
if (!mounted.current || intent !== generation.current) return;
if (!isStatus(value)) { setError(replyError(value)); return; }
let next: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const current: unknown = await window.lh!.chatgpt!.status();
if (!mounted.current || intent !== generation.current) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
if (!next) return;
// Dismissed status warnings belong to an earlier action. Only a new
// disconnect outcome (or an explicit IPC error envelope above) reopens one.
const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null;
if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) {
if (dialogRef.current === owner) {
if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" });
else if (!failure) showDialog(null);
}
setError(failure);
} else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null));
} catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
}
async function login() {
const bridge = window.lh?.chatgpt;
const owner = dialogRef.current;
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
loginAttempt.current = attempt; operation.current = "login";
const request = ++statusSequence.current;
setBusy(true); setError(null); setDismissedError(null);
try {
let value: unknown = await bridge.login(registration || undefined);
if (!owns(attempt) || attempt.phase === "consent") return;
// Never publish an old acknowledgement over a newer event. If events
// coalesced past pending before the acknowledgement arrived, read the
// current backend state after it rather than losing that completion.
if (request !== statusSequence.current && isStatus(value)) {
if (value.error) { setError(value.error); finish(attempt); return; }
attempt.pending = true; attempt.phase = "pending";
const refresh = ++statusSequence.current;
value = await bridge.status();
if (!owns(attempt) || loginAttempt.current?.phase === "consent" || refresh !== statusSequence.current) return;
}
const next = publishStatus(value);
if (!next) { finish(attempt); return; }
reconcile(next, true);
} catch { if (owns(attempt)) { setError(CONTROL_ERROR); finish(attempt); } }
// No finally: pending OAuth outlives this IPC promise. Only its status
// completion, failure, or explicit cancellation releases the attempt.
}
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
const context = {
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
connect: openConnect,
manage: (id: string) => {
const account = statusRef.current.accounts.find((a) => a.id === id);
if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); }
},
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
};
return <Context.Provider value={context}>
{children}
{dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
{dialog.kind === "warning" ? <>
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>Disconnect account</Button>
</div>
</div> : <>
{dialog.kind === "connect" ? <>
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
<option value="">Add another account or workspace</option>
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
</div>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
</> : null}
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
else if (bridge) void control("change", () => bridge.consent(enabled));
}} />
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
</div>
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.</p>}
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
{dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
<div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>}
</div>
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>Add accounts</Button>
</div>}
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>Disconnect</Button>
</div>}>
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>Sign in again</Button>}
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
</ConnectionFooter>
</>}
</AccountDialog>}
</Context.Provider>;
}
function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) {
return <Button type="button" variant="outline" size="sm" data-testid={testid} onClick={onClick} disabled={disabled} className="cursor-pointer disabled:cursor-not-allowed">
{/* Official unmodified mark; existing secondary button hover/focus treatment. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
Continue with ChatGPT
</Button>;
}
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
}
/** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{active && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>}
</ConnectionRow>
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
</div>;
}
/** Contextual consent discovery belongs beside background model choices only. */
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
const ui = useContext(Context);
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
}
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span>
{children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) {
return <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid={testid}>
{leading}
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">{children}</div>
</div>;
}
export function ConnectionDialog({ children, onClose, busy, label, testid, backdropTestid }: { children: React.ReactNode; onClose: () => void; busy: boolean; label: string; testid: string; backdropTestid: string }) {
const ref = useRef<HTMLDivElement>(null), closeRef = useRef(onClose);
closeRef.current = onClose;
const backdropDismiss = useBackdropDismiss(() => { if (!busy) onClose(); });
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const focusable = () => [...(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])];
focusable()[0]?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); }
else if (event.key === "Tab") {
const items = focusable(), first = items[0], last = items.at(-1);
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
}
};
document.addEventListener("keydown", key, true);
return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
}, []);
return createPortal(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid={backdropTestid} {...backdropDismiss}>
<div ref={ref} role="dialog" aria-modal="true" aria-label={label} aria-busy={busy} data-testid={testid} onClick={(event) => event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}</div>
</div>, document.body);
}
+17 -20
View File
@@ -15,8 +15,8 @@
* a paste box on a key the UI cannot make live would mislead.
* - OpenRouter is NOT special: one provider among 33, a row only when
* stored, otherwise it lives in the Add dropdown like everyone else.
* - Tools & services (Deepgram, the Google pair) keep their own group,
* same look, same interaction.
* - Providers groups API keys, subscriptions, dictation and search.
* Unknown variables stay masked/read-only at the end of Model API keys.
*
* API: GET /api/keys (masked statuses + the providers array), PUT
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
@@ -26,6 +26,7 @@
import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
@@ -412,7 +413,7 @@ export function KeysSection({
return (
<>
<SettingsGroup label="Model providers" testid="group-provider-keys">
<SettingsGroup label="Model API keys" testid="group-provider-keys">
{providerRows.map((status) => (
<KeyTableRow
key={status.id}
@@ -446,10 +447,20 @@ export function KeysSection({
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div>
)}
{unknownRows.length > 0 && <div data-testid="group-unknown-keys">
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</div>)}
</div>}
</SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys">
{toolRows.map((status) => (
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
<SubscriptionConnections />
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
<KeyTableRow
key={status.id}
label={status.info?.label ?? status.id}
@@ -467,22 +478,8 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`}
/>
))}
</SettingsGroup>
</SettingsGroup>)}
{unknownRows.length > 0 && (
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
{unknownRows.map((status) => (
<div
key={status.id}
data-testid={`unknown-${status.id}`}
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
>
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</div>
))}
</SettingsGroup>
)}
</>
);
}
+4 -4
View File
@@ -21,7 +21,7 @@
import { useEffect, useRef, useState } from "react";
import type { ModelCatalogEntry } from "@/src/core";
import { fuzzyScore } from "@/src/core";
import { fuzzyScore, subscriptionLabel } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
@@ -125,12 +125,12 @@ export function ModelMenuList({
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
{priceLabel(m.cost)}
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{m.provider} · {m.id}</span>
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
</span>
</button>
+10 -8
View File
@@ -49,17 +49,19 @@ import { KeysSection } from "./keys-section.tsx";
import { Hint } from "@/components/ui/hint";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx";
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx";
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
* prose is out; only the keyboard note stays (storage location is a
* real question users ask). */
const SECTIONS = [
{ id: "general", label: "General" },
{ id: "keys", label: "Keys" },
{ id: "keys", label: "Providers" },
{ id: "agent", label: "Agent" },
{ id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" },
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
{ id: "general", label: "General" },
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
@@ -83,20 +85,20 @@ export function SettingsDialogProvider({
children: React.ReactNode;
}) {
const [show, setShow] = useState(false);
const [landOn, setLandOn] = useState<SectionId>("general");
const [landOn, setLandOn] = useState<SectionId>("keys");
const [landOnRow, setLandOnRow] = useState<string | null>(null);
const open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId);
setLandOn(section ? section as SectionId : "keys");
setLandOnRow(row ?? null);
setShow(true);
}, []);
return (
<DialogContext.Provider value={{ open }}>
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
{children}
{show && (
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)}
</DialogContext.Provider>
</DialogContext.Provider></NativeSubscriptionUIProvider></ChatGPTUIProvider>
);
}
@@ -133,7 +135,7 @@ function SettingsDialog({
* (portals render nothing under renderToStaticMarkup). */
export function SettingsDialogBody({
onClose,
initialSection = "general",
initialSection = "keys",
initialRow = null,
}: {
onClose: () => void;
@@ -376,7 +378,7 @@ export function SettingsDialogBody({
</SettingsSection>
)}
{active === "keys" && (
<SettingsSection title="Keys" testid="section-keys">
<SettingsSection title="Providers" testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection>
)}
@@ -0,0 +1,138 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
import { Button } from "@/components/ui/button";
import { notifySettingsSaved } from "@/components/settings-signal";
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { ChatGPTConnection } from "./chatgpt-connection";
import { Toggle } from "./controls";
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState("");
const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState<string | null>(null);
const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses);
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog);
const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined);
const interaction = status?.interaction, prompt = interaction?.prompt;
const management = view === "manage";
const busy = operation === "control" || status?.committing === true;
async function refresh() {
const request = ++sequence.current;
try {
const values = await window.lh?.subscriptions?.status();
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
return next && (!old || next.revision >= old.revision) ? next : old ?? emptySubscriptionStatus(providerId);
});
statusesRef.current = accepted; setStatuses(accepted);
} catch { if (mounted.current && request === sequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
}
useEffect(() => {
mounted.current = true; void refresh();
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
return () => { mounted.current = false; generation.current++; off?.(); };
// The root-lifetime subscriber reads current state through refs.
}, []);
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
function open(providerId: NativeSubscriptionProviderId) {
if (operation || statusesRef.current.some((s) => s.pending)) return;
generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false);
const current = statusesRef.current.find((s) => s.providerId === providerId);
setBackground(current?.allowBackgroundUse ?? true); setView(current?.connectionId ? "manage" : "notice"); setDialog(providerId);
}
async function control(action: () => Promise<void>, close = false) {
if (busy) return;
const owner = ++generation.current; setOperation("control"); setError(null);
try { await action(); await refresh(); if (mounted.current && owner === generation.current && close) setDialog(null); }
catch { if (mounted.current && owner === generation.current) setError(CONTROL_ERROR); }
finally { if (mounted.current && owner === generation.current) setOperation(null); }
}
function close() {
if (!dialog || busy) return;
if (confirm) { setConfirm(false); setError(null); return; }
const bridge = window.lh?.subscriptions;
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
generation.current++; setDialog(null); setError(null);
}
function beginSignIn() {
if (operation || busy || !status) return;
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
}
async function login() {
const bridge = window.lh?.subscriptions;
if (!dialog || !bridge || operation || !status?.available) return;
const owner = ++generation.current; setOperation("login"); setError(null);
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
finally { if (mounted.current && generation.current === owner) setOperation(null); }
}
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
{children}
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
{confirm ? <>
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
</> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
</> : <>
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
<Toggle label="Allow background use" checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span>
</div>
{!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>}
{view === "connection" && interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
{interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>}
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>}
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>}
{prompt && <form className="space-y-2" onSubmit={(event) => { event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}>
<label className="block" htmlFor="subscription-auth-prompt">{prompt.message}</label>
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>Select…</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>Continue</Button>
</form>}
</div>}
</>}
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>}
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>}>
{!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>}
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>Sign in again</Button>
</>}
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? "Cancel" : "Close"}</Button>
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>Continue</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
</ConnectionFooter>
</ConnectionDialog>}
</Context.Provider>;
}
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
</div>;
}
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
}
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId);
if (!descriptor || !status?.connected || status.allowBackgroundUse) return null;
return <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>Enable background use</Button>;
}
+3 -22
View File
@@ -15,12 +15,10 @@
* its own save), the same re-read pattern chat.tsx uses for "models".
*/
import { useCallback, useEffect, useState } from "react";
import type { AuthKeyStatus } from "@/src/core";
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
import { useModelCatalog } from "@/components/model-picker";
import type { ModelCatalogResponse } from "@/src/core";
import { useSettingsSaved } from "@/components/settings-signal";
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
* classifyKeys (the keys section's own classification — no drift):
@@ -40,22 +38,8 @@ export function hasProviderKey(
* failed read claiming "zero keys" would be a lie the CTA flow then
* leans on). Re-reads on the keys signal. */
export function useZeroKeys(): boolean {
const [zeroKeys, setZeroKeys] = useState(false);
const read = useCallback(() => {
fetch("/api/keys")
.then((r) => r.json())
.then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => {
setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers));
})
.catch(() => {});
}, []);
useEffect(() => {
read();
}, [read]);
useSettingsSaved((section) => {
if (section === "keys") read();
});
return zeroKeys;
const { catalog } = useModelCatalog();
return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false;
}
/** The pill itself — the models CTA's chrome and placement, the keys
@@ -93,9 +77,6 @@ export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolea
* (a provider key saved or deleted) and "models" (the strong slot
* re-pinned). Live-updates with the page, never mid-send. */
export function useStrongKeyless(): boolean {
const { catalog, refresh } = useModelCatalog();
useSettingsSaved((section) => {
if (section === "keys" || section === "models") refresh();
});
const { catalog } = useModelCatalog();
return strongKeyless(catalog);
}
+12 -5
View File
@@ -44,6 +44,8 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
import { ThinkingMenuList } from "@/components/settings/model-menu";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -147,7 +149,6 @@ export function SetupKeysBody({
}) {
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
<p className="text-sm text-foreground">Add a provider key.</p>
<p className="text-xs text-muted-foreground">
Choose your AI provider and paste its API key. You’ll pick your models next.
</p>
@@ -211,6 +212,7 @@ export function SetupKeysBody({
</p>
)}
</div>
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">Subscriptions</h3><SubscriptionConnections setup /></div>
</div>
);
}
@@ -265,16 +267,19 @@ export function SetupDialog({
// moment ago (stage 1's own screen) widens the pickers immediately.
useEffect(() => {
if (!open) return;
let cancelled = false;
setBody(null);
setPicked({});
setLevels({});
setError(null);
fetch("/api/keys")
.then((r) => r.json())
.then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? []))
.catch(() => setKeyProviders([]));
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
.catch(() => { if (!cancelled) setKeyProviders([]); });
fetch("/api/models")
.then((r) => r.json())
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
.then((b: ModelsBody) => {
if (cancelled) return;
setBody(b);
setFailed(false);
// Prefill (owner, 2026-09-22): each pinned tier opens with its
@@ -292,7 +297,8 @@ export function SetupDialog({
}
setLevels(preLevels);
})
.catch(() => setFailed(true));
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [open, keysTick]);
// Esc + outside-click — layered dismissal, closable WITHOUT
@@ -472,6 +478,7 @@ export function SetupDialog({
)}
</span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
+13 -2
View File
@@ -21,6 +21,7 @@ import { fuzzyScore } from "@/src/core";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { ChatListItem } from "@/src/core";
import { kbdEventForAction } from "@/src/core";
import { notifySettingsSaved } from "@/components/settings-signal";
/**
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
@@ -47,6 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
*
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
@@ -58,6 +60,8 @@ declare global {
lh?: {
/** Read-only host OS; determines the native-control gutter. */
platform?: string;
chatgpt?: ChatGPTControlBridge;
subscriptions?: NativeSubscriptionControlBridge;
expandWindow: () => void;
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
@@ -79,6 +83,13 @@ export function useSidebar() {
}
export function SidebarProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const bridge = window.lh?.chatgpt;
if (!bridge) return;
const off = bridge.onChange(() => notifySettingsSaved("keys"));
notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription
return off;
}, []);
// Start closed so server and client first-render agree (no hydration
// mismatch); the stored value is applied after mount.
const [open, setOpen] = useState(false);
@@ -213,7 +224,7 @@ export function SidebarToggle() {
* phases only advance (all sessions end in Teaching — phase-as-signal was
* decoration, now retired app-wide), and question-pending is the dominant
* RESTING state of real sessions — a resting state has no scan value, so
* the meta line's "Waiting for your answer" text carries it. Idle is the
* the meta line's "Waiting for input" text carries it. Idle is the
* absence of a signal, by design.
*/
function SessionStatus({ busy }: { busy: boolean }) {
@@ -250,7 +261,7 @@ export function timeAgo(iso: string, now = new Date()): string {
* ever wanted. */
function sessionMeta(chat: ChatListItem): string {
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for your answer · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
}
+33 -30
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
const numerator = tokens === null ? "?" : formatTokens(tokens);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
}
/**
* The below-composer stats bar (wave 4 observability): the session's
* money/context footprint as one quiet row beneath the composer —
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
* there died with the injection; a "— · —" summary over an empty
* popover is pure noise) — the dictation strip's middle simply widens.
*
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
* — no cost math lives here. Absent data renders "—", never zero (the
* doctrine): no turns yet → every figure is "—"; turns the price table
* can't price count as unpriced, shown in the popover, never folded into
* a fake $0. The RIGHT side is the model + thinking control (components/
* model-picker.tsx): one controller for the conversation agents, backed by
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
* settings, else config's pin), while the left-side figures still fold the
* turn log's actual billed models.
* Spending arrives already priced by the server through Pi's catalog and
* calculator. Missing cost stays "—", never fake zero. Context is a separate
* completed-conversation measurement, never a money-log/summarizer fold or
* transcript estimate. Its catalog denominator follows the selected model;
* model changes preserve the numerator. Successful compaction clears it to ?.
*/
export function StatsBar({
turns,
contextTokens = null,
pricing,
dictationSlot,
chatId,
leadingSlot,
}: {
turns: TurnUsage[];
contextTokens?: number | null;
pricing?: SessionPricing;
/** The bound session (per-session models, owner 2026-09-28): present
* on the in-session bar — the model control binds THE SESSION'S own
* record; absent on the draft surfaces, where it binds the strong
@@ -49,6 +54,7 @@ export function StatsBar({
leadingSlot?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
// Same dropdown discipline as the model control (owner, df4433f treatment,
// stats round 1): close on any pointerdown outside the bar or on Escape.
const barRef = useRef<HTMLDivElement>(null);
@@ -73,22 +79,18 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// The live-limits join (context-meter amendment, owner 2026-09-23):
// contextWindow's denominator resolves ONLY from pi's live catalog —
// built client-side over the picker's cached /api/models fetch (the
// same fetch ModelControl below rides; module-level dedupe). Catalog
// pending or failed → undefined index → limit null → USAGE-ONLY (the
// honest-render precedent, never a fake window); the usage-only render
// IS the null path.
// Catalog-only denominator; the picker reports its effective selection.
// Missing/pending catalog means usage-only, never a hardcoded maximum.
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const ctx = contextWindow({ turns }, limits);
const { cost, unpriced } = sessionCost({ turns });
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const contextLabel = formatContextMeasurement(contextTokens, limit);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced;
const costLabel = pricedCount > 0 ? formatUsd(cost) : "—";
const lastModel = turns.at(-1)?.model;
const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
return (
<div
@@ -110,7 +112,7 @@ export function StatsBar({
{leadingSlot}
{/* The left side: the session-data summary — only once a session
exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && (
{(chatId || turns.length > 0) && (
<div ref={barRef} className="relative">
{/* No tooltip — the numbers describe themselves and the popover
speaks on click (owner, subtlety round 2026-09-25). */}
@@ -122,7 +124,7 @@ export function StatsBar({
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
@@ -139,13 +141,14 @@ export function StatsBar({
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Total cost</span>
<span>{subscription ? "Usage" : "Total cost"}</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
</div>
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
{unpriced > 0 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
@@ -160,7 +163,7 @@ export function StatsBar({
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
const c = pricing?.turnCosts[turn.id] ?? null;
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
@@ -168,7 +171,7 @@ export function StatsBar({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{c === null ? "—" : formatUsd(c)}</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
</span>
</li>
);
@@ -189,7 +192,7 @@ export function StatsBar({
{/* The model + thinking control — one controller for the conversation
agents (prober/teacher); the record's `turns` above still folds
the ACTUAL models billed, regardless of what the picker says. */}
<ModelControl chatId={chatId} />
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
</div>
);
}
+5
View File
@@ -8,6 +8,11 @@ export function formatUsd(c: number): string {
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
}
export function planCostLabel(dollars: number | null, subscription: boolean | string | readonly string[] | undefined, format = formatUsd): string {
const labels = subscription === true ? ["ChatGPT"] : typeof subscription === "string" ? [subscription] : Array.isArray(subscription) ? [...new Set(subscription)] : [];
return [...(dollars === null ? [] : [format(dollars)]), ...labels].join(" + ") || "—";
}
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
export function formatTokens(n: number): string {
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
+10 -2
View File
@@ -399,7 +399,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
```mermaid
flowchart LR
subgraph DESKTOP["Electron app (production)"]
MAIN["Electron main (electron/main.cjs)<br/>thin launcher + security guard<br/>1 IPC channel: lh:expand-window"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
MAIN["Electron main (electron/main.cjs)<br/>launcher + credential/security owner<br/>origin-pinned controls · private child IPC"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
MAIN -->|"loadURL, sandboxed renderer"| REND["Browser renderer (components/)<br/>fetch/WebSocket to own origin only"]
end
subgraph OUTSIDE["outside the process"]
@@ -417,10 +417,18 @@ flowchart LR
REND -->|"fetch /api/*"| NEXT
```
The Electron main is a launcher + security guard, never a host for app logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
The Electron main is a launcher + credential/security guard, never a host for teaching or inference logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
---
### 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.
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.
`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.
## 9. The foundational invariants — what ALWAYS holds
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
+84
View File
@@ -0,0 +1,84 @@
# Compaction and usage accounting
## Retention
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
coding-agent dependency. Upgrade this port deliberately; the native golden
fixtures are in `tests/compaction-native-parity.test.ts`.
Walk backward to the approximately 20,000-token retained-tail budget, then select
the nearest valid user/assistant boundary at or after the crossing. Never start
at a tool result. If trailing results alone exceed the budget, keep the preceding
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
its visible custom markers to user-like entries, excludes UI-only markers, and
uses the active compaction's retained history rather than discarded messages.
Text, visible thinking, tool-call names/arguments use characters divided by four;
images receive the native fixed 1,200-token allowance. Encrypted signatures are
not measured or stripped. Whole-message boundaries can overshoot. This does
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
Only cutoff selection is ported. LearnHarness retains its educational summary
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.
## Conversation meter
The numerator is the latest qualifying completed conversation response's native
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
counts, and all-zero usage do not replace the last measurement. A successful
compaction marker invalidates earlier measurements: show `? / maximum` until a
new qualifying response completes. Summarizer usage never supplies this meter.
The denominator follows the selected model's catalog limit, independently of the
last numeric measurement. Changing models preserves that numerator. No catalog
limit means no invented denominator; no percentage display is added.
## Spending
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
`calculateCost` on a new usage object. The browser receives derived costs, not a
second calculator/rate table. Totals are estimates using the currently shipped
catalog, not provider invoices; catalog updates can reprice history.
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
subtract cached reads from it again or migrate old logs as raw inclusive prompt
tokens. New captures preserve the one-hour subset of cache writes and individual
background-job calls. Older write-bearing records with a lost retention split,
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
the lost detail can change the answer. They are not reconstructed from totals.
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
API dollars. Summarizer costs continue to count toward session spending.
## Upstream attribution
The cutoff/estimator port adapts MIT-licensed code from
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
MIT License
Copyright (c) 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.
+5 -2
View File
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
```
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.
@@ -144,7 +143,7 @@ src/
electron/ desktop launcher + security guard (spawns the standalone
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
scripts/ build-server.mjs (packaging guards), import-notes,
backfill-graph, replay-layout, sync-pricing
backfill-graph, replay-layout
data/ chats/ · graph/ (nodes/chains/sessions/coverage
ledgers + positions/sources/render-settings maps) ·
jobs/ · settings.json — runtime state, git-ignored
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
## Key mechanisms
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
measurement-only context meter, and server-side current-catalog pricing. There is no
pricing-generation step: Pi AI's catalog and public calculator are the authority.
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
+10
View File
@@ -81,7 +81,12 @@ files:
- node_modules/universalify/**/*
- node_modules/ms/**/*
- node_modules/argparse/**/*
# Main-process ID-token signature verification (no bundled OAuth implementation).
- node_modules/jose/**/*
extraResources:
# Attribution/license for the bounded local Pi cutoff-selector port.
- from: docs/compaction-accounting.md
to: licenses/pi-compaction.md
# Copy from the PARENT dir (.next), not .next/standalone itself:
# electron-builder hard-excludes a copy's root node_modules and the
# default no-filter pattern is a literal path match (empirically both
@@ -95,6 +100,11 @@ extraResources:
filter:
- "standalone/**/*"
mac:
# The backend's --require runs before Next loads. Keep this resource
# outside app.asar so early Node-mode loading needs no ASAR support.
extraResources:
- from: electron/server-title-guard.cjs
to: server-title-guard.cjs
icon: build/icon.icon
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
+94
View File
@@ -0,0 +1,94 @@
// No Electron import: the shipping helper's readiness contract is testable
// without a display. Never announce CDP until its exact loopback endpoint works.
const http = require("node:http");
/**
* @param {number} port
* @param {{signal?: AbortSignal, timeoutMs?: number, requestTimeoutMs?: number, retryMs?: number}} [options]
* @returns {Promise<string>}
*/
function waitForBrowserService(port, {
signal,
timeoutMs = 5000,
requestTimeoutMs = 500,
retryMs = 50,
} = {}) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.reject(new Error("browser-service: invalid loopback port"));
}
return new Promise((resolve, reject) => {
let settled = false;
let activeRequest;
let attemptTimer;
let retryTimer;
let lastError;
const finish = (error, endpoint) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
clearTimeout(attemptTimer);
clearTimeout(retryTimer);
signal?.removeEventListener("abort", abort);
activeRequest?.destroy();
if (error) reject(error);
else resolve(endpoint);
};
const abort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("browser-service readiness aborted"));
const deadline = setTimeout(() => finish(new Error(
`browser-service: http://127.0.0.1:${port}/json/version not ready within ${timeoutMs}ms`,
{ cause: lastError },
)), timeoutMs);
const attempt = () => {
if (settled) return;
let done = false;
let response;
const complete = (error, endpoint) => {
if (done || settled) return;
done = true;
clearTimeout(attemptTimer);
response?.destroy();
request.destroy();
activeRequest = undefined;
if (!error) return finish(null, endpoint);
lastError = error;
retryTimer = setTimeout(attempt, retryMs);
};
const request = http.get({
hostname: "127.0.0.1", port, path: "/json/version", agent: false,
}, (res) => {
response = res;
res.on("error", (error) => complete(error));
if (res.statusCode !== 200) return complete(new Error(`CDP HTTP status ${res.statusCode}`));
const chunks = [];
let bytes = 0;
res.on("data", (chunk) => {
if (done || settled) return;
bytes += chunk.length;
if (bytes > 16 * 1024) return complete(new Error("CDP response exceeds 16 KiB"));
chunks.push(chunk);
});
res.on("end", () => {
try {
const info = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const endpoint = new URL(info.webSocketDebuggerUrl);
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || endpoint.port !== String(port) ||
endpoint.username || endpoint.password || endpoint.search || endpoint.hash ||
endpoint.href !== `ws://127.0.0.1:${port}${endpoint.pathname}` ||
!/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(endpoint.pathname)) {
throw new Error("CDP response has an invalid browser WebSocket endpoint");
}
complete(null, endpoint.href);
} catch (error) { complete(error); }
});
});
activeRequest = request;
request.on("error", (error) => complete(error));
attemptTimer = setTimeout(() => complete(new Error("CDP HTTP request timed out")), requestTimeoutMs);
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
else attempt();
});
}
module.exports = { waitForBrowserService };
+43
View File
@@ -0,0 +1,43 @@
"use strict";
const { publicError } = require("./chatgpt.cjs");
/** Inherited Node child IPC, not a listening HTTP/socket token service. */
function attachChatGPTChild(child, connection) {
const send = (value) => {
if (child.connected) child.send({ channel: "lh:chatgpt", ...value }, () => {});
};
const listener = async (message) => {
if (!message || message.channel !== "lh:chatgpt" || typeof message.id !== "string" || message.id.length > 100) return;
try {
let value;
if (message.op === "status") value = { status: await connection.status(), ...connection.stamp() };
else if (message.op === "models") value = await connection.models();
else if (message.op === "lease" && typeof message.background === "boolean") value = await connection.lease(message.background, message);
else throw new Error("Invalid private operation");
send({ id: message.id, op: message.op, ok: true, value });
} catch (err) { send({ id: message.id, op: message.op, ok: false, stamp: connection.stamp(), error: publicError(err) }); }
};
child.on("message", listener);
return { send, dispose: () => child.removeListener("message", listener) };
}
/** Sender must be the trusted window's main frame at its exact app origin. */
function trustedSender(event, origin, BrowserWindow) {
const win = BrowserWindow.fromWebContents(event.sender);
try {
return !!win && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin;
} catch { return false; }
}
async function chatGPTControl(command, connection) {
try {
if (command?.op === "status") return await connection.status();
if (command?.op === "login" && (command.id === undefined || typeof command.id === "string")) return await connection.login(command.id);
if (command?.op === "cancel") return connection.cancelLogin();
if (command?.op === "select" && typeof command.id === "string") return await connection.select(command.id);
if (command?.op === "disconnect" && typeof command.id === "string") return await connection.disconnect(command.id);
if (command?.op === "logout" && typeof command.id === "string") return await connection.logout(command.id);
if (command?.op === "consent" && typeof command.enabled === "boolean") return await connection.consent(command.enabled);
return { error: "Unsupported ChatGPT control." };
} catch (err) { return { error: publicError(err) }; }
}
module.exports = { attachChatGPTChild, trustedSender, chatGPTControl };
+542
View File
@@ -0,0 +1,542 @@
"use strict";
// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
// This module is main-process-only; dependencies are injectable for scratch tests.
const crypto = require("node:crypto");
const http = require("node:http");
const fs = require("node:fs/promises");
const path = require("node:path");
const ISSUER = "https://auth.openai.com";
const RESOURCE = "https://api.openai.com/v1";
const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
const JWKS = `${ISSUER}/.well-known/jwks.json`;
const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
const random = () => crypto.randomBytes(32).toString("base64url");
const fail = (code) => Object.assign(new Error(code), { code });
const publicError = (err) => ({
cancelled: "ChatGPT sign-in cancelled.",
timeout: "ChatGPT sign-in timed out. Try again.",
storage: "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.",
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
background: "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
expired: "The ChatGPT session expired. Reconnect in Providers.",
network: "ChatGPT could not be reached. Check your connection and try again.",
pending: "A ChatGPT sign-in is already pending.",
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
}[err?.code] || "ChatGPT sign-in failed. Try again; no API-key fallback was used.");
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
/** Reject the entire credential record when its schema is invalid. */
function validateState(state) {
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
const ids = new Set(); const clients = new Set();
for (const r of state.registrations) {
if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || (Object.hasOwn(r, "background") && typeof r.background !== "boolean") || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage");
ids.add(r.id); clients.add(r.clientId);
if (r.tokens !== undefined) {
const t = r.tokens;
if (!t || !nonempty(t.idToken) || !Array.isArray(t.scopes) || t.scopes.some((s) => !nonempty(s)) || !Number.isFinite(t.expiresAt) || t.expiresAt < 0 || (t.accessToken !== undefined && (!nonempty(t.accessToken) || t.accessToken.startsWith("sk-"))) || (t.refreshToken !== undefined && !nonempty(t.refreshToken))) throw fail("storage");
}
}
if (state.activeId !== null && !ids.has(state.activeId)) throw fail("storage");
return state;
}
/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
function fileStore(file) {
return {
async read() {
try {
return validateState(JSON.parse(await fs.readFile(file, "utf8")));
} catch (err) {
if (err.code === "ENOENT") return null;
throw fail("storage");
}
},
async write(state) {
validateState(state);
const temp = `${file}.${crypto.randomUUID()}.tmp`;
let handle;
try {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
handle = await fs.open(temp, "wx", 0o600);
await handle.writeFile(`${JSON.stringify(state)}\n`);
await handle.sync();
await handle.close();
handle = null;
await fs.rename(temp, file);
// Directory fsync is supported on Unix; the file fsync above is mandatory.
if (process.platform !== "win32") {
const dir = await fs.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
}
} catch {
throw fail("storage");
} finally {
await handle?.close().catch(() => {});
await fs.unlink(temp).catch(() => {});
}
},
};
}
function allowedAuthEndpoint(value) {
try {
const url = new URL(value);
return url.origin === ISSUER && !url.username && !url.password && !url.search && !url.hash;
} catch { return false; }
}
function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch, onChange = () => {}, timeoutMs = 180000 }) {
let state;
let init;
let queue = Promise.resolve();
let epoch = 0;
let revision = 0;
let authorityRevision = 0;
let catalogSequence = 0;
let storageDenied = false;
const stamp = () => ({ epoch, authorityRevision, revision });
let backgroundIntent;
let pending;
let catalog = [];
let catalogError = null;
let lastError = null;
let closed = false;
let paused = false;
let suspended = false;
let retryRegistration;
let transportAbort = new AbortController();
let backgroundAbort = new AbortController();
const serial = (fn) => {
const next = queue.then(fn, fn);
queue = next.catch(() => {});
return next;
};
const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined;
// These fields are validated/verified before storage. Email is never identity.
const sameAccount = (a, b) => a.issuer === b.issuer && a.subject === b.subject;
function accountGroups() {
const groups = [];
for (const record of state?.registrations || []) {
const group = groups.find((members) => sameAccount(members[0], record));
if (group) group.push(record); else groups.push([record]);
}
return groups;
}
const backgroundAllowed = () => state?.allowBackgroundUse !== false && backgroundIntent?.enabled !== false;
const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
function stop(backgroundOnly = false) {
if (backgroundOnly) {
backgroundAbort.abort();
backgroundAbort = new AbortController();
notify("background");
return;
}
suspended = true;
epoch++;
authorityRevision++;
catalogSequence++;
transportAbort.abort();
transportAbort = new AbortController();
catalog = [];
catalogError = null;
notify("all");
}
async function ready() {
if (closed) throw fail("disconnected");
if (!init) init = (async () => {
const loaded = await store.read();
state = loaded ? validateState(loaded) : null;
if (!state) {
const first = { version: 1, hostId: `urn:uuid:${crypto.randomUUID()}`, activeId: null, registrations: [] };
await store.write(first);
state = first;
}
})();
try { await init; } catch (err) { denyStorage(err); throw err; }
}
function denyStorage(err) {
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
}
async function save(next) {
try { validateState(next); await store.write(next); } catch (err) {
// A rotated token may already have invalidated its predecessor. Fail closed.
denyStorage(err);
lastError = publicError(err);
notify();
throw err;
}
state = next;
}
function snapshot() {
return {
available: !!state && !closed && !paused,
pending: !!pending,
allowBackgroundUse: backgroundAllowed(),
activeId: state?.activeId ?? null,
accounts: accountGroups().map((members) => {
// Preserve the actual active registration ID at the private transport
// boundary; inactive groups prefer a usable registration, then a
// remembered one. No credential/client records are merged or deleted.
const r = members.find((r) => r.id === state.activeId) ?? members.find((r) => !r.reconnectRequired && planScopes(r.tokens)) ?? members[0];
return {
id: r.id,
email: r.email || "ChatGPT account",
registration: r.clientId,
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
// Recovery is authorization-only, not a response to storage failure
// or temporary transport suspension. Never infer it from error copy.
canSignInAgain: !storageDenied && !closed && !paused && (r.reconnectRequired === true || !planScopes(r.tokens)),
};
}),
error: lastError,
catalogError,
};
}
async function status() {
try { await ready(); } catch (err) { return { ...snapshot(), available: false, error: publicError(err) }; }
return snapshot();
}
async function json(url, options = {}) {
try {
const response = await request(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(15000) });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const oauthCode = typeof body.error === "string" ? body.error : body.error?.code;
throw Object.assign(fail(oauthCode === "invalid_grant" || response.status === 401 || response.status === 403 ? "expired" : "network"), { oauthCode });
}
return await response.json();
} catch (err) { throw err.code ? err : fail("network"); }
}
async function identity(idToken, clientId, nonce, expectedSubject) {
try {
const { jwtVerify, createLocalJWKSet } = await import("jose");
// Fixed, documented JWKS origin; never follow a discovery-supplied URL.
const keys = await json(JWKS);
const { payload } = await jwtVerify(idToken, createLocalJWKSet(keys), {
issuer: ISSUER, audience: clientId, algorithms: ["RS256"],
requiredClaims: ["sub", "exp", "iat"], clockTolerance: 5,
});
if (typeof payload.sub !== "string" || !payload.sub || (nonce !== undefined && payload.nonce !== nonce) || (expectedSubject && payload.sub !== expectedSubject)) throw fail("identity");
if ((payload.azp !== undefined && payload.azp !== clientId) || (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId)) throw fail("identity");
return { subject: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined };
} catch { throw fail("identity"); }
}
function tokenSet(body, previous) {
const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/) : previous?.scopes;
if (!scopes || SCOPES.some((scope) => !scopes.includes(scope))) {
// Lack of entitlement is not lack of revocable material.
return { idToken: body.id_token ?? previous?.idToken, scopes: scopes ?? [], expiresAt: 0,
...(nonempty(body.refresh_token) ? { refreshToken: body.refresh_token } : previous?.refreshToken ? { refreshToken: previous.refreshToken } : {}) };
}
if (typeof body.access_token !== "string" || !body.access_token || body.access_token.startsWith("sk-") || body.token_type?.toLowerCase() !== "bearer" || !Number.isFinite(body.expires_in) || body.expires_in <= 0) throw fail("identity");
const refreshToken = body.refresh_token ?? previous?.refreshToken;
if (!nonempty(refreshToken)) throw fail("identity");
return { accessToken: body.access_token, refreshToken, idToken: body.id_token ?? previous?.idToken, scopes, expiresAt: Date.now() + body.expires_in * 1000 };
}
async function grant(fields, signal) {
return json(TOKEN, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams({ ...fields, resource: RESOURCE }), signal });
}
async function login(accountId) {
await ready();
if (pending) throw fail("pending");
if (paused) throw fail("disconnected");
const selected = accountId ? state.registrations.find((r) => r.id === accountId) : retryRegistration;
if (accountId && !selected) throw fail("identity");
const attemptEpoch = epoch;
const verifier = random();
const nonce = random();
const expectedState = random();
const abort = new AbortController();
let finish;
const completion = new Promise((resolve) => { finish = resolve; });
const listener = http.createServer();
let timer;
let consumed = false;
const cleanup = () => { clearTimeout(timer); listener.close(); listener.closeAllConnections(); };
const cancel = (code = "cancelled") => {
abort.abort();
cleanup();
if (pending?.abort === abort) pending = null;
lastError = publicError(fail(code));
finish();
notify();
};
pending = { abort, cancel };
lastError = null;
notify();
try {
await new Promise((resolve, reject) => {
listener.once("error", reject);
listener.listen(0, "127.0.0.1", resolve);
});
const redirectUri = `http://127.0.0.1:${listener.address().port}/auth/callback`;
listener.on("request", async (req, res) => {
let callback;
try { callback = new URL(req.url || "/", redirectUri); }
catch { res.writeHead(400).end(); return; }
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Security-Policy", "default-src 'none'");
res.setHeader("Referrer-Policy", "no-referrer");
if (req.method !== "GET" || callback.origin !== new URL(redirectUri).origin || callback.pathname !== "/auth/callback" || req.headers.host !== new URL(redirectUri).host || consumed) { res.writeHead(404).end(); return; }
const params = callback.searchParams;
// Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
if (["state", "code", "client_id", "error"].some((key) => params.getAll(key).length > 1) || params.get("state") !== expectedState) { res.writeHead(400).end("Sign-in could not be verified."); return; }
consumed = true;
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Return to LearnHarness to view the sign-in result. You may close this tab.");
cleanup();
try {
if (params.has("error")) throw fail(params.get("error") === "access_denied" ? "cancelled" : "identity");
const clientId = params.get("client_id") || selected?.clientId;
if (!clientId || clientId === "dynamic_agent_client" || clientId.length > 512 || /[\s\x00-\x1f]/.test(clientId) || (selected && clientId !== selected.clientId) || !params.get("code")) throw fail("identity");
let body;
try { body = await grant({ grant_type: "authorization_code", client_id: clientId, code: params.get("code"), code_verifier: verifier, redirect_uri: redirectUri }, AbortSignal.any([abort.signal, AbortSignal.timeout(15000)])); }
catch (err) {
if (err.oauthCode === "invalid_grant" && !selected?.subject) retryRegistration = { clientId };
throw err;
}
if (typeof body.id_token !== "string") throw fail("identity");
const verified = await identity(body.id_token, clientId, nonce, selected?.subject);
const tokens = tokenSet(body);
await serial(async () => {
if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
const existing = state.registrations.find((r) => r.clientId === clientId);
if (existing && existing.subject !== verified.subject) throw fail("identity");
const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, tokens };
const next = structuredClone(state);
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
next.activeId = record.id;
const previous = structuredClone(state);
await save(next); // Active identity changes only AFTER validation and durable persistence.
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
await save(previous);
throw fail("cancelled");
}
stop();
storageDenied = false;
suspended = false;
retryRegistration = undefined;
});
lastError = planScopes(tokens) ? null : publicError(fail("scope"));
} catch (err) { if (!abort.signal.aborted) lastError = publicError(err); }
finally { if (pending?.abort === abort) pending = null; finish(); notify(); }
});
timer = setTimeout(() => cancel("timeout"), timeoutMs);
const url = new URL(AUTHORIZE);
url.search = new URLSearchParams({
client_id: selected?.clientId ?? "dynamic_agent_client",
...(selected ? {} : { agent_name_hint: "LearnHarness" }),
ext_agent_host_id: state.hostId,
...(selected?.tokens?.idToken ? { id_token_hint: selected.tokens.idToken } : {}),
...(selected?.email ? { login_hint: selected.email } : {}),
response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), resource: RESOURCE,
state: expectedState, nonce, code_challenge_method: "S256",
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
}).toString();
await openExternal(url.toString()); // Trusted main only. Never return this URL to the renderer.
} catch (err) { cancel(err.code || "network"); }
void completion;
return snapshot();
}
function cancelLogin() { pending?.cancel(); return snapshot(); }
function authorized(background, expected, capturedEpoch) {
const account = active();
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
if (!planScopes(account.tokens)) throw fail("scope");
if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog");
if (background && !backgroundAllowed()) throw fail("background");
return account;
}
async function credential(background, expected) {
await ready();
return serial(async () => {
const requestEpoch = epoch;
const account = authorized(background, expected);
if (account.tokens.expiresAt <= Date.now() + 60000) {
const refreshEpoch = epoch;
const signal = AbortSignal.any([transportAbort.signal, ...(background ? [backgroundAbort.signal] : []), AbortSignal.timeout(15000)]);
let body;
try { body = await grant({ grant_type: "refresh_token", client_id: account.clientId, refresh_token: account.tokens.refreshToken }, signal); }
catch (err) {
if (err.oauthCode === "invalid_grant") {
const next = structuredClone(state);
delete next.registrations.find((r) => r.id === account.id).tokens;
next.activeId = null;
await save(next);
stop();
notify();
}
throw err;
}
let tokens;
try {
if (body.id_token !== undefined) await identity(body.id_token, account.clientId, undefined, account.subject);
tokens = tokenSet(body, account.tokens);
} catch (err) {
// The fixed token endpoint may have rotated already. Preserve only
// verified identity and a single likely-latest revocation handle.
stop();
const restricted = structuredClone(state);
const record = restricted.registrations.find((r) => r.id === account.id);
record.reconnectRequired = true;
if (nonempty(body.refresh_token)) record.tokens.refreshToken = body.refresh_token;
await save(restricted);
lastError = publicError(fail("identity")); notify();
throw err;
}
// A successful remote rotation must be durably retained even when
// logout, account selection, consent revocation, or pause denies use.
// Those serialized operations will revoke/clear the LATEST token.
const next = structuredClone(state);
next.registrations.find((r) => r.id === account.id).tokens = tokens;
await save(next);
if (closed || refreshEpoch !== epoch || signal.aborted) throw fail("disconnected");
}
const current = authorized(background, expected, requestEpoch);
return { accessToken: current.tokens.accessToken, accountId: current.id, ...stamp() };
});
}
async function lease(background, expected) {
if (typeof background !== "boolean" || !expected || !nonempty(expected.expectedAccountId) || !Number.isSafeInteger(expected.expectedAuthorityRevision) || !nonempty(expected.selectedModelId)) throw fail("catalog");
return credential(background, expected);
}
const modelValue = () => ({ ...stamp(), status: snapshot(), accountId: active()?.id ?? null, models: catalog });
async function models() {
const sequence = ++catalogSequence;
try {
await ready();
if (!active()) return modelValue();
// Discovery is identity/scope authorization, not an invented model lease.
const token = await credential(false);
const body = await json(`${RESOURCE}/models`, { headers: { Authorization: `Bearer ${token.accessToken}` }, signal: AbortSignal.any([transportAbort.signal, AbortSignal.timeout(15000)]) });
authorized(false, undefined, token.epoch);
if (sequence !== catalogSequence || active()?.id !== token.accountId || !Array.isArray(body.models)) throw fail("catalog");
const visible = body.models.filter((m) => m && m.visibility === "list" && nonempty(m.slug) && typeof m.display_name === "string").map((m) => ({ id: m.slug, name: m.display_name }));
const changed = JSON.stringify(visible) !== JSON.stringify(catalog);
catalog = visible;
if (changed) authorityRevision++;
const hadError = catalogError !== null; catalogError = null;
if (changed || hadError) notify();
return modelValue();
} catch {
if (sequence === catalogSequence) {
if (catalog.length) { catalog = []; authorityRevision++; }
catalogError = publicError(fail("catalog")); notify();
}
return modelValue();
}
}
async function select(id) {
pending?.cancel();
stop();
const selectionIntent = epoch;
await ready();
return serial(async () => {
const account = state.registrations.find((r) => r.id === id);
if (!account?.tokens) throw fail("disconnected");
await save({ ...state, activeId: id });
if (epoch !== selectionIntent || closed || paused || storageDenied) throw fail("disconnected");
suspended = false;
lastError = null;
notify();
return snapshot();
});
}
async function consent(enabled) {
if (typeof enabled !== "boolean") throw fail("identity");
// One app-owned intent, independent of account/registration lifetime.
const intent = { enabled: false };
backgroundIntent = intent; // ON cannot grant until its durable save.
if (!enabled) stop(true); // Revoke all ChatGPT background streams only.
await ready();
return serial(async () => {
await save({ ...structuredClone(state), allowBackgroundUse: enabled });
if (backgroundIntent === intent && enabled) backgroundIntent = undefined;
notify();
return snapshot();
});
}
async function revoke(account) {
let revoked = false;
try {
if (account.tokens?.refreshToken) {
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
try {
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
revoked = response.status === 200;
if (revoked || response.status < 500) break;
} catch { /* retry only while the refresh token is still in memory */ }
}
}
} catch { /* local action is mandatory even when revocation is unavailable */ }
return revoked;
}
async function disconnect(id) {
// Explicit local forgetting: a visible account represents every retained
// registration with this exact verified issuer/subject, never its email.
pending?.cancel();
const initial = state?.registrations.find((r) => r.id === id);
let stopped = !!initial && state.registrations.some((r) => r.id === state.activeId && sameAccount(r, initial));
if (stopped) stop();
await ready();
return serial(async () => {
const target = state.registrations.find((r) => r.id === id);
if (!target) throw fail("identity");
const members = state.registrations.filter((r) => sameAccount(r, target));
const activeGroup = members.some((r) => r.id === state.activeId);
if (activeGroup && !stopped) { stop(); stopped = true; }
const disconnectEpoch = epoch;
let revoked = true;
for (const member of members) { const confirmed = await revoke(member); revoked = confirmed && revoked; }
const next = structuredClone(state);
next.registrations = next.registrations.filter((r) => !sameAccount(r, target));
if (activeGroup) next.activeId = null; // Never silently select another account.
await save(next);
if (members.some((r) => r.clientId === retryRegistration?.clientId)) retryRegistration = undefined;
if (stopped && !closed && epoch === disconnectEpoch) suspended = false;
lastError = revoked ? null : REVOCATION_WARNING;
notify();
return snapshot();
});
}
async function logout(id) {
pending?.cancel();
stop();
await ready();
return serial(async () => {
const account = state.registrations.find((r) => r.id === id);
if (!account) throw fail("identity");
const revoked = await revoke(account);
const next = structuredClone(state);
delete next.registrations.find((r) => r.id === id).tokens;
if (next.activeId === id) next.activeId = null;
const logoutEpoch = epoch;
await save(next);
if (!closed && epoch === logoutEpoch) suspended = false;
lastError = revoked ? null : REVOCATION_WARNING;
notify();
return snapshot();
});
}
function close() { closed = true; pending?.cancel(); stop(); }
function pause() { paused = true; pending?.cancel(); stop(); notify(); }
function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
}
module.exports = { createChatGPT, fileStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
+25
View File
@@ -0,0 +1,25 @@
"use strict";
const HEADER = "x-learnharness-desktop";
/** Called for EVERY outgoing request, so redirects cannot carry the secret
* to another origin. Never expose this function or its secret to preload. */
function desktopAPIHeaders(details, origin, secret, windows) {
const headers = { ...details.requestHeaders };
for (const name of Object.keys(headers)) if (name.toLowerCase() === HEADER) delete headers[name];
if (!secret) return headers;
try {
const target = new URL(details.url);
if (target.origin !== origin || target.username || target.password || !(target.pathname === "/api" || target.pathname.startsWith("/api/"))) return headers;
const win = windows.find((candidate) => !candidate.isDestroyed() && candidate.webContents.id === details.webContentsId);
if (!win || win.webContents.isDestroyed()) return headers;
const contents = win.webContents;
if (details.webContents && details.webContents !== contents) return headers;
if (!details.frame || details.frame !== contents.mainFrame) return headers;
if (new URL(contents.mainFrame.url).origin !== origin) return headers;
if (details.initiatorOrigin !== undefined && details.initiatorOrigin !== origin) return headers;
headers[HEADER] = secret;
} catch { /* Missing/destroyed/navigated frames fail closed. */ }
return headers;
}
module.exports = { desktopAPIHeaders, HEADER };
+95 -10
View File
@@ -25,6 +25,12 @@
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = 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 { randomBytes } = require("node:crypto");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
@@ -64,13 +70,24 @@ function serverEntry() {
// <this binary> --browser-service
// and connects over CDP (system Chrome stays the fallback). Handshake:
// the helper prints "BROWSER_SERVICE_CDP <port>" on stdout once the CDP
// endpoint listens. Lifetime: stdin stays open; parent death = EOF = quit.
// endpoint listens. Lifetime: parent IPC disconnect/death = quit.
// MUST run before the single-instance lock — a helper is a second
// instance of this very binary, and before the LH_DEV_URL redirect — the
// helper owns no user data at all.
if (process.argv.includes("--browser-service")) {
const { waitForBrowserService } = require("./browser-service-readiness.cjs");
// Own the helper's entire startup lifetime, including the readiness wait.
const lifetime = new AbortController();
const parentClosed = () => {
lifetime.abort(new Error("browser-service parent IPC disconnected"));
app.quit();
};
(async () => {
try {
// Full Windows Electron replaces stdin with synthetic EOF. Use the
// real Node IPC channel, and refuse to start an unowned helper.
if (!process.channel || !process.connected) throw new Error("browser-service requires connected parent IPC");
process.once("disconnect", parentClosed);
app.dock?.hide();
const port = await freePort();
// Must land before app-ready for Chromium to pick it up.
@@ -80,13 +97,13 @@ if (process.argv.includes("--browser-service")) {
// platform (no window-all-closed handler is installed here).
const win = new BrowserWindow({ show: false });
await win.loadURL("about:blank");
console.log(`BROWSER_SERVICE_CDP ${port}`);
// Parent-tied lifetime: the server holds our stdin open.
process.stdin.resume();
process.stdin.on("end", () => app.quit());
process.stdin.on("close", () => app.quit());
// Window load is not a DevTools HTTP readiness barrier. Confirm the
// endpoint before both production and smoke clients see the handshake.
await waitForBrowserService(port, { signal: lifetime.signal });
if (!lifetime.signal.aborted) console.log(`BROWSER_SERVICE_CDP ${port}`);
} catch (err) {
console.error(`browser-service failed: ${err?.stack ?? err}`);
if (lifetime.signal.aborted) return; // Parent disconnect is normal.
console.error("browser-service failed:", err);
app.exit(1);
}
})();
@@ -117,6 +134,13 @@ if (process.argv.includes("--browser-service")) {
}
let serverProcess = null;
let chatgpt = null;
let chatgptChild = null;
let subscriptions = null;
let subscriptionChild = null;
// Per-launch request capability, never OAuth tokens and never renderer state.
let desktopAPISecret = null;
const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
// in an event handler): the window + child server are the product, and
@@ -176,7 +200,11 @@ function startServer(port) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[serverEntry()],
// Only the macOS backend needs this: Next's native title setter
// otherwise checks this Node-mode process in as a foreground app.
process.platform === "darwin"
? ["--require", path.join(process.resourcesPath, "server-title-guard.cjs"), serverEntry()]
: [serverEntry()],
{
cwd: path.dirname(serverEntry()),
env: {
@@ -190,17 +218,27 @@ function startServer(port) {
// (no env injection anymore: a spawn-time copy would shadow every
// settings-page key change until restart; env wins over file).
LH_AUTH_FILE: AUTH_FILE(),
LH_DESKTOP_SERVER: "1",
LH_DESKTOP_API_SECRET: desktopAPISecret,
},
stdio: ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
},
);
serverProcess = child;
chatgptChild = attachChatGPTChild(child, chatgpt);
subscriptionChild = attachSubscriptionChild(child, subscriptions);
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
child.stdout.on("data", fwd);
child.stderr.on("data", fwd);
child.on("exit", (code, signal) => {
log(`server exited (code=${code} signal=${signal})`);
serverProcess = null;
chatgptChild?.dispose();
chatgptChild = null;
subscriptionChild?.dispose();
subscriptionChild = null;
chatgpt?.close();
subscriptions?.close();
});
resolve(child);
});
@@ -224,6 +262,8 @@ function waitUntilUp(url, tries = 150) {
}
function killServer() {
chatgpt?.close();
subscriptions?.close();
if (!serverProcess) return;
const child = serverProcess;
serverProcess = null;
@@ -295,6 +335,8 @@ function createWindow(url) {
minHeight: 600,
});
trustedAppWindows.add(win);
win.once("closed", () => trustedAppWindows.delete(win));
const allowedOrigin = () => new URL(url).origin;
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
// clipboard-sanitized-write — the copy affordances in the transcript
@@ -535,6 +577,8 @@ function installMenu() {
async function main() {
app.on("window-all-closed", () => {
chatgpt?.pause();
subscriptions?.pause();
// macOS convention: the app stays resident, reactivated from the Dock.
if (process.platform !== "darwin") app.quit();
});
@@ -552,6 +596,30 @@ async function main() {
const devUrl = process.env.LH_DEV_URL;
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
if (!devUrl) {
desktopAPISecret = randomBytes(32).toString("hex");
const ownedOrigin = new URL(url).origin;
// All URLs, not only /api: remove the header again on external redirects.
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ["<all_urls>"] }, (details, callback) => {
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
});
chatgpt = createChatGPT({
store: fileStore(path.join(app.getPath("userData"), "chatgpt-credentials.json")),
openExternal: (target) => shell.openExternal(target),
onChange: (value) => {
chatgptChild?.send(value);
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
},
});
subscriptions = createSubscriptions({
directory: app.getPath("userData"),
loadSDK: () => loadSubscriptionSDK(path.dirname(serverEntry())),
openExternal: (target) => shell.openExternal(target),
onChange: (snapshot) => {
subscriptionChild?.send(snapshot);
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:subscriptions:changed");
},
});
await subscriptions.ready;
log(`starting server: ${serverEntry()}`);
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
await startServer(new URL(url).port);
@@ -561,6 +629,23 @@ async function main() {
log(`dev mode: ${devUrl}`);
}
// Renderer controls contain no token/URL/code operation. The separate server
// child is the only recipient of credential leases over inherited IPC.
ipcMain.handle("lh:chatgpt:control", async (event, command) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return { error: "Untrusted desktop request." };
if (!chatgpt || !serverProcess?.connected) return { available: false, pending: false, activeId: null, accounts: [], error: "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
return chatGPTControl(command, chatgpt);
});
ipcMain.handle("lh:subscriptions:control", async (event, command) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
if (!subscriptions || !serverProcess?.connected) {
if (command?.op === "status") return [];
throw new Error("Subscription sign-in requires the desktop app's private server connection.");
}
return subscriptionControl(command, subscriptions);
});
installMenu();
// Auto-update (Wave C): wire the updater before the window so the
@@ -586,7 +671,7 @@ async function main() {
if (width < target) win.setBounds({ x, y, width: target, height });
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
});
createWindow(url);
log(`window open: ${url}`);
+29
View File
@@ -29,6 +29,35 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }),
select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }),
disconnect: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "disconnect", id }),
logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }),
consent: (enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", enabled }),
onChange: (cb) => {
const listener = () => cb();
ipcRenderer.on("lh:chatgpt:changed", listener);
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
},
},
subscriptions: {
status: () => ipcRenderer.invoke("lh:subscriptions:control", { op: "status" }),
login: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "login", providerId, enabled }),
cancel: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "cancel", providerId }),
respond: (providerId, attemptId, promptId, value) => ipcRenderer.invoke("lh:subscriptions:control", { op: "respond", providerId, attemptId, promptId, value }),
open: (providerId, attemptId, actionId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "open", providerId, attemptId, actionId }),
retry: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "retry", providerId }),
disconnect: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "disconnect", providerId }),
consent: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "consent", providerId, enabled }),
onChange: (cb) => {
const listener = () => cb();
ipcRenderer.on("lh:subscriptions:changed", listener);
return () => ipcRenderer.removeListener("lh:subscriptions:changed", listener);
},
},
expandWindow: () => ipcRenderer.send("lh:expand-window"),
onZoom: (cb) => {
const listener = (_event, factor) => cb(factor);
+24
View File
@@ -0,0 +1,24 @@
/**
* Preloaded only into the packaged macOS backend, before Next starts.
*
* Next assigns process.title. In Electron's Node mode on macOS, the native
* setter checks in with Launch Services using the main app bundle, creating
* an unwanted foreground application registration. Keep title writes in JS
* instead: the native process name stays unchanged while JS can read back
* its assigned title. This deliberately does not emulate native truncation
* or every coercion detail; assignments use String(value).
*
* Explicit launch arguments (not NODE_OPTIONS) keep the full-Electron browser
* helper untouched. Node-mode fork descendants can inherit this preload.
*/
if (process.platform === "darwin" && process.env.ELECTRON_RUN_AS_NODE === "1" && process.env.LH_DESKTOP_SERVER === "1") {
let title = process.title;
Object.defineProperty(process, "title", {
configurable: true,
enumerable: true,
get: () => title,
set: (value) => {
title = String(value);
},
});
}
+45
View File
@@ -0,0 +1,45 @@
"use strict";
const { PROVIDERS } = require("./subscription-store.cjs");
const { publicError } = require("./subscriptions.cjs");
function attachSubscriptionChild(child, controller) {
let disposed = false;
const send = (packet) => { if (!disposed && child.connected) { try { child.send(packet, () => {}); } catch { /* child exited */ } } };
const listener = async (message) => {
if (disposed || !child.connected || message?.channel !== "lh:subscriptions" || typeof message.id !== "string") return;
const { id, op } = message;
try {
if (!controller || !["snapshot", "lease"].includes(op)) throw new Error();
if (op === "lease" && (typeof message.background !== "boolean" || !validScope(message.scope))) throw new Error();
const value = op === "snapshot" ? await controller.snapshot() : await controller.lease(message.scope, message.background);
send({ channel: "lh:subscriptions", id, op, ok: true, value });
} catch (error) { send({ channel: "lh:subscriptions", id, op, ok: false, error: publicError(error) }); }
};
child.on("message", listener);
return {
send(snapshot) { send({ channel: "lh:subscriptions", op: "changed", snapshot }); },
dispose() { disposed = true; child.removeListener("message", listener); },
};
}
function validScope(scope) {
return scope && PROVIDERS.includes(scope.providerId) && typeof scope.connectionId === "string" && !!scope.connectionId && typeof scope.selectedModelId === "string" && !!scope.selectedModelId && [scope.epoch, scope.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0);
}
/** Caller authenticates the exact trusted main-frame origin before dispatch. */
async function subscriptionControl(command, controller) {
try {
if (!controller || !command || typeof command !== "object") throw new Error();
if (command.op === "status") return await controller.status();
if (!PROVIDERS.includes(command.providerId)) throw new Error();
const id = command.providerId;
switch (command.op) {
case "login": return await controller.login(id, command.enabled);
case "cancel": return controller.cancel(id);
case "respond": return await controller.respond(id, command.attemptId, command.promptId, command.value);
case "open": return await controller.open(id, command.attemptId, command.actionId);
case "retry": return await controller.retry(id);
case "disconnect": return await controller.disconnect(id);
case "consent": return await controller.consent(id, command.enabled);
default: throw new Error();
}
} catch (error) { throw new Error(publicError(error)); }
}
module.exports = { attachSubscriptionChild, subscriptionControl, validScope };
+64
View File
@@ -0,0 +1,64 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const PROVIDERS = ["github-copilot", "xai"];
const failure = (code) => Object.assign(new Error(code), { code });
const nonempty = (value) => typeof value === "string" && value.length > 0 && !/[\x00-\x20\x7f]/.test(value);
function validateCredential(providerId, credential) {
if (!credential || credential.type !== "oauth" || !nonempty(credential.access) || !nonempty(credential.refresh) || !Number.isFinite(credential.expires) || credential.expires < 0) throw failure("storage");
if (providerId === "github-copilot") {
if (credential.enterpriseUrl !== undefined && !safeDomain(credential.enterpriseUrl)) throw failure("storage");
if (credential.availableModelIds !== undefined && (!Array.isArray(credential.availableModelIds) || !credential.availableModelIds.every(nonempty))) throw failure("storage");
}
return credential;
}
function safeDomain(value) {
if (typeof value !== "string" || !value || /[\s/:@?#\\]/.test(value)) return false;
try { return new URL(`https://${value}`).hostname === value; } catch { return false; }
}
function safeHttps(value) {
try {
const url = new URL(value);
return url.protocol === "https:" && !url.username && !url.password;
} catch { return false; }
}
function validateState(providerId, state) {
if (!PROVIDERS.includes(providerId) || !state || state.version !== 1 || typeof state.allowBackgroundUse !== "boolean") throw failure("storage");
const hasId = Object.hasOwn(state, "connectionId");
if (hasId !== Object.hasOwn(state, "credential") || (hasId && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(state.connectionId))) throw failure("storage");
if (hasId) validateCredential(providerId, state.credential);
return state;
}
/** Main-process-only, one independent owner-only file per provider. No recovery writes on corruption. */
function fileStore(file, providerId, io = fs) {
return {
async read() {
try {
const stat = await io.lstat(file);
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())))) throw failure("storage");
return validateState(providerId, JSON.parse(await io.readFile(file, "utf8")));
}
catch (error) { if (error.code === "ENOENT") return { version: 1, allowBackgroundUse: true }; throw failure("storage"); }
},
async write(state) {
validateState(providerId, state);
const temporary = `${file}.${randomUUID()}.tmp`;
let handle;
try {
await io.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
handle = await io.open(temporary, "wx", 0o600);
await handle.writeFile(`${JSON.stringify(state)}\n`);
await handle.sync(); await handle.close(); handle = null;
await io.rename(temporary, file);
if (process.platform !== "win32") {
const directory = await io.open(path.dirname(file), "r");
try { await directory.sync(); } finally { await directory.close(); }
}
} catch { throw failure("storage"); }
finally { await handle?.close().catch(() => {}); await io.unlink(temporary).catch(() => {}); }
},
};
}
module.exports = { PROVIDERS, failure, validateCredential, validateState, safeDomain, safeHttps, fileStore };
+251
View File
@@ -0,0 +1,251 @@
"use strict";
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { createRequire, registerHooks } = require("node:module");
const { pathToFileURL } = require("node:url");
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
const LABELS = { "github-copilot": "GitHub Copilot", xai: "Grok" };
const APIS = new Set(["openai-responses", "openai-completions", "anthropic-messages"]);
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
const MESSAGES = {
storage: "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
login: "Sign-in did not complete. Try connecting again.",
refresh: "The connection could not be refreshed. Retry or reconnect in Providers.",
cancel: "Sign-in cancelled.",
unavailable: "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
background: "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
stale: "The connection or model catalog changed. Select an available model and retry.",
interaction: "This sign-in action is no longer available.",
};
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 hook = registerHooks({ resolve(specifier, context, next) {
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
} });
let file;
try { file = resolve.resolve(id); } finally { hook.deregister(); }
return import(pathToFileURL(file).href);
};
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 };
}
function validEndpoint(providerId, auth, credential) {
if (!auth || (!(typeof auth.apiKey === "string" && auth.apiKey.length > 0) && !Object.entries(auth.headers ?? {}).some(([key, value]) => ["authorization", "x-api-key", "api-key"].includes(key.toLowerCase()) && typeof value === "string" && value.trim().length > 0))) return false;
const value = auth.baseUrl ?? (providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
if (!safeHttps(value)) return false;
const url = new URL(value);
if (providerId === "xai") return url.origin === "https://api.x.ai" && url.pathname.replace(/\/$/, "") === "/v1" && !url.search && !url.hash;
const domain = credential?.enterpriseUrl;
return !url.search && !url.hash && url.pathname === "/" && (!url.port || url.port === "443") && (url.hostname.endsWith(".githubcopilot.com") || (domain && (url.hostname === domain || url.hostname.endsWith(`.${domain}`))));
}
/** Main owns durable credentials and refresh. Everything returned by status/snapshot is explicitly projected. */
function createSubscriptions(options) {
let sdk;
let closed = false, paused = false;
const entries = new Map(PROVIDERS.map((providerId) => [providerId, {
providerId, state: { version: 1, allowBackgroundUse: true }, disk: options.stores?.[providerId] ?? fileStore(path.join(options.directory, `${providerId}-credentials.json`), providerId),
epoch: 0, authorityRevision: 0, revision: 0, tail: Promise.resolve(), fatal: false, blocked: false, ready: false, error: undefined, authFailed: false, attempt: null, catalog: [], endpoint: null,
}]));
const entryFor = (id) => { const entry = entries.get(id); if (!entry) throw failure("unavailable"); return entry; };
function serialized(entry, fn) { const result = entry.tail.then(fn); entry.tail = result.catch(() => {}); return result; }
function statusOf(entry) {
const available = entry.ready && !closed && !paused && !entry.fatal;
const attempt = entry.attempt;
return {
providerId: entry.providerId, available, connected: !!(available && entry.state.credential && !entry.blocked && !entry.authFailed), pending: !!attempt, committing: attempt?.committing === true,
allowBackgroundUse: entry.state.allowBackgroundUse, connectionId: entry.state.connectionId ?? null,
epoch: entry.epoch, authorityRevision: entry.authorityRevision, revision: entry.revision,
...(entry.error ? { error: entry.error } : {}),
...(attempt ? { interaction: clone(attempt.public) } : {}),
};
}
function snapshotNow() {
return { statuses: [...entries.values()].map(statusOf), models: Object.fromEntries([...entries.values()].map((entry) => [entry.providerId, statusOf(entry).connected ? clone(entry.catalog) : []])) };
}
function changed(entry) { entry.revision++; try { options.onChange?.(snapshotNow()); } catch { /* observer cannot break credential writes */ } }
function catalog(entry) {
let models = entry.provider?.getModels().filter((model) => APIS.has(model.api)) ?? [];
if (!entry.state.credential) models = [];
else if (entry.providerId === "github-copilot" && !Array.isArray(entry.state.credential.availableModelIds)) models = [];
else if (entry.provider?.filterModels) models = [...entry.provider.filterModels(models, entry.state.credential)];
if (JSON.stringify(models) !== JSON.stringify(entry.catalog)) { entry.catalog = models; entry.authorityRevision++; }
}
async function updateEndpoint(entry) {
let endpoint = null;
if (entry.state.credential) {
const auth = await entry.provider.auth.oauth.toAuth(entry.state.credential);
if (!validEndpoint(entry.providerId, auth, entry.state.credential)) throw failure("storage");
endpoint = auth.baseUrl ?? (entry.providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
}
if (entry.endpoint !== endpoint) { entry.endpoint = endpoint; entry.authorityRevision++; }
}
function storageFailed(entry) { entry.fatal = true; entry.blocked = true; entry.epoch++; entry.error = MESSAGES.storage; changed(entry); }
async function save(entry, state) {
try { await entry.disk.write(state); } catch { storageFailed(entry); throw failure("storage"); }
entry.state = clone(state);
try { await updateEndpoint(entry); catalog(entry); } catch { storageFailed(entry); throw failure("storage"); }
changed(entry);
}
const ready = (async () => {
try { sdk = options.sdk ?? await options.loadSDK(); }
catch { for (const entry of entries.values()) { entry.error = MESSAGES.unavailable; entry.fatal = true; changed(entry); } return; }
await Promise.all([...entries.values()].map(async (entry) => {
try {
entry.state = clone(await entry.disk.read());
const native = entry.providerId === "github-copilot" ? sdk.githubCopilotProvider() : sdk.xaiProvider();
// No API-key auth method exists in this collection: ambient keys cannot become a fallback.
entry.provider = { ...native, auth: { oauth: native.auth.oauth } };
const store = {
read: async (id) => id === entry.providerId ? clone(entry.state.credential) : undefined,
list: async () => entry.state.credential ? [{ providerId: entry.providerId, type: "oauth" }] : [],
modify: (id, fn, operation) => serialized(entry, async () => {
if (id !== entry.providerId || entry.fatal || closed || paused || operation?.signal?.aborted) throw failure("unavailable");
const next = await fn(clone(entry.state.credential));
// A returned rotation must be saved even if the app paused while
// refresh was in flight. Admission is rechecked after resolution;
// queued logout deletes only after this serialized durable write.
if (next !== undefined && JSON.stringify(next) !== JSON.stringify(entry.state.credential)) {
try { validateCredential(id, next); } catch { storageFailed(entry); throw failure("storage"); }
await save(entry, { ...entry.state, credential: next });
}
return clone(entry.state.credential);
}),
delete: (id) => serialized(entry, async () => {
if (id !== entry.providerId || entry.fatal) throw failure("storage");
await save(entry, { version: 1, allowBackgroundUse: entry.state.allowBackgroundUse });
}),
};
entry.models = sdk.createModels({ credentials: store }); entry.models.setProvider(entry.provider);
await updateEndpoint(entry); entry.ready = true; catalog(entry); changed(entry);
} catch { entry.fatal = true; entry.error = MESSAGES.storage; changed(entry); }
}));
})();
function current(entry, scope) {
return statusOf(entry).connected && scope?.providerId === entry.providerId && scope.connectionId === entry.state.connectionId && scope.epoch === entry.epoch && scope.authorityRevision === entry.authorityRevision && entry.catalog.some((m) => m.id === scope.selectedModelId);
}
async function consent(providerId, enabled) {
await ready; const entry = entryFor(providerId);
if (typeof enabled !== "boolean" || entry.fatal || closed || paused) throw failure("unavailable");
await serialized(entry, async () => { if (entry.state.allowBackgroundUse !== enabled) await save(entry, { ...entry.state, allowBackgroundUse: enabled }); });
}
async function login(providerId, enabled) {
await ready; const entry = entryFor(providerId);
if ((enabled !== undefined && typeof enabled !== "boolean") || entry.fatal || closed || paused || !entry.ready) throw failure("unavailable");
if ([...entries.values()].some((e) => e.attempt)) throw failure("interaction");
const abort = new AbortController();
const attempt = { id: randomUUID(), abort, committing: false, actions: new Map(), prompt: null, public: {} };
attempt.public = { attemptId: attempt.id }; entry.attempt = attempt; entry.error = undefined; changed(entry);
const active = () => entry.attempt === attempt && !abort.signal.aborted && !closed && !paused;
const addBrowser = (value) => {
if (!safeHttps(value)) throw failure("interaction");
const id = randomUUID(); attempt.actions.set(id, value);
const url = new URL(value); attempt.public.browser = { id, url: `${url.origin}${url.pathname}` };
};
try {
const staging = sdk.createModels(); staging.setProvider(entry.provider);
const credential = await staging.login(providerId, "oauth", {
signal: abort.signal,
notify: (event) => {
if (!active()) return;
if (event.type === "auth_url") addBrowser(event.url);
if (event.type === "device_code") { addBrowser(event.verificationUri); attempt.public.userCode = event.userCode; }
// Native provider diagnostics are never forwarded. Public prompt wording comes from SDK-owned prompts.
attempt.public.progress = event.type === "device_code" || event.type === "auth_url" ? "Complete sign-in in your browser." : "Waiting for the provider…";
changed(entry);
},
prompt: (prompt) => new Promise((resolve, reject) => {
if (!active() || prompt.signal?.aborted) { reject(failure("cancel")); return; }
const id = randomUUID();
const stop = () => { cleanup(); reject(failure("cancel")); };
const cleanup = () => { abort.signal.removeEventListener("abort", stop); prompt.signal?.removeEventListener("abort", stop); if (attempt.prompt?.id === id) { attempt.prompt = null; delete attempt.public.prompt; } };
attempt.prompt = { id, resolve: (value) => { cleanup(); resolve(value); }, reject: stop };
attempt.public.prompt = { id, type: prompt.type, message: prompt.message, ...(prompt.placeholder ? { placeholder: prompt.placeholder } : {}), ...(prompt.type === "select" ? { options: clone(prompt.options) } : {}) };
abort.signal.addEventListener("abort", stop, { once: true }); prompt.signal?.addEventListener("abort", stop, { once: true }); changed(entry);
}),
}, { agentName: "LearnHarness" });
if (!active()) throw failure("cancel"); validateCredential(providerId, credential);
const auth = await entry.provider.auth.oauth.toAuth(credential);
if (!validEndpoint(providerId, auth, credential) || !active()) throw failure("login");
await serialized(entry, async () => {
if (!active() || entry.fatal) throw failure("cancel");
attempt.committing = true; entry.blocked = true; entry.epoch++; changed(entry);
await save(entry, { version: 1, allowBackgroundUse: enabled ?? entry.state.allowBackgroundUse, connectionId: randomUUID(), credential });
entry.blocked = false; entry.authFailed = false; entry.error = undefined;
});
} catch (error) {
if (active() && !entry.fatal) entry.error = error?.code === "cancel" ? undefined : MESSAGES.login;
if (!abort.signal.aborted) throw failure(entry.fatal ? "storage" : "login");
} finally { attempt.prompt?.reject(); if (entry.attempt === attempt) { entry.attempt = null; changed(entry); } }
}
function cancel(providerId) { const entry = entryFor(providerId); if (entry.attempt && !entry.attempt.committing) { entry.attempt.abort.abort(); entry.attempt = null; if (entry.authFailed && !entry.error) entry.error = MESSAGES.refresh; changed(entry); } }
async function resolveAuth(entry, modelId) {
if (entry.fatal || closed || paused || !entry.state.credential) throw failure("unavailable");
const connectionId = entry.state.connectionId, epoch = entry.epoch;
const unchanged = () => connectionId === entry.state.connectionId && epoch === entry.epoch && !entry.blocked && !closed && !paused;
try {
const model = entry.provider.getModels().find((m) => m.id === modelId);
if (!model) throw failure("stale");
const resolved = await entry.models.getAuth(model);
if (!unchanged()) throw failure("stale");
if (!validEndpoint(entry.providerId, resolved?.auth, entry.state.credential)) throw failure("unavailable");
return {
...(typeof resolved.auth.apiKey === "string" ? { apiKey: resolved.auth.apiKey } : {}),
...(resolved.auth.headers ? { headers: Object.fromEntries(Object.entries(resolved.auth.headers).filter(([, value]) => value === null || typeof value === "string")) } : {}),
...(typeof resolved.auth.baseUrl === "string" ? { baseUrl: resolved.auth.baseUrl } : {}),
};
} catch (error) {
if (!entry.fatal && unchanged()) { entry.authFailed = true; entry.error = MESSAGES.refresh; entry.epoch++; changed(entry); }
throw failure(entry.fatal ? "storage" : error?.code === "stale" ? "stale" : "refresh");
}
}
return {
ready,
async snapshot() { await ready; return snapshotNow(); },
async status() { await ready; return snapshotNow().statuses; },
login, consent, cancel,
async respond(providerId, attemptId, promptId, value) {
const entry = entryFor(providerId), attempt = entry.attempt;
if (!attempt || attempt.id !== attemptId || attempt.prompt?.id !== promptId || typeof value !== "string" || value.length > 4096) throw failure("interaction");
const prompt = attempt.public.prompt;
if (prompt.type === "select" && !prompt.options.some((option) => option.id === value)) throw failure("interaction");
attempt.prompt.resolve(value); changed(entry);
},
async open(providerId, attemptId, actionId) {
const attempt = entryFor(providerId).attempt;
const target = attempt?.id === attemptId ? attempt.actions.get(actionId) : undefined;
if (!target || !safeHttps(target)) throw failure("interaction");
try { await options.openExternal(target); } catch { throw failure("interaction"); }
},
async lease(scope, background) {
await ready; const entry = entryFor(scope?.providerId);
if (!current(entry, scope)) throw failure("stale");
if (background && !entry.state.allowBackgroundUse) throw failure("background");
const auth = await resolveAuth(entry, scope.selectedModelId);
if (!current(entry, scope)) throw failure("stale");
if (background && !entry.state.allowBackgroundUse) throw failure("background");
return { scope: clone(scope), auth: clone(auth) };
},
async retry(providerId) {
await ready; const entry = entryFor(providerId);
const model = entry.catalog[0]; if (!model || entry.fatal || entry.blocked) throw failure("unavailable");
await resolveAuth(entry, model.id); entry.authFailed = false; entry.error = undefined; changed(entry);
},
async disconnect(providerId) {
await ready; const entry = entryFor(providerId); cancel(providerId);
if (entry.fatal) throw failure("storage");
entry.blocked = true; entry.epoch++; changed(entry);
await entry.models.logout(providerId); entry.blocked = false; entry.authFailed = false; entry.error = undefined; changed(entry);
},
pause() { paused = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
resume() { if (closed) return; paused = false; for (const entry of entries.values()) changed(entry); },
close() { closed = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
};
}
module.exports = { createSubscriptions, loadSubscriptionSDK, publicError, validEndpoint, LABELS };
+24 -4
View File
@@ -17,7 +17,8 @@ const nextConfig: NextConfig = {
// into the DMG (the packaged app never reads it: the shell passes
// LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES
// to package any standalone still containing it. .env* belt-and-braces
// the same class (not observed swept, same seam).
// the same class. Next also copies loaded bare .env after tracing;
// the build-server refusal remains required even with these excludes.
//
// ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE
// (empirically established 2026-10-08): adding dir excludes like
@@ -26,8 +27,11 @@ const nextConfig: NextConfig = {
// under their dist/ — the traced copy collapsed to a bare package.json
// and the packaged server died on ERR_MODULE_NOT_FOUND; openai/
// @anthropic-ai — pi-ai's deps — vanished from the trace entirely).
// Anchored "./"-prefixed forms behave the same. Only the three entries
// below are verified safe (complete externals trace + clean payload).
// Anchored "./"-prefixed forms behave the same. **/data/**/* fixes
// suffix matching in Next's JavaScript collector, but NOT its native
// Turbopack tracer: that still prunes gcp-metadata and its private deps.
// Keep the narrow closure workaround below AND the native runtime gate.
// Real SDK data/ directories still need their explicit includes too.
// The recursive-DMG payload (repo dist/ swept into the bundle, shipping
// the previous build inside the next) is instead prevented by the
// desktop:dist clean step AND targeted post-trace curation in
@@ -37,7 +41,7 @@ const nextConfig: NextConfig = {
// stay intact, and the script guards against leaked data/keys.
outputFileTracingExcludes: {
"*": [
"data/**/*",
"**/data/**/*",
"auth.json",
".env*",
],
@@ -55,6 +59,9 @@ const nextConfig: NextConfig = {
// caught it; unit tests run unbundled and never see it.
outputFileTracingIncludes: {
"*": [
// The standalone trace omitted this entire runtime-imported directory
// in 0.3.0, so importing puppeteer-core failed before browser startup.
"node_modules/@puppeteer/browsers/lib/browser-data/**/*.js",
"node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*",
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
@@ -67,7 +74,20 @@ const nextConfig: NextConfig = {
// per-platform-binary story as the canvas above (one per build).
"node_modules/@resvg/resvg-js-*/**/*",
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
// Public native OAuth factories load these flows through variable imports.
// Electron main uses the same standalone SDK; the offline runtime gate
// evaluates their real toAuth paths without login or provider requests.
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
"node_modules/@earendil-works/pi-ai/dist/utils/sleep.js",
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
// Next 16.3.2 native Turbopack applies unanchored excludes while
// traversing modules; **/data/**/* still prunes gcp-metadata and
// its private runtime chain. Includes are applied after traversal.
// This is the verified GoogleAuth closure, not a general dep copy;
// the offline native Pi gate must catch future SDK closure changes.
"node_modules/gcp-metadata/**/*",
"node_modules/json-bigint/**/*",
"node_modules/bignumber.js/**/*",
],
},
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
+144 -134
View File
@@ -1,16 +1,16 @@
{
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.7",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
"@earendil-works/pi-ai": "1.0.2",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
@@ -31,7 +31,8 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
@@ -145,16 +146,16 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"version": "3.978.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",
"integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/types": "^3.975.0",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/core": "^3.36.1",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@@ -164,14 +165,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",
"integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -180,14 +181,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",
"integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -198,21 +199,21 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"version": "3.973.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",
"integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/credential-provider-env": "^3.972.76",
"@aws-sdk/credential-provider-http": "^3.972.78",
"@aws-sdk/credential-provider-login": "^3.972.83",
"@aws-sdk/credential-provider-process": "^3.972.76",
"@aws-sdk/credential-provider-sso": "^3.973.20",
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -222,15 +223,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"version": "3.972.83",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",
"integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -239,19 +240,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"version": "3.972.88",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",
"integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/credential-provider-env": "^3.972.76",
"@aws-sdk/credential-provider-http": "^3.972.78",
"@aws-sdk/credential-provider-ini": "^3.973.21",
"@aws-sdk/credential-provider-process": "^3.972.76",
"@aws-sdk/credential-provider-sso": "^3.973.20",
"@aws-sdk/credential-provider-web-identity": "^3.972.82",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
@@ -261,14 +262,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",
"integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -277,16 +278,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"version": "3.973.20",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",
"integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/token-providers": "3.1155.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -295,15 +296,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"version": "3.1155.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",
"integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -312,15 +313,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"version": "3.972.82",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",
"integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/nested-clients": "^3.997.50",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -329,13 +330,13 @@
}
},
"node_modules/@aws-sdk/eventstream-handler-node": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz",
"integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==",
"version": "3.972.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.38.tgz",
"integrity": "sha512-K7vPKNstDlavHFZgbmcFdJOFIyowsevjddycRziRBbtXH/D9z+i9juCnH17liwSX9T8fJiDQzlEYkRtxkR+1vA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -344,13 +345,13 @@
}
},
"node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz",
"integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==",
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.33.tgz",
"integrity": "sha512-8QUt39D/dk5tdV7rLvIfMMI/ilQff4zth7mkMKpVOLM+M5AQ9W3nPxFVuHQANevmVWi4T5MlxqfKV4Q2drYeBA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -359,16 +360,16 @@
}
},
"node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz",
"integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==",
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.58.tgz",
"integrity": "sha512-/BvocdloKozx/zFr4benH+9J1ywB/2dYFHPWNUdo0CBCe00gTm0HPflOq8MH6cuCTbnywSrscGq0/LheN59teQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -377,15 +378,15 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"version": "3.997.50",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",
"integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@aws-sdk/core": "^3.978.5",
"@aws-sdk/signature-v4-multi-region": "^3.996.49",
"@aws-sdk/types": "^3.975.0",
"@smithy/core": "^3.36.1",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
@@ -396,13 +397,13 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"version": "3.996.49",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",
"integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@aws-sdk/types": "^3.975.0",
"@smithy/signature-v4": "^5.7.4",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
@@ -428,9 +429,9 @@
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"version": "3.975.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",
"integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -463,9 +464,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -484,12 +485,12 @@
"license": "Apache-2.0"
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.2.tgz",
"integrity": "sha512-VRfewY1R5mbedJzlm01PxdCmqU8QrSXjXXhV50bp6rvVuT8YYQv1inTXRY4YzssmGAYpkC2rXipJKP5BpBFXew==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz",
"integrity": "sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^1.0.2",
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
},
"engines": {
@@ -497,14 +498,14 @@
}
},
"node_modules/@earendil-works/pi-ai": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.2.tgz",
"integrity": "sha512-JP59xGlSAQ/HhQ6EHN5qmneBlWfDyu6FPPryAvAaSo8A38ubAsEpS9YisCM5Jbbns0srrDffLZonE0OXorHI5A==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz",
"integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^1.0.2",
"@earendil-works/pi-telemetry": "1.4.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@@ -558,9 +559,9 @@
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.2.tgz",
"integrity": "sha512-Ev5h9TE8nEXHCfRKhWj0qpUFhQgVqNteS95vsM2KNGRGm533vOO2ruDPaEwImBKPMBKXy4omz3D6V2cm41gLOQ==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz",
"integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
@@ -4727,9 +4728,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
"version": "3.36.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",
"integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
@@ -11655,6 +11656,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -11775,9 +11785,9 @@
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz",
"integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.7",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
@@ -21,11 +21,11 @@
"typecheck": "tsc --noEmit",
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
"dev:fresh": "bash scripts/dev-fresh.sh",
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
},
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
"@earendil-works/pi-ai": "1.0.2",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@mozilla/readability": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2",
@@ -46,7 +46,8 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
"katex": "^0.16.47",
"jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
"media-chrome": "^4.19.2",
@@ -70,6 +71,9 @@
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3"
},
"overrides": {
"katex": "$katex"
},
"devDependencies": {
"@next/eslint-plugin-next": "^16.3.3",
"@tailwindcss/postcss": "^4.3.3",
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse, type NextRequest } from "next/server";
import { desktopAPIAuthorized } from "./src/server/desktop-access.ts";
/** Applies to every API method, including SSE and spending/settings routes.
* No public callback or worker bypass: internal jobs call server functions,
* and any legitimate self-HTTP request must carry the private capability. */
export function proxy(request: NextRequest) {
if (!desktopAPIAuthorized(request.headers)) {
return NextResponse.json(
{ error: "Desktop API access requires the trusted LearnHarness window." },
{ status: 403, headers: { "Cache-Control": "no-store" } },
);
}
return NextResponse.next();
}
export const config = { matcher: "/api/:path*" };
+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="ChatGPT logo &#226;&#128;&#148; original vector">
<path id="Vector" d="M11.8102 19.1625C11.2327 19.1625 10.6841 19.0528 10.1643 18.8334C9.64459 18.6139 9.18259 18.3078 8.77834 17.9151C8.33944 18.0653 7.88322 18.1404 7.40967 18.1404C6.63582 18.1404 5.91972 17.9498 5.26137 17.5686C4.60302 17.1875 4.07172 16.6677 3.66747 16.0094C3.27477 15.351 3.07842 14.6176 3.07842 13.8091C3.07842 13.4742 3.12462 13.1103 3.21702 12.7176C2.75502 12.2903 2.39697 11.7994 2.14287 11.245C1.88877 10.679 1.76172 10.09 1.76172 9.47785C1.76172 8.85415 1.89454 8.25355 2.16019 7.67605C2.42584 7.09855 2.79544 6.6019 3.26899 6.1861C3.75409 5.75875 4.31427 5.46422 4.94952 5.30252C5.07657 4.64417 5.34222 4.05512 5.74647 3.53537C6.16227 3.00407 6.67047 2.58827 7.27107 2.28797C7.87167 1.98767 8.51269 1.83752 9.19414 1.83752C9.77164 1.83752 10.3203 1.94725 10.84 2.1667C11.3598 2.38615 11.8218 2.69222 12.226 3.08492C12.6649 2.93477 13.1211 2.8597 13.5947 2.8597C14.3685 2.8597 15.0846 3.05027 15.743 3.43142C16.4013 3.81257 16.9269 4.33232 17.3196 4.99067C17.7238 5.64902 17.9259 6.38245 17.9259 7.19095C17.9259 7.5259 17.8797 7.88973 17.7873 8.28242C18.2493 8.70977 18.6074 9.20643 18.8615 9.77238C19.1156 10.3268 19.2426 10.91 19.2426 11.5222C19.2426 12.1459 19.1098 12.7465 18.8442 13.324C18.5785 13.9015 18.2031 14.4039 17.718 14.8313C17.2445 15.2471 16.6901 15.5358 16.0548 15.6975C15.9278 16.3559 15.6564 16.9449 15.2406 17.4647C14.8363 17.996 14.3339 18.4118 13.7333 18.7121C13.1327 19.0124 12.4917 19.1625 11.8102 19.1625ZM7.53094 16.9969C8.10844 16.9969 8.61087 16.8756 9.03822 16.6331L12.2953 14.762C12.4108 14.6811 12.4686 14.5714 12.4686 14.4328V12.9429L8.27592 15.351C8.02182 15.5012 7.76772 15.5012 7.51362 15.351L4.23919 13.4626C4.23919 13.4973 4.23342 13.5377 4.22187 13.5839C4.22187 13.6301 4.22187 13.6994 4.22187 13.7918C4.22187 14.3808 4.36047 14.9237 4.63767 15.4203C4.92642 15.9054 5.32489 16.2866 5.83309 16.5638C6.34129 16.8525 6.90724 16.9969 7.53094 16.9969ZM7.70419 14.1729C7.77349 14.2076 7.83702 14.2249 7.89477 14.2249C7.95252 14.2249 8.01027 14.2076 8.06802 14.1729L9.36739 13.4279L5.19207 11.0024C4.93797 10.8523 4.81092 10.6271 4.81092 10.3268V6.56725C4.23342 6.82135 3.77142 7.21405 3.42492 7.74535C3.07842 8.2651 2.90517 8.8426 2.90517 9.47785C2.90517 10.0438 3.04954 10.5866 3.33829 11.1064C3.62704 11.6261 4.00242 12.0189 4.46442 12.2845L7.70419 14.1729ZM11.8102 18.0191C12.4224 18.0191 12.9768 17.8805 13.4734 17.6033C13.9701 17.3261 14.3628 16.9449 14.6515 16.4598C14.9403 15.9747 15.0846 15.4319 15.0846 14.8313V11.0891C15.0846 10.9505 15.0269 10.8465 14.9114 10.7772L13.5947 10.0149V14.8486C13.5947 15.1489 13.4676 15.3741 13.2135 15.5243L9.93912 17.4127C10.5051 17.817 11.1288 18.0191 11.8102 18.0191ZM12.4686 11.6781V9.32192L10.5108 8.21312L8.53579 9.32192V11.6781L10.5108 12.7869L12.4686 11.6781ZM7.40967 6.15145C7.40967 5.85115 7.53672 5.62592 7.79082 5.47577L11.0652 3.58735C10.4993 3.1831 9.87559 2.98097 9.19414 2.98097C8.58199 2.98097 8.02759 3.11957 7.53094 3.39677C7.03429 3.67397 6.64159 4.05512 6.35284 4.54022C6.07564 5.02532 5.93704 5.56817 5.93704 6.16878V9.89365C5.93704 10.0323 5.99479 10.142 6.11029 10.2228L7.40967 10.9851V6.15145ZM16.2108 14.4328C16.7883 14.1787 17.2445 13.786 17.5794 13.2547C17.9259 12.7234 18.0992 12.1459 18.0992 11.5222C18.0992 10.9562 17.9548 10.4134 17.6661 9.89365C17.3773 9.3739 17.0019 8.9812 16.5399 8.71555L13.3002 6.84445C13.2309 6.79825 13.1673 6.78092 13.1096 6.79248C13.0518 6.79248 12.9941 6.8098 12.9363 6.84445L11.637 7.5721L15.8296 10.0149C15.9567 10.0842 16.0491 10.1766 16.1068 10.2921C16.1761 10.3961 16.2108 10.5231 16.2108 10.6733V14.4328ZM12.7284 5.6317C12.9825 5.47 13.2366 5.47 13.4907 5.6317L16.7825 7.55477C16.7825 7.47393 16.7825 7.36997 16.7825 7.24292C16.7825 6.68852 16.6439 6.163 16.3667 5.66635C16.101 5.15815 15.7141 4.7539 15.2059 4.4536C14.7093 4.1533 14.1318 4.00315 13.4734 4.00315C12.8959 4.00315 12.3935 4.12442 11.9661 4.36697L8.70904 6.23808C8.59354 6.31892 8.53579 6.42865 8.53579 6.56725V8.0572L12.7284 5.6317Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+234
View File
@@ -0,0 +1,234 @@
/**
* Import the demo dependency graph from ONLY a standalone payload. With --browser,
* also drive the packaged app's own --browser-service (never system Chrome).
* No server, model, keys, installed app UI, or production diagnostic route needed.
*
* node scripts/smoke-demo-runtime.mjs --standalone <dir> --runtime <electron binary> [--browser]
* --runtime defaults to host Node for the import-only gate.
*/
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire, registerHooks } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/** @param {Record<string, string | undefined>} env @param {string} home @returns {Record<string, string | undefined>} */
export function probeEnv(env, home) {
const clean = Object.fromEntries(Object.entries(env).filter(([key]) =>
!/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key) &&
!/^(AWS_|GOOGLE_|GCLOUD_|CLOUDSDK_|AZURE_|ANTHROPIC_|OPENAI_|OPENROUTER_|GEMINI_)/.test(key) &&
!["NODE_OPTIONS", "NODE_PATH", "LH_DEV_URL", "ELECTRON_RUN_AS_NODE"].includes(key)));
return {
...clean,
HOME: home, USERPROFILE: home,
APPDATA: path.join(home, "config"), LOCALAPPDATA: path.join(home, "local"),
XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache"),
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
LH_IGNORE_ENV_KEYS: "1", NODE_ENV: "production",
};
}
// Keep fetch's nested cause (including AggregateError socket failures), but
// never dump arbitrary error properties such as request headers or environment.
export function formatProbeError(error, depth = 0) {
if (depth > 5) return "[cause chain truncated]";
if (!error || typeof error !== "object") return String(error);
const lines = [typeof error.stack === "string" ? error.stack : `${error.name ?? "Error"}: ${error.message ?? "unknown error"}`];
for (const field of ["code", "errno", "syscall", "address", "port"]) {
if (["string", "number"].includes(typeof error[field])) lines.push(`${field}: ${error[field]}`);
}
if (error.cause !== undefined) lines.push(`cause: ${formatProbeError(error.cause, depth + 1)}`);
if (Array.isArray(error.errors)) {
for (const nested of error.errors.slice(0, 8)) lines.push(`aggregate: ${formatProbeError(nested, depth + 1)}`);
}
return lines.join("\n");
}
export function verifyDemoRuntime(standalone, runtime = process.execPath, browser = false) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-runtime-"));
try {
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone), ...(browser ? ["--browser"] : [])], {
cwd: home,
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
encoding: "utf8", timeout: 60_000, maxBuffer: 128 * 1024,
});
if (result.status !== 0 || result.error) {
throw new Error(`demo runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
}
process.stdout.write(result.stdout);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
}
async function importPayload(standalone) {
const root = fs.realpathSync(standalone);
const inside = (file) => {
const relative = path.relative(root, fs.realpathSync(file));
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
`dependency escaped standalone payload: ${file}`);
};
const require = createRequire(path.join(root, "server.js"));
const entry = require.resolve("puppeteer-core");
inside(entry); // require.resolve must not silently fall back to checkout node_modules.
const loaded = new Set();
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith("file:")) {
inside(fileURLToPath(result.url));
loaded.add(result.url);
} else {
assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
}
return result;
},
});
try {
const { default: puppeteer } = await import(pathToFileURL(entry).href);
assert.equal(typeof puppeteer.connect, "function");
console.log(`[smoke-demo-runtime] puppeteer-core import ok; ${loaded.size} modules confined to ${root}`);
return { puppeteer, hooks };
} catch (error) {
hooks.deregister();
throw error;
}
}
// Explicit IPC disconnect can omit ChildProcess 'close' even after Electron
// exits. Wait for actual exit, boundedly; never infer shutdown intent from
// Node's automatic stdin cleanup after an unexpected child exit.
/** @returns {Promise<{code: number | null, signal: NodeJS.Signals | null, requested: boolean}>} */
export async function stopBrowserService(helper) {
if (!helper.pid || helper.exitCode !== null || helper.signalCode !== null) {
return { code: helper.exitCode, signal: helper.signalCode, requested: false };
}
let onExit, onError;
const exited = new Promise((resolve, reject) => {
onExit = (code, signal) => resolve({ code, signal });
onError = reject;
helper.once("exit", onExit);
helper.once("error", onError);
});
const wait = async (ms) => {
let timer;
try {
return await Promise.race([exited, new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); })]);
} finally { clearTimeout(timer); }
};
try {
const requested = helper.connected;
if (requested) {
console.log("[smoke-demo-runtime] requesting parent IPC disconnect");
helper.disconnect();
}
const result = await wait(5000);
if (!result) {
helper.kill("SIGKILL");
if (!await wait(3000)) throw new Error("browser-service did not exit after forced cleanup");
throw new Error("browser-service did not exit after IPC disconnect; force-killed");
}
return { ...result, requested };
} finally {
helper.removeListener("exit", onExit);
helper.removeListener("error", onError);
}
}
async function browserProbe(puppeteer) {
assert.ok(process.versions.electron, "--browser requires a packaged Electron --runtime binary");
const home = process.env.HOME;
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
delete env.LH_DEV_URL;
const helper = spawn(process.execPath, [
"--browser-service", `--user-data-dir=${path.join(home, "browser-profile")}`,
"--disable-background-networking", "--disable-component-update", "--no-proxy-server",
// Hosted Linux runners disallow Chromium's unprivileged sandbox.
...(process.platform === "linux" ? ["--no-sandbox"] : []),
], { cwd: home, env, stdio: ["ignore", "pipe", "pipe", "ipc"] });
let output = "";
let errors = "";
helper.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-16_000); });
let browser;
let failed = false;
try {
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("browser-service handshake timed out")), 15_000);
helper.once("error", (err) => { clearTimeout(timer); reject(err); });
helper.once("exit", (code) => { clearTimeout(timer); reject(new Error(`browser-service exited early: ${code}`)); });
helper.stdout.on("data", (chunk) => {
output = (output + chunk).slice(-16_000);
const match = output.match(/^BROWSER_SERVICE_CDP (\d+)\r?$/m);
if (match) { clearTimeout(timer); resolve(Number(match[1])); }
});
});
const info = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
browser = await puppeteer.connect({ browserWSEndpoint: info.webSocketDebuggerUrl, defaultViewport: null, protocolTimeout: 10_000 });
// Electron CDP has no Target.createTarget: use the helper's existing page.
const pages = await browser.pages();
const page = pages.find((candidate) => candidate.target().type() === "page");
assert.ok(page, "browser-service has no page target");
const failures = [];
page.on("console", (msg) => { if (msg.type() === "error") failures.push(msg.text()); });
page.on("pageerror", (err) => failures.push(String(err)));
page.on("requestfailed", (req) => failures.push(`failed request: ${req.url()}`));
await page.setRequestInterception(true);
page.on("request", (req) => {
if (/^(file:|about:|data:)/.test(req.url())) void req.continue();
else { failures.push(`unexpected network request: ${req.url()}`); void req.abort(); }
});
const html = path.join(home, "demo.html");
fs.writeFileSync(html, '<!doctype html><meta charset="utf-8"><button id="increment">Increment</button><output id="count">0</output><script>document.querySelector("#increment").onclick = () => { document.querySelector("#count").textContent = "1"; };</script>');
await page.goto(pathToFileURL(html).href, { waitUntil: "load", timeout: 10_000 });
assert.equal(await page.$eval("#count", (element) => element.textContent), "0");
await page.click("#increment");
assert.equal(await page.$eval("#count", (element) => element.textContent), "1");
await page.goto("about:blank");
assert.equal(await page.$("#increment"), null, "page state was not cleared");
assert.deepEqual(failures, [], "demo console/page/network errors");
console.log("[smoke-demo-runtime] packaged browser-service: JS button 0 → 1, clean console/page, page reset ok (no Chrome fallback)");
} catch (err) {
failed = true;
throw new Error(`browser-service probe failed at local CDP/browser interaction\n--- browser-service output ---\n${output}\n${errors}`, { cause: err });
} finally {
browser?.disconnect();
try {
const { code, signal, requested } = await stopBrowserService(helper);
if (requested) console.log(`[smoke-demo-runtime] observed helper exit after requested IPC disconnect: code=${code}, signal=${signal} (no resident helper)`);
else console.log(`[smoke-demo-runtime] helper already exited/disconnected before cleanup: code=${code}, signal=${signal}; no shutdown requested`);
if (!failed) {
assert.ok(requested, "browser-service exited/disconnected before requested cleanup");
assert.deepEqual({ code, signal }, { code: 0, signal: null }, "browser-service shutdown failed");
}
} catch (error) {
if (!failed) throw error;
console.error(`[smoke-demo-runtime] cleanup also failed: ${formatProbeError(error)}`);
}
}
}
async function main() {
const args = process.argv.slice(2);
if (args[0] === "--probe") {
const { puppeteer, hooks } = await importPayload(args[1]);
try {
if (args.includes("--browser")) await browserProbe(puppeteer);
} finally { hooks.deregister(); }
return;
}
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]);
};
verifyDemoRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath), args.includes("--browser"));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err) => { console.error(`[smoke-demo-runtime] FAIL: ${formatProbeError(err)}`); process.exitCode = 1; });
}
+153
View File
@@ -0,0 +1,153 @@
/**
* 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.
* All file modules must resolve inside the payload, including nested dependencies.
*
* node scripts/smoke-provider-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire, registerHooks, syncBuiltinESMExports } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath, pathToFileURL } from "node:url";
import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
export function verifyProviderRuntime(standalone, runtime = process.execPath) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-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, maxBuffer: 128 * 1024,
});
if (result.status !== 0 || result.error) {
throw new Error(`provider runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
}
assert.match(result.stdout, /native Pi Google initialization passed/, "probe exited without success marker");
process.stdout.write(result.stdout);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
}
// Dedicated child only: nothing delegates to a live transport, even if the SDK
// changes its fetch path. Count attempts as well: a swallowed guard error fails.
function closeNetwork() {
let attempts = 0;
const deny = () => { attempts++; throw new Error("OFFLINE provider gate: network forbidden"); };
globalThis.fetch = deny;
globalThis.WebSocket = class { constructor() { deny(); } };
const require = createRequire(import.meta.url);
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;
syncBuiltinESMExports();
return () => assert.equal(attempts, 0, "SDK attempted network access");
}
async function probe(standalone) {
const noNetwork = closeNetwork();
const root = fs.realpathSync(standalone);
const inside = (file) => {
const relative = path.relative(root, fs.realpathSync(file));
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
`dependency escaped standalone payload: ${file}`);
};
const loaded = new Set();
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith("file:")) {
inside(fileURLToPath(result.url));
loaded.add(fileURLToPath(result.url));
} else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
return result;
},
});
try {
// Pi is an explicit server external. Import its shipped public entry and
// 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);
const models = createModels();
for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) {
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 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.ok(payload && typeof payload === "object");
reached++;
throw new Error(sentinel);
},
}).result();
assert.equal(reached, 1, `${name}: did not reach onPayload: ${result.errorMessage}`);
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`);
}
// Exercise the public lazy OAuth surface offline. toAuth evaluates the
// real flow module and its imports but neither logs in nor refreshes.
for (const [name, factory] of [["github-copilot", "githubCopilotProvider"], ["xai", "xaiProvider"]]) {
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
const credential = { type: "oauth", access: name === "github-copilot" ? "tid=offline;proxy-ep=proxy.individual.githubcopilot.com;" : "offline-xai-oauth-not-a-token", refresh: "offline-refresh-not-a-token", expires: Date.now() + 3600000, availableModelIds: provider.getModels().map((model) => model.id) };
const auth = await provider.auth.oauth.toAuth(credential);
assert.equal(auth.apiKey, credential.access);
if (name === "github-copilot") assert.equal(auth.baseUrl, "https://api.individual.githubcopilot.com");
const seenApis = new Set();
for (const model of provider.getModels()) {
if (seenApis.has(model.api)) continue;
seenApis.add(model.api);
const sentinel = `LH_OFFLINE_SUBSCRIPTION_${randomUUID()}`;
let reached = 0;
const result = await provider.streamSimple({ ...model, ...(auth.baseUrl ? { baseUrl: auth.baseUrl } : {}) }, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
...auth, maxRetries: 0, onPayload() { reached++; throw new Error(sentinel); },
}).result();
assert.equal(reached, 1, `${name}/${model.api}: ${result.errorMessage}`);
assert.equal(result.errorMessage, sentinel);
}
noNetwork();
console.log(`[smoke-provider-runtime] ${name}: public native OAuth and ${seenApis.size} transport APIs initialized offline`);
}
// Resolution/evaluation, not folder-exists checks. This also covers the
// previously missing transitive edge and permits legitimate nested deps.
for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) {
assert.ok([...loaded].some((file) => file.includes(`${path.sep}node_modules${path.sep}${name.replaceAll("/", path.sep)}${path.sep}`)),
`Google runtime chain was not loaded: ${name}`);
}
console.log(`[smoke-provider-runtime] native Pi Google initialization passed; ${loaded.size} modules confined to ${root}; zero network attempts`);
} finally { hooks.deregister(); }
}
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]);
};
verifyProviderRuntime(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-provider-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
}
+8 -1
View File
@@ -25,6 +25,8 @@
* way, same day).
*/
import { spawn } from "node:child_process";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
import { createRequire } from "node:module";
import * as fs from "node:fs";
import * as http from "node:http";
@@ -76,6 +78,11 @@ function get(url) {
}
async function main() {
// HTTP boot alone never imports the dynamically loaded demo engine.
// Fail before packaging if its standalone dependency graph is incomplete.
verifyDemoRuntime(STANDALONE, RUNTIME);
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
verifyProviderRuntime(STANDALONE, RUNTIME);
const port = await freePort();
const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
@@ -104,7 +111,7 @@ async function main() {
const child = spawn(RUNTIME, [SERVER], {
cwd: STANDALONE,
env: {
...process.env,
...probeEnv(process.env, home),
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production",
PORT: String(port),
-121
View File
@@ -1,121 +0,0 @@
/**
* Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
* catalogs (providers/data/*.json).
*
* Usage: npx tsx scripts/sync-pricing.ts
*
* The catalogs are pi-ai's curated, versioned snapshots — the SAME source
* piEngineFor resolves engines from and /api/models serves the picker
* from — so the money channel's lookup table can never disagree with the
* engine. Fully offline (the JSON ships in node_modules); refreshes happen
* by bumping @earendil-works/pi-ai and re-running this script. The tables
* are generated; hand edits are overwritten. A model missing from the
* catalogs gets no entry — cost renders as absent ("—"), never zero.
*
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
* compat — records stamped with pre-wave bare slugs simply render unpriced.
*/
import * as fs from "node:fs";
import * as path from "node:path";
const DATA_DIR = path.join(
import.meta.dirname,
"..",
"node_modules",
"@earendil-works",
"pi-ai",
"dist",
"providers",
"data",
);
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
/** One raw catalog entry (fields we consume; the files carry more). */
interface CatalogModel {
id: string;
name: string;
/** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */
type?: "chat" | "image" | "classifier";
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
/** The pure walk: every catalog entry that carries pricing, keyed
* "provider:id". Exported for the drift-guard test (tests/prices-sync)
* so the guard asserts EXACTLY what this script would generate — the
* test and the generator can never disagree (owner, 2026-09-29). */
export function buildPrices(): Map<string, CatalogModel["cost"]> {
const priced = new Map<string, CatalogModel["cost"]>();
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
const provider = file.slice(0, -".json".length);
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
string,
Record<string, CatalogModel>
>;
for (const models of Object.values(catalog)) {
for (const [id, m] of Object.entries(models)) {
// pi-ai 1.0 (catalog schema v6): keys are type-qualified
// ("chat:<id>", "image:<id>", "classifier:<id>") and entries
// carry their own `type`. We price CHAT models only — our
// usage records are chat — and keys stay "provider:id"
// (owner format, 2026-09-20): strip the chat: prefix, skip the
// rest.
if (m.type && m.type !== "chat") continue;
const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id;
// A catalog entry without pricing data gets no key at all
// (cost renders "—", never zero).
if (!m.cost) continue;
// First writer wins (an id can appear under two apis within one
// file with identical pricing; cross-file collisions cannot
// happen — keys are provider-qualified).
priced.set(`${provider}:${plainId}`, m.cost);
}
}
}
return priced;
}
function main(): void {
const priced = new Map<string, { cost: CatalogModel["cost"] }>(
[...buildPrices()].map(([k, cost]) => [k, { cost }] as const),
);
// Sort by key for a stable, diff-friendly generated table.
const rows = [...priced.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, m]) => ({
price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
}));
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
* as absent ("—"), never zero. Keys are uniformly provider-qualified
* ("provider:id") — NO legacy bare-slug compat (owner decision, sync
* script 2026-09-20): pre-wave records stamped with bare slugs render
* unpriced.
* Context-window limits are NOT a table (owner amendment, 2026-09-23):
* every meter's denominator reads pi's LIVE catalog (GET /api/models /
* the activity feed's server-side join) — missing model, usage-only.
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
* (reads pi-ai's shipped catalogs — the same source the engine and the
* picker use). Forgetting the re-run is exactly how models go unpriced
* (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test
* (tests/prices-sync.test.ts) fails the gates until you do. */
export const PRICES: Record<string, ModelPrice> = {
${rows.map((r) => r.price).join("\n")}
};
`;
const src = fs.readFileSync(OUT, "utf-8");
const start = src.indexOf("/** Token prices");
const end = src.indexOf("// ─── Pure helpers");
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
}
// Run only as a script, not when imported (the drift-guard test).
if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) {
main();
}
+128
View File
@@ -0,0 +1,128 @@
import type { Model } from "@earendil-works/pi-ai";
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";
export interface Stamp { epoch: number; authorityRevision: number; revision: number }
interface StatusValue extends Stamp { status: ChatGPTStatus }
interface ModelsValue extends StatusValue { accountId: string | null; models: { id: string; name: string }[] }
interface LeaseValue extends Stamp { accessToken: string; accountId: string }
interface Pending { op: string; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
interface BridgeState {
status: ChatGPTStatus; stamp: Stamp; models: Model<"openai-responses">[]; catalogAccount: string | null;
pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number;
}
const key = Symbol.for("lh.chatgpt.bridge");
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
/** Next dev also uses parent/child IPC. IPC alone is not our desktop
* transport: require the same launch markers set by Electron startServer.
* These identify the transport, not a substitute for admission/lease checks. */
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 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 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.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); }
if (m.invalidate === "all" || m.invalidate === "background") {
for (const [controller, background] of state.controllers) if (m.invalidate === "all" || background) controller.abort(m.invalidate === "all" ? "all" : "background");
}
for (const listener of state.listeners) listener();
}
if (m.id) {
const call = state.pending.get(m.id);
if (!call || call.op !== m.op) return;
clearTimeout(call.timer); state.pending.delete(m.id);
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
});
process.on("disconnect", () => {
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.")); }
state.pending.clear();
for (const listener of state.listeners) listener();
});
}
return global[key];
}
function clearCatalog(state: BridgeState) { state.models = []; state.catalogAccount = null; }
function acceptStatus(state: BridgeState, value: StatusValue) {
if (!validStamp(value) || value.revision < state.stamp.revision) return;
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. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
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<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
state.pending.set(id, { op, resolve: (v) => resolve(v as T), reject, timer });
process.send!({ ...fields, channel: "lh:chatgpt", id, op }, (error) => {
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The ChatGPT desktop connection closed.")); }
});
});
}
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 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; }
export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
export function chatGPTScope(selectedModelId: string): AdmissionScope {
const state = bridge();
if (!chatGPTConnected() || !state.catalogAccount || state.catalogAccount !== state.status.activeId || !chatGPTModel(selectedModelId)) throw new Error("ChatGPT account model catalog is unavailable");
return { accountId: state.catalogAccount, epoch: state.stamp.epoch, authorityRevision: state.stamp.authorityRevision, selectedModelId };
}
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);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
const value = await chatGPTRequest<LeaseValue>("lease", { background, expectedAccountId: scope.accountId, expectedAuthorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
if (!validStamp(value) || !chatGPTScopeCurrent(scope) || value.epoch !== scope.epoch || value.authorityRevision !== scope.authorityRevision || value.accountId !== scope.accountId || typeof value.accessToken !== "string" || !value.accessToken || value.accessToken.startsWith("sk-")) throw new Error("ChatGPT lease is stale");
return value;
}
/** Safe status confirmation can establish consent-only denial, never parse error text. */
export async function chatGPTDenial(scope: AdmissionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
if (callerAborted || controller.signal.reason === "all") return "all";
if (!scope || !chatGPTScopeCurrent(scope)) return "other";
if (controller.signal.reason === "background") return "background";
if (!background) return "other";
try { const value = await chatGPTRequest<StatusValue>("status"); acceptStatus(bridge(), value); }
catch { return "other"; }
if (controller.signal.reason === "all" || !chatGPTScopeCurrent(scope)) return "all";
return !chatGPTBackgroundAllowed() ? "background" : "other";
}
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
const state = bridge();
if (!desktopIPCConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
if (sequence !== state.catalogRequest || !validStamp(value)) return;
acceptStatus(state, value);
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.accountId !== state.status.activeId || !chatGPTConnected()) return;
state.catalogAccount = value.accountId;
state.models = value.models.flatMap((entry) => {
const known = supported.find((m) => m.id === entry.id && m.api === "openai-responses");
return known ? [{ ...known, provider: "chatgpt", name: entry.name, headers: undefined, samplingParams: undefined, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [];
});
} catch { if (sequence === state.catalogRequest) clearCatalog(state); }
}
export function chatGPTAbortController(background = true): { controller: AbortController; release: () => void } {
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, background);
return { controller, release: () => { state.controllers.delete(controller); } };
}
+4
View File
@@ -0,0 +1,4 @@
/** Compatibility surface: ChatGPT keeps its scope/behavior while all subscription
* connections share the same provider-neutral request-admission state machine. */
export { SubscriptionRequestCycle as ChatGPTRequestCycle } from "./subscription-request-cycle.ts";
export type { AdmissionScope, NoDispatchKind, SubscriptionDispatchLifecycle as ChatGPTDispatchLifecycle } from "./subscription-request-cycle.ts";
+118
View File
@@ -0,0 +1,118 @@
import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai";
import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts";
import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
const USAGE = "https://chatgpt.com/settings/usage";
export function chatGPTStreamError(message: string): string {
if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`;
if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.";
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.";
}
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
const payload = raw as Record<string, unknown>;
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
if (payload[key] !== undefined) out[key] = payload[key];
}
if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history");
out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item);
if (Array.isArray(payload.tools) && payload.tools.length) {
if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability");
out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }];
}
return out;
}
/** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */
export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<Api>): TranscriptContext {
// Apply Pi's normal foreign-model signature treatment BEFORE the alias
// becomes openai, or real API-key turns could look like subscription turns.
const messages = transformMessages(context.messages, logicalModel);
return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) };
}
function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream();
const purpose = background();
const { controller, release } = chatGPTAbortController(purpose);
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
void (async () => {
let partial = empty(logicalModel);
let completed = false;
let failure = "";
let scope: AdmissionScope | undefined;
let committed = false;
let terminal = false;
const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); };
try {
if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model");
if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden");
scope = chatGPTScope(logicalModel.id);
lifecycle?.captured(scope);
const credentials = await chatGPTLease(scope, purpose);
check();
// NEVER dispatch through Models under openai: that would expose ambient API-key fallback.
const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } };
const delegate = options?.fetch ?? globalThis.fetch;
const source = streamSimple(transport, chatGPTHistory(context, logicalModel), {
...options,
apiKey: credentials.accessToken,
signal,
maxRetries: 0,
headers: { "OpenAI-Beta": null, ...options?.headers },
fetch: (input, init) => {
check();
if (committed) throw new Error("ChatGPT does not retry admitted requests");
lifecycle?.beforeDispatch(scope!);
committed = true;
return delegate(input, init);
},
onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model);
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
check();
return payload;
},
onProviderStreamEvent: async (raw, model) => {
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
await options?.onProviderStreamEvent?.(raw, model);
},
});
for await (const event of source) {
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
// Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact.
partial = { ...message, provider: "chatgpt" };
if (event.type === "done") {
if (!completed || failure) throw new Error(failure || "missing terminal response.completed");
terminal = true;
output.push({ ...event, message: partial });
} else if (event.type === "error") {
throw new Error(failure || event.error.errorMessage || "ChatGPT stream error");
} else output.push({ ...event, partial });
}
} catch (err) {
if (terminal) return;
if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose));
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") };
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
} finally { release(); output.end(); }
})();
return output;
};
}
+22 -8
View File
@@ -11,6 +11,19 @@ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
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 { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
import { nativeSubscriptionStream } from "./subscription-stream.ts";
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
export const APP_IDENTITY = Object.freeze({
userAgent: Object.freeze({ custom: `learnharness/${packageMetadata.version}` }),
attribution: Object.freeze({ name: packageMetadata.name, url: packageMetadata.homepage }),
});
/**
* The one engine factory every agent's model comes from (the pi-ai swap,
@@ -47,7 +60,7 @@ let collection: ReturnType<typeof createModels> | undefined;
* keyPresent. */
export function piModels() {
if (!collection) {
collection = createModels();
collection = createModels({ identity: APP_IDENTITY });
for (const provider of builtinProviders()) collection.setProvider(provider);
}
return collection;
@@ -85,9 +98,10 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
* key is resolved by pi-ai itself from the environment per request (the
* keys seam reflects file-saved keys into env), so the engine never
* hardcodes OpenRouter's. */
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const model = models.getModel(spec.provider, spec.id);
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);
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -103,11 +117,11 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
// 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.
streamFn: (requestModel, context, options) =>
models.streamSimple(requestModel, context, {
...options,
reasoning: level === "off" ? undefined : level,
}),
streamFn: (requestModel, context, options) => {
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
},
};
}
+6 -6
View File
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
Three things this session is not:
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
1. **Target** — per the selection rules.
2. **Mode** — the cheapest undemonstrated layer for that target.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
@@ -101,7 +101,7 @@ Three structural rules:
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
## VIII. Progression
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
## IX. The arc
+63 -22
View File
@@ -742,6 +742,20 @@ export interface NotesImportInput {
text: string;
}
/** A notes import did not finish. Earlier graph writes and billed usage are
* retained; partialStats describe known merges, not an all-or-nothing result. */
export class NotesImportError extends Error {
constructor(
public readonly outcome: "failed" | "partial" | "aborted" | "busy",
public readonly partialStats: EmissionStats,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "NotesImportError";
}
}
export interface ExtractionPipeline {
/** Extract + audit + merge ONE chat. Single-flight per chat; resolves to
* the merger's append stats (or throws — no checkpoint on failure). */
@@ -763,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
@@ -1285,9 +1300,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
const { id } = input;
if (inFlight.has(id)) return { nodes: 0, chains: 0 };
inFlight.add(id);
// importNotes owns the source exclusion, including metadata and cleanup.
const t0 = performance.now();
const mergedStats: EmissionStats = { nodes: 0, chains: 0 };
try {
// BYOK per-need gate (owner, 2026-09-22) + key presence
// (2026-09-25): same refusal as the chat front door — no
@@ -1313,11 +1328,15 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const settled: PromiseSettledResult<EmissionStats>[] = [];
for (const [chunkIndex, chunk] of chunks.entries()) {
const part = chunks.length > 1 ? { i: chunkIndex + 1, n: chunks.length } : undefined;
// Keep the actual signal after runJob tears down its registry row:
// usage persistence can throw and mask the body's AbortError.
let chunkSignal: AbortSignal | undefined;
try {
settled.push({
status: "fulfilled",
value: await runJob({ id, kind: "notes", label: input.filePath, part }, async (jobKey) => {
setPhase(jobKey, "queued", "notes", input.filePath, part);
chunkSignal = runningExtractions.get(jobKey)?.controller.signal;
const findings = await withExtractSlot(jobKey, async () => {
setPhase(jobKey, "extracting", "notes", input.filePath, part);
return notesExtract(jobKey, { ...input, text: chunk, part });
@@ -1337,6 +1356,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
{ chatStore, graphStore },
{ foldedAt: input.importedAt },
);
// Capture the committed merge BEFORE layout/usage can fail.
mergedStats.nodes += merged.nodes;
mergedStats.chains! += merged.chains ?? 0;
if (merged.droppedChains)
mergedStats.droppedChains = (mergedStats.droppedChains ?? 0) + merged.droppedChains;
// The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk.
settleGraphLayout(graphStore);
@@ -1346,46 +1370,63 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
}),
});
} catch (err) {
settled.push({ status: "rejected", reason: err });
let failure = err;
if (chunkSignal?.aborted && !(err instanceof Error && err.name === "AbortError")) {
// Notes-only: never start a fresh chunk after user cancellation,
// even when usage cleanup replaced the original abort rejection.
// Keep the secondary failure visible in the final import error.
const aborted = new Error(`Notes import aborted; job cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
aborted.name = "AbortError";
failure = aborted;
}
settled.push({ status: "rejected", reason: failure });
// Same source-wide-abort law as the chat door: stop the
// run; isolated failures keep going.
if (err instanceof Error && err.name === "AbortError") break;
if (failure instanceof Error && failure.name === "AbortError") break;
}
}
const stats = aggregateJobs(id, settled);
// Completion is a chunk-status contract, never a node-count test.
// Zero findings (and an empty file after the readiness gate) are valid.
const rejected = settled.filter((r) => r.status === "rejected");
if (rejected.length > 0 || settled.length !== chunks.length) {
const aborted = rejected.some((r) => r.reason instanceof Error && r.reason.name === "AbortError");
const outcome = aborted ? "aborted" : settled.some((r) => r.status === "fulfilled") ? "partial" : "failed";
const details = rejected.map((r) => r.reason instanceof Error ? r.reason.message : String(r.reason)).join("; ");
throw new NotesImportError(outcome, mergedStats, details || "Notes import did not complete");
}
console.info(
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` +
`(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
);
return stats;
} catch (err) {
// Pre-fan-out failures only (stage gate, chunking): job-level
// failures and aborts are absorbed at the fan-out site, per job,
// with their usage records already persisted.
console.error(`[extraction ${id}] notes pipeline failed (${Math.round(performance.now() - t0)}ms):`, err);
throw err;
if (err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError) throw err;
throw new NotesImportError(err instanceof Error && err.name === "AbortError" ? "aborted" : "failed", mergedStats,
err instanceof Error ? err.message : String(err), { cause: err });
} finally {
inFlight.delete(id);
finishExtraction(id); // done or failed — no longer running
}
}
/** Import ONE raw notes file: register the source (deterministic id per
* file path — re-import keeps the same identity and its usage history)
* and run the pipeline over it. Re-import guard (owner, 2026-09-29):
* the file's content fingerprint decides — unchanged text is a TRUE
* no-op (the mappings ledger used to dedupe this at merge time by
* accident); changed text re-runs the whole file. */
/** Every explicit import processes the whole file. Identity, source digest,
* auditor and usage history remain source-scoped; this is not exactly-once. */
async function importNotes(input: NotesImportInput): Promise<EmissionStats> {
const id = `src_${crypto.createHash("sha256").update(input.filePath).digest("hex").slice(0, 12)}`;
const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`);
return { nodes: 0, chains: 0 };
// Acquire BEFORE source upsert: overlapping contents must not alter metadata.
if (inFlight.has(id)) throw new NotesImportError("busy", { nodes: 0, chains: 0 }, "This file is already being imported. Try again when it finishes.");
inFlight.add(id);
try {
const importedAt = new Date().toISOString();
graphStore.upsertSource({ id, filePath: input.filePath, importedAt });
return await runNotesExtraction({ id, ...input, importedAt });
} catch (err) {
if (err instanceof NotesImportError || err instanceof ModelsNotConfiguredError || err instanceof ProviderKeyMissingError) throw err;
throw new NotesImportError("failed", { nodes: 0, chains: 0 }, err instanceof Error ? err.message : String(err), { cause: err });
} finally {
inFlight.delete(id);
}
const importedAt = new Date().toISOString();
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp });
return runNotesExtraction({ id, ...input, importedAt });
}
return { runExtraction, importNotes };
+9 -5
View File
@@ -116,8 +116,9 @@ const NO_CHROME_MESSAGE =
// Demos execute JS and need a browser. PRIMARY: this very app's binary
// spawned with --browser-service (main.cjs boots a hidden window with a CDP
// port and prints "BROWSER_SERVICE_CDP <port>"). FALLBACK: system Chrome.
// The helper's stdin is held open by this process; server death = EOF = the
// helper quits — no orphans. PDFs and SVGs never come here (native now).
// A Node IPC channel owns its lifetime: parent death/disconnect quits the
// helper on every OS (Windows Electron stdin is synthetic EOF). No orphans.
// PDFs and SVGs never come here (native now).
/** Handshake line parser (pure; exported for tests). */
export function parseBrowserServiceLine(line: string): number | null {
@@ -136,6 +137,9 @@ export function browserServiceSpawnEnv(
const out: Record<string, string | undefined> = { ...env };
delete out.ELECTRON_RUN_AS_NODE;
delete out.LH_DEV_URL;
// The artifact renderer is not an authorized app API client.
delete out.LH_DESKTOP_API_SECRET;
delete out.LH_DESKTOP_SERVER;
return out;
}
@@ -180,7 +184,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
// Cast: the sanitizer is pure over a loose record; NODE_ENV is
// always present in a real process.env.
env: browserServiceSpawnEnv(process.env) as NodeJS.ProcessEnv,
stdio: ["pipe", "pipe", "pipe"] as const,
stdio: ["ignore", "pipe", "pipe", "ipc"] as const,
});
browserServiceProc = proc;
port = await new Promise<number | null>((resolve) => {
@@ -197,7 +201,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
resolve(parseBrowserServiceLine(line));
}
};
proc.stdout.on("data", onData);
proc.stdout!.on("data", onData);
proc.once("exit", () => {
clearTimeout(timer);
resolve(null);
@@ -215,7 +219,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
const info = (await version.json()) as { webSocketDebuggerUrl?: string };
if (!info.webSocketDebuggerUrl) return null;
// connect ≠ launch: browser.close() below merely disconnects — the
// helper stays resident for the next job (stdin-held lifetime).
// helper stays resident for the next job (IPC-owned lifetime).
return await puppeteer.connect({
browserWSEndpoint: info.webSocketDebuggerUrl,
defaultViewport: null,
+5 -1
View File
@@ -1,3 +1,4 @@
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 { Type } from "typebox";
@@ -183,6 +184,7 @@ export async function runPdfReaderAgent(
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
// factory pin to consult (engine.level is the resolved/clamped value).
const reasoning = engine.level;
const sessionId = randomUUID();
const prompt = [
`PDF path: ${pdfPath}`,
`Question (what the teacher needs from this document): ${input.question || "(no question — give a structural overview)"}`,
@@ -207,6 +209,7 @@ export async function runPdfReaderAgent(
INSTRUCTIONS,
messages,
toolDeclarations,
sessionId,
reasoning,
opts.abortSignal,
opts.chatId ? providerEventObserver(opts.chatId, "job:pdfReader") : undefined,
@@ -395,6 +398,7 @@ async function streamOnce(
systemPrompt: string,
messages: Message[],
tools: Tool[],
sessionId: string,
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
onProviderStreamEvent?: ProviderEventObserver,
@@ -403,8 +407,8 @@ async function streamOnce(
// and tool declarations fold into a leading system message.
// normalizeContext is the library's only Context→TranscriptContext entry.
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
sessionId,
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
onProviderStreamEvent: onProviderStreamEvent as never,
} as never);
+7 -1
View File
@@ -1,6 +1,8 @@
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { piEngineFor, type PiEngine } from "../models.ts";
import { randomUUID } from "node:crypto";
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
import { usageWasAdmitted } from "../subscription-request-cycle.ts";
import { providerEventObserver } from "../provider-events.ts";
import type { ModelRole } from "../../../config.ts";
@@ -64,6 +66,9 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
// Debug recorder (owner, 2026-10-04): raw provider events per job run.
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, `job:${opts.role}`) : undefined;
const agent = new Agent({
identity: APP_IDENTITY,
// One unsaved conversation per invocation, stable through every turn.
sessionId: randomUUID(),
initialState: {
systemPrompt: opts.systemPrompt,
model: engine.model,
@@ -103,6 +108,7 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
aborted = true;
break;
}
if (!usageWasAdmitted(m.usage)) { if (m.stopReason === "error") errorMessage = m.errorMessage; break; }
step++;
opts.onUsage?.(m.usage);
opts.onAssistantMessage?.(m, step);
+3 -2
View File
@@ -49,6 +49,8 @@ Tool outputs may be error strings (e.g. search unconfigured, a page that failed
Adapt: try another URL or another search angle; if research is genuinely impossible, say so
in the brief instead of inventing facts.
If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources.
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
## Summary
@@ -70,8 +72,7 @@ export interface ResearcherRunOptions {
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
* provider per call, null = honestly unavailable (the web_search tool
* reports that in its output — no crash, no silent fallback). Absent
* seam reads as unavailable: the server wiring always injects one; the
* dispatch-level drop (jobTools) keeps doomed jobs from even starting. */
* seam reads as unavailable; web_fetch remains usable without it. */
search?: () => SearchProvider | null;
/** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the
* toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped
+2 -3
View File
@@ -82,9 +82,8 @@ export interface JobRunnerDeps {
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
* provider fresh per job — server/search.ts composition injected by
* the wiring; null = honestly unavailable (the researcher's web_search
* reports it). Absent seam (tests) reads the same. The dispatch-level
* drop (jobTools' searchAvailable) uses the same signal — jobs already
* in flight when a key disappears still fail honestly, never crash. */
* reports it as recoverable text). Absent seam (tests) reads the same;
* research dispatch and web_fetch do not require search credentials. */
searchProvider?: () => SearchProvider | null;
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
* resolved engine's provider + the kind's slot tier, return the
+5 -1
View File
@@ -1,3 +1,4 @@
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 { Type } from "typebox";
@@ -142,6 +143,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
// factory pin to consult (engine.level is the resolved/clamped value).
const reasoning = engine.level;
const sessionId = randomUUID();
const prompt = [
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
brief,
@@ -164,6 +166,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
INSTRUCTIONS,
messages,
toolDeclarations,
sessionId,
reasoning,
opts.abortSignal,
opts.chatId ? providerEventObserver(opts.chatId, "job:visualMaker") : undefined,
@@ -317,6 +320,7 @@ async function streamOnce(
systemPrompt: string,
messages: Message[],
tools: Tool[],
sessionId: string,
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
onProviderStreamEvent?: ProviderEventObserver,
@@ -325,8 +329,8 @@ async function streamOnce(
// and tool declarations fold into a leading system message.
// normalizeContext is the library's only Context→TranscriptContext entry.
const stream = engine.streamFn(engine.model, normalizeContext({ systemPrompt, messages, tools }), {
sessionId,
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
onProviderStreamEvent: onProviderStreamEvent as never,
} as never);
+132
View File
@@ -0,0 +1,132 @@
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
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";
interface Pending { op: string; resolve(value: unknown): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
interface State {
statuses: Map<NativeSubscriptionProviderId, NativeSubscriptionStatus>;
models: Map<NativeSubscriptionProviderId, Model<Api>[]>;
pending: Map<string, Pending>;
controllers: Map<AbortController, { providerId: NativeSubscriptionProviderId; background: boolean }>;
listeners: Set<() => void>;
counter: number;
}
const KEY = Symbol.for("lh.subscriptions.bridge");
export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProviderId | undefined {
return NATIVE_SUBSCRIPTIONS.find((entry) => entry.logicalId === logicalId)?.providerId;
}
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 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");
if (providerId) { state.models.delete(providerId); state.statuses.delete(providerId); }
else { state.models.clear(); state.statuses.clear(); }
}
function accept(state: State, snapshot: NativeSubscriptionSnapshot) {
if (!snapshot || !Array.isArray(snapshot.statuses) || !snapshot.models) return;
let changed = false;
for (const entry of snapshot.statuses) {
if (!NATIVE_SUBSCRIPTIONS.some((p) => p.providerId === entry.providerId) || ![entry.revision, entry.epoch, entry.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0)) continue;
const previous = state.statuses.get(entry.providerId);
if (previous && entry.revision < previous.revision) continue;
const all = !!previous && (entry.connectionId !== previous.connectionId || entry.epoch !== previous.epoch || entry.authorityRevision !== previous.authorityRevision || !entry.available || !entry.connected);
for (const [controller, owner] of state.controllers) {
if (owner.providerId !== entry.providerId) continue;
if (all) controller.abort("all");
else if (owner.background && !entry.allowBackgroundUse) controller.abort("background");
}
state.statuses.set(entry.providerId, entry);
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === entry.providerId)!;
state.models.set(entry.providerId, entry.available && entry.connected ? (snapshot.models[entry.providerId] ?? []).filter((m) => m.provider === entry.providerId).map((m) => ({ ...m, provider: descriptor.logicalId })) : []);
changed ||= !previous || entry.revision !== previous.revision;
}
if (changed) notify(state);
}
function bridge(): State {
const global = globalThis as typeof globalThis & { [KEY]?: State };
if (!global[KEY]) {
const state: State = { statuses: new Map(), models: new Map(), pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0 };
global[KEY] = state;
process.on("message", (raw: unknown) => {
const message = raw as { channel?: string; op?: string; id?: string; ok?: boolean; value?: unknown; error?: string; snapshot?: NativeSubscriptionSnapshot };
if (!desktopConnected() || message?.channel !== "lh:subscriptions") return;
if (message.op === "changed" && message.snapshot) accept(state, message.snapshot);
if (!message.id) return;
const pending = state.pending.get(message.id);
if (!pending || pending.op !== message.op) return;
clearTimeout(pending.timer); state.pending.delete(message.id);
if (message.ok) pending.resolve(message.value);
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
});
process.on("disconnect", () => {
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);
});
}
return global[KEY];
}
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
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<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The subscription desktop connection timed out.")); }, 45000);
state.pending.set(id, { op, timer, resolve: (value) => resolve(value as T), reject });
process.send!({ ...fields, channel: "lh:subscriptions", id, op }, (error) => {
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The subscription desktop connection closed.")); }
});
});
}
export async function refreshSubscriptionModels(): Promise<void> {
const state = bridge();
if (!desktopConnected()) { invalidate(state); return; }
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("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;
}
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | 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 {
const state = nativeSubscriptionStatus(providerId);
const logicalId = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!.logicalId;
if (!state?.connectionId || !nativeSubscriptionConnected(providerId) || !nativeSubscriptionModel(logicalId, selectedModelId)) throw new Error("Subscription model catalog unavailable");
return { providerId, connectionId: state.connectionId, epoch: state.epoch, authorityRevision: state.authorityRevision, selectedModelId };
}
export function subscriptionScopeCurrent(scope: NativeSubscriptionScope): boolean {
try { return JSON.stringify(subscriptionScope(scope.providerId, scope.selectedModelId)) === JSON.stringify(scope); } catch { return false; }
}
/** Compatibility shape for the shared admission cycle; accountId is a local connection ID here. */
export function subscriptionAdmissionScope(scope: NativeSubscriptionScope): AdmissionScope {
return { accountId: scope.connectionId, epoch: scope.epoch, authorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId };
}
export function nativeAdmissionCurrent(providerId: NativeSubscriptionProviderId, scope: AdmissionScope): boolean {
return subscriptionScopeCurrent({ providerId, connectionId: scope.accountId, epoch: scope.epoch, authorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
}
export async function subscriptionLease(scope: NativeSubscriptionScope, background: boolean): Promise<ModelAuth> {
if (!subscriptionScopeCurrent(scope)) throw new Error("Subscription connection changed");
const result = await subscriptionRequest<{ scope: NativeSubscriptionScope; auth: ModelAuth }>("lease", { scope, background });
if (!result?.auth || !subscriptionScopeCurrent(scope) || JSON.stringify(result.scope) !== JSON.stringify(scope) || (!result.auth.apiKey && !Object.values(result.auth.headers ?? {}).some((value) => !!value))) throw new Error("Subscription lease is stale");
return result.auth;
}
export function subscriptionAbortController(providerId: NativeSubscriptionProviderId, background: boolean) {
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, { providerId, background });
return { controller, release: () => { state.controllers.delete(controller); } };
}
export async function subscriptionDenial(scope: NativeSubscriptionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
if (callerAborted || controller.signal.reason === "all") return "all";
if (!scope || !subscriptionScopeCurrent(scope)) return "other";
if (controller.signal.reason === "background") return "background";
if (!background) return "other";
await refreshSubscriptionModels();
if (!subscriptionScopeCurrent(scope)) return "all";
return nativeSubscriptionBackgroundAllowed(scope.providerId) ? "other" : "background";
}
+23
View File
@@ -0,0 +1,23 @@
import { chatGPTBackgroundAllowed, chatGPTConnected, chatGPTScopeCurrent, onChatGPTChange } from "./chatgpt-bridge.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionBackgroundAllowed, nativeAdmissionCurrent, onNativeSubscriptionChange } from "./subscription-bridge.ts";
import type { AdmissionScope } from "./subscription-request-cycle.ts";
export function isSubscriptionProvider(provider: string | undefined): boolean { return provider === "chatgpt" || !!(provider && nativeSubscriptionId(provider)); }
export function subscriptionConnected(provider: string): boolean {
if (provider === "chatgpt") return chatGPTConnected();
const native = nativeSubscriptionId(provider); return !!native && nativeSubscriptionConnected(native);
}
/** Ordinary API connections have no subscription background-consent gate. */
export function connectionBackgroundAllowed(provider: string | undefined): boolean {
if (provider === "chatgpt") return chatGPTBackgroundAllowed();
const native = provider ? nativeSubscriptionId(provider) : undefined;
return native ? nativeSubscriptionBackgroundAllowed(native) : true;
}
export function connectionScopeCurrent(provider: string, scope: AdmissionScope): boolean {
if (provider === "chatgpt") return chatGPTScopeCurrent(scope);
const native = nativeSubscriptionId(provider); return !!native && nativeAdmissionCurrent(native, scope);
}
export function onSubscriptionChange(listener: () => void): () => void {
const stopChatGPT = onChatGPTChange(listener), stopNative = onNativeSubscriptionChange(listener);
return () => { stopChatGPT(); stopNative(); };
}
+128
View File
@@ -0,0 +1,128 @@
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
export type NoDispatchKind = "background" | "all" | "other";
const USAGE_KEY = Symbol.for("lh.subscription.unadmittedUsage");
const usageGlobal = globalThis as typeof globalThis & { [USAGE_KEY]?: WeakSet<object> };
const unadmittedUsage = usageGlobal[USAGE_KEY] ??= new WeakSet<object>();
/** Transient identity only: no new provenance field enters saved native messages. */
export function markUnadmittedUsage(usage: object): void { unadmittedUsage.add(usage); }
export function usageWasAdmitted(usage: object): boolean { return !unadmittedUsage.has(usage); }
export interface SubscriptionDispatchLifecycle {
captured(scope: AdmissionScope): void;
beforeDispatch(scope: AdmissionScope): void;
noDispatch(kind: NoDispatchKind): void;
}
interface CycleOptions {
initialMessages: AgentMessage[];
staged?: AgentMessage[];
foregroundOwed: boolean;
purpose: { background: boolean };
scopeCurrent: (scope: AdmissionScope) => boolean;
commitStaged?: () => void;
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
}
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
export class SubscriptionRequestCycle implements SubscriptionDispatchLifecycle {
private release?: (event: AgentEvent) => void;
private held: AgentEvent[] = [];
private snapshot: AgentMessage[] = [];
private seen: Set<string>;
private automatic = new Set<string>();
private committed = false;
private denied?: NoDispatchKind;
private scope?: AdmissionScope;
private originalScope?: AdmissionScope;
private foregroundOwed: boolean;
private inheritedBackground: boolean;
private resumed = false;
private userAborted = false;
private stagedCommitted = false;
private started = false;
private anyDispatch = false;
private firstResolved = false;
private resolveAdmission!: (admitted: boolean) => void;
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
constructor(private readonly options: CycleOptions) {
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
this.foregroundOwed = options.foregroundOwed;
this.inheritedBackground = options.purpose.background;
}
get dispatched(): boolean { return this.anyDispatch; }
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
abort(): void { this.userAborted = true; }
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
prepare(request: PrepareRequestContext): void {
const messages = request.context.messages as AgentMessage[];
this.snapshot = [...messages]; this.automatic = new Set();
let learner = false;
for (const m of messages) {
const id = idOf(m); if (!id || this.seen.has(id)) continue;
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
if (cause === "automatic") this.automatic.add(id);
if (cause === "learner") learner = true;
}
if (this.automatic.size > 0) this.options.purpose.background = true;
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
}
hold(event: AgentEvent): boolean {
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
if (event.type === "turn_start") {
this.committed = false; this.denied = undefined; this.scope = undefined;
// A retained learner batch from the denied cycle already owns its turn_start.
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
return true;
}
if (!this.committed) { this.held.push(event); return true; }
return false;
}
captured(scope: AdmissionScope): void {
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("Subscription recovery scope changed");
this.scope = { ...scope };
this.originalScope ??= { ...scope };
}
beforeDispatch(scope: AdmissionScope): void {
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("Subscription admission cancelled");
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
this.stagedCommitted = true;
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
const boundary = this.held.findIndex((event) => event.type === "turn_start");
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
}
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
this.inheritedBackground = this.options.purpose.background;
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
const events = this.held; this.held = [];
for (const event of events) this.release?.(event);
this.resolveFirst(true);
}
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
this.options.restoreAutomatic(this.automatic, retainedIds);
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
if (retry) {
this.resumed = true; this.options.purpose.background = false;
this.seen = retainedIds;
this.denied = undefined;
// Staged automatic input must not be resurrected on foreground recovery.
this.options.staged = undefined;
return { action: "resumeForeground", messages };
}
this.held = [];
return { action: "settle", messages };
}
}
+85
View File
@@ -0,0 +1,85 @@
import { normalizeContext, type Api, type AssistantMessage, type Model, type Provider, type SimpleStreamOptions, type StreamFunction, type TranscriptContext } from "@earendil-works/pi-ai";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionScope } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionBackgroundAllowed, subscriptionScope, subscriptionScopeCurrent, subscriptionAdmissionScope, subscriptionLease, subscriptionAbortController, subscriptionDenial } from "./subscription-bridge.ts";
import { markUnadmittedUsage, type SubscriptionDispatchLifecycle } from "./subscription-request-cycle.ts";
export function nativeSubscriptionError(provider: string, message: string): string {
const label = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider)?.label ?? "Subscription";
const suffix = " No API-key fallback was used.";
if (/background/i.test(message)) return `${label} background use is off in this app. Enable it in Providers to run this work.${suffix}`;
if (/quota|429|limit/i.test(message)) return `${label} usage limit reached. Check your provider account and try again later.${suffix}`;
if (/expired|invalid_grant|401|refresh/i.test(message)) return `${label} authorization could not be refreshed. Retry or reconnect in Providers.${suffix}`;
if (/cancel|abort|closed|disconnect/i.test(message)) return `${label} request cancelled or connection closed.${suffix}`;
if (/catalog|model|stale|changed/i.test(message)) return `The selected ${label} model or connection is unavailable. Refresh Providers and explicitly choose an available model.${suffix}`;
return `${label} request failed. Check the connection, account access and provider limits, then retry.${suffix}`;
}
export function subscriptionHistory(context: TranscriptContext, logical: Model<Api>, nativeId: string): TranscriptContext {
const messages = transformMessages(context.messages, logical);
return { ...context, messages: messages.map((message) => message.role === "assistant" && message.provider === logical.provider ? { ...message, provider: nativeId } : message) };
}
function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: model.provider, model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
/** Direct native provider dispatch bypasses Models' ambient credential resolution entirely. */
export function nativeSubscriptionStream(provider: Provider, background: () => boolean, lifecycle?: SubscriptionDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream();
const nativeId = nativeSubscriptionId(logicalModel.provider);
if (!nativeId || provider.id !== nativeId) {
const error = { ...empty(logicalModel), errorMessage: nativeSubscriptionError(logicalModel.provider, "model unavailable") }; markUnadmittedUsage(error.usage); lifecycle?.noDispatch("other");
output.push({ type: "error", reason: "error", error }); output.end(); return output;
}
const purpose = background();
const { controller, release } = subscriptionAbortController(nativeId, purpose);
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
void (async () => {
let partial = empty(logicalModel), scope: NativeSubscriptionScope | undefined;
let committed = false, terminal = false;
const check = () => {
if (signal.aborted || !scope || !subscriptionScopeCurrent(scope)) throw new Error("cancelled or connection changed");
if (purpose && !nativeSubscriptionBackgroundAllowed(nativeId)) throw new Error("background use disabled");
};
try {
if (Object.keys(options?.headers ?? {}).some((key) => ["authorization", "x-api-key", "api-key", "host"].includes(key.toLowerCase()))) throw new Error("Authorization override forbidden");
scope = subscriptionScope(nativeId, logicalModel.id); lifecycle?.captured(subscriptionAdmissionScope(scope));
const auth = await subscriptionLease(scope, purpose); check();
const authKeys = new Set(Object.keys(auth.headers ?? {}).map((key) => key.toLowerCase()));
if (Object.keys(options?.headers ?? {}).some((key) => authKeys.has(key.toLowerCase()))) throw new Error("Authorization override forbidden");
const nativeModel = provider.getModels().find((model) => model.id === logicalModel.id && model.api === logicalModel.api);
if (!nativeModel) throw new Error("Native model unavailable");
const transport = { ...nativeModel, ...(auth.baseUrl ? { baseUrl: auth.baseUrl } : {}) };
const fetch = options?.fetch ?? globalThis.fetch;
const source = provider.streamSimple(transport, subscriptionHistory(normalizeContext(context), logicalModel, nativeId), {
...options, apiKey: auth.apiKey, headers: { ...options?.headers, ...auth.headers }, signal, maxRetries: 0,
fetch: (input, init) => {
check(); if (committed) throw new Error("Subscription requests do not retry after dispatch");
lifecycle?.beforeDispatch(subscriptionAdmissionScope(scope!)); committed = true; return fetch(input, init);
},
onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model); check();
if (replacement === undefined) return raw;
if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) throw new Error("Invalid model payload override");
return { ...replacement, model: (raw as { model: string }).model };
},
});
for await (const event of source) {
check();
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
partial = { ...message, provider: logicalModel.provider };
if (event.type === "error") throw new Error(event.error.errorMessage ?? "Native stream failed");
if (event.type === "done") { if (!terminal) { terminal = true; output.push({ ...event, message: partial }); } }
else if (!terminal) output.push({ ...event, partial });
}
if (!terminal) throw new Error("Native stream ended without a terminal response");
} catch (error) {
if (terminal) return;
if (!committed) { markUnadmittedUsage(partial.usage); lifecycle?.noDispatch(await subscriptionDenial(scope, controller, options?.signal?.aborted === true, purpose)); }
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: nativeSubscriptionError(logicalModel.provider, error instanceof Error ? error.message : "") };
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
} finally { release(); output.end(); }
})();
return output;
};
}
+31 -4
View File
@@ -4,7 +4,7 @@ import { invalidInputResult, questionResult } from "./tool-execute.ts";
import { providerEventObserver } from "./provider-events.ts";
import type { Usage } from "@earendil-works/pi-ai";
import { PATHS } from "../../config.ts";
import { piEngineFor, type PiEngine } from "./models.ts";
import { APP_IDENTITY, piEngineFor, type PiEngine } from "./models.ts";
import { effectivePromptSegments } from "./prompt-segments.ts";
import {
askUserQuestionTool,
@@ -18,7 +18,9 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages } from "../core/native-messages.ts";
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -97,6 +99,8 @@ export type StepUsageCollector = (usage: Usage) => void;
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
callPurpose?: { background: boolean };
requestCycle?: ChatGPTRequestCycle;
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
@@ -116,7 +120,9 @@ export interface AgentBuildOptions {
* fresh per turn, honoring enabled overrides (switch on + file
* exists); absent → the cached pure-source composition. */
promptSwitches?: Record<string, boolean>;
/** Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
/** Native conversation identity: the existing persisted chat id, reused
* across reconstructed prober, teacher, and practice Agents.
* Debug: when set AND LH_PROVIDER_EVENTS=1, raw provider stream events
* (pre-normalization) append to data/debug/provider-events/
* <chatId>.jsonl via providerEventObserver — the GLM-truncation /
* DeepSeek-id incident layer. Absent or env-off → no observer. */
@@ -176,7 +182,10 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off).
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
const seenInputs = new Set((opts.messages ?? []).map((m) => (m as { id?: string }).id).filter(Boolean));
const agent = new Agent({
identity: APP_IDENTITY,
sessionId: opts.chatId,
initialState: {
systemPrompt,
model: engine.model,
@@ -207,13 +216,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
convertToLlm: toLlmMessages,
prepareRequest: (request) => {
if (opts.callPurpose) {
let learner = false, automatic = false;
const messages = request.context.messages;
for (const message of messages) {
const id = (message as { id?: string }).id;
const cause = nativeInputCause(message, messages.slice(0, messages.indexOf(message)));
if (cause === "neutral") continue;
if (id && seenInputs.has(id)) continue;
if (id) seenInputs.add(id);
if (cause === "learner" && id) learner = true;
else automatic = true; // missing/unrecognized identity never grants foreground
}
if (automatic) opts.callPurpose.background = true;
else if (learner) opts.callPurpose.background = false;
}
opts.requestCycle?.prepare(request);
},
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
if (event.message.stopReason === "aborted") return;
if (event.message.stopReason === "aborted" || !usageWasAdmitted(event.message.usage) || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
opts.onUsage!(event.message.usage);
}
});
+5 -13
View File
@@ -268,8 +268,7 @@ export interface ProberToolContext extends ProbeDoneContext {
jobStore: JobStore;
/** Fire the background runner for a freshly created job. */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
/** Search availability — the research dispatch tool's honest-disable
* signal (same gate as the teacher's). Absent = available. */
/** Retained search-status seam; research dispatch is available keylessly. */
searchAvailable?: () => boolean;
/** The prober's graph-map reader (wave 7): present = the two map tools
* (list_sessions / list_propositions) are registered. The composition
@@ -331,13 +330,12 @@ export function createProberTools(
// research + pdf are ALWAYS available in every phase — the
// prober can verify facts or read learner PDFs while assessing.
// demo/visual stay teach-phase-only (they need the lesson's plan
// context). Same gating as the teacher: toggle-filtered here,
// research subject to the searchAvailable gate (honestly absent
// when no engine is live).
// context). Same user-toggle filtering as the teacher; research
// remains available without a search-provider key.
...(jobs.research ? { research: jobs.research } : {}),
pdf: jobs.pdf!,
// The map reader (wave 7): present only when the settings gate
// opened — same registration-time drop as the search gate.
// opened.
...(ctx.graphStore
? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore })
: {}),
@@ -400,9 +398,7 @@ export interface TeacherToolContext extends PlanToolContext {
* rides along — the spawn fires mid-stream, before the tool part is
* persisted to the transcript (a transcript scan would race it). */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
/** Search availability (search port wave, 2026-09-20): the research
* dispatch tool's honest-disable signal — no live engine = the tool
* drops from this registry (JobToolContext.searchAvailable). */
/** Retained search-status seam; research dispatch is available keylessly. */
searchAvailable?: () => boolean;
}
@@ -462,10 +458,6 @@ export function createTeacherTools(
find: findTool,
grep: grepTool,
...planTools(ctx),
// Fix wave (2026-09-20): searchAvailable was RECEIVED here but
// DROPPED when building the inner JobToolContext — the drop-gate
// always took its ?? true default. Threaded now: the live signal
// reaches jobTools' research gate.
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
},
withoutMechanics(enabled),
+14 -21
View File
@@ -23,11 +23,9 @@ export interface JobToolContext {
* input rides along because the spawn happens MID-STREAM, before
* onFinish persists the tool part — a transcript scan would race it. */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
/** Search availability (search port wave, 2026-09-20): the honest
* disable signal for the research dispatch tool — no live engine
* means NO research tool in the registry (owner decision: registry-
* level drop, no doomed dispatch, no silent fallback). Absent =
* available (tests + any seam without search wiring). */
/** Search-provider availability, retained for existing composition seams.
* Research dispatch does not depend on it: web_fetch is keyless and
* web_search reports a missing provider as a recoverable tool result. */
searchAvailable?: () => boolean;
}
@@ -127,25 +125,20 @@ const PDF_DESCRIPTION =
* context (chatId + jobStore + the spawn seam), so concurrent chats never
* share state. chat-handler wires it each turn; tests wire their own.
*
* The research tool drops from the registry when ctx.searchAvailable reports
* no live engine (2026-09-20) — the teacher never sees it, so a keyless
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
* a key disappears still run to their honest failure (runner backstop).
* Research remains available without a search-provider key; the role's
* existing user-toggle filtering still applies outside this registry.
*/
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
const searchOn = ctx.searchAvailable?.() ?? true;
return {
...(searchOn && {
research: {
name: "research",
label: "research",
description: RESEARCH_DESCRIPTION,
parameters: researchInputSchema,
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
),
},
}),
research: {
name: "research",
label: "research",
description: RESEARCH_DESCRIPTION,
parameters: researchInputSchema,
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
),
},
demo: {
name: "demo",
label: "demo",
+8 -6
View File
@@ -40,8 +40,8 @@ export interface PracticeToolContext {
graphNodes?: () => ReadonlyArray<Pick<import("../../core/contracts/graph.ts").GraphNode, "id" | "name">>;
/** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers
* behind the SAME settings gate as the prober's (graphToolsEnabled at
* the composition root); the research + pdf jobs with the honest
* searchAvailable drop. Each absent (tests) = absent from the registry. */
* the composition root); the research + pdf jobs (both keyless).
* Each absent context (tests) = absent from the registry. */
graph?: GraphToolContext;
jobs?: JobToolContext;
}
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
label: "conclude",
description:
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
"up in text). Nothing may conclude unless a problem is active.",
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
parameters: concludeInputObject,
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
executionMode: "sequential",
@@ -223,8 +225,8 @@ export function practiceTools(ctx: PracticeToolContext): Record<string, AgentToo
const gate = createPracticeGate(ctx);
// THE RARELY-NEEDED SET (owner round 10): read/ls/find/grep over the
// learner's files (always — the same read-only set the teacher and
// prober carry), the research + pdf jobs (research honestly absent
// when no search engine is live; demo/visual stay teach-only), and
// prober carry), the research + pdf jobs (available without search
// keys; demo/visual stay teach-only), and
// the prober's map readers when the settings gate opened. None are
// toggle-filtered: problem/conclude are the session; these are
// exceptions the doctrine says to reach for rarely.
+1 -1
View File
@@ -77,7 +77,7 @@ export interface AuthProviderInfo {
*
* EXCLUDED by owner spec — providers whose auth is not a plain key this
* wave: github-copilot (OAuth), amazon-bedrock (AWS creds), google-vertex
* (ADC), azure-openai-responses (needs a base URL), the cloudflare
* (ADC), azure (needs a base URL), the cloudflare
* account-scoped pair. Anthropic keeps ANTHROPIC_API_KEY only — its
* AUTH_TOKEN/OAUTH_TOKEN alternates stay env-only. openai-codex exposes
* no env-key (naturally absent). Providers sharing one env var (moonshotai
+62 -51
View File
@@ -1,7 +1,8 @@
/**
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
* mirroring pi's design (docs aside, this is OUR implementation — pi's
* compaction lives in the coding-agent package we don't depend on).
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
* No coding-agent dependency. See docs/compaction-accounting.md for the
* behavior boundary, native golden fixtures, and upstream MIT notice.
*
* THE CONTRACT: full history stays persisted in the record — extraction
* and the user's transcript keep reading everything. A compaction marker
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
import { newMessageId } from "./native-messages.ts";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
// ─── constants (the tunables, owner-approved) ────────────────────────────────
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
);
}
/** Estimate a message's context tokens from its WIRE-relevant text only
* (pi's estimator): text + thinking + toolCall name/args for assistants,
* content text for user/toolResult/custom. NEVER JSON.stringify of the
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
* whole message — record scaffolding (ids, usage, responseId, app-side
* details, escaped quotes/backslashes) inflates ~3x (measured on the
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
}
} else {
chars += textCharsOfContent(content);
if (message.role === "system") {
const system = message as SystemMessage;
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
}
}
return Math.ceil(chars / 4);
}
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
// measurement of encrypted reasoning or actual provider vision tokens.
const ESTIMATED_IMAGE_CHARS = 4_800;
function textCharsOfContent(content: unknown): number {
if (typeof content === "string") return content.length;
if (!Array.isArray(content)) return 0;
let chars = 0;
for (const block of content as Array<string | { type?: string; text?: string }>) {
if (typeof block === "string") chars += block.length;
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
else if (block?.type === "text") chars += block.text?.length ?? 0;
}
return chars;
}
/** The newest assistant message carrying VALID provider-reported usage
* (pi's rule: skip aborted messages and all-zero usage — they carry no
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
* truth about the wire). The scan STOPS at the newest compaction
* marker: usage captured before a compaction described the pre-cut
* context — the next request carries the summary + kept tail instead,
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
if ((m as { role?: string }).role !== "assistant") continue;
const a = m as AssistantMessage & { stopReason?: string };
if (a.stopReason === "aborted") continue;
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
return i;
}
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
// ─── the cut walk ────────────────────────────────────────────────────────────
/**
* Find the compaction cut index: walk BACKWARDS from the end accumulating
* estimates until ~keepRecentTokens are kept; the cut lands there.
*
* HARD INVARIANT — a tool result is never separated from its call: if the
* walk stops on a toolResult, the KEPT region extends to the assistant
* message carrying its call (keeping the result means keeping the call).
* This is the provider protocol's shape law: dropping a call while
* keeping its result strands an orphan the provider rejects. A PENDING
* question/problem call (no result yet — the pose→answer window) sits at
* the transcript end and is always kept: the walk never crosses it.
*
* Returns 0 when everything must be summarized (the whole history is the
* span — valid), or messages.length when there is nothing meaningful to
* cut (kept budget covers everything — caller checks before compacting).
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
let kept = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (kept >= keepRecentTokens) {
// The cut candidate: extend past orphaned tool results to the
// assistant message carrying their calls.
let cut = i + 1;
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
if (callIndex === -1) break; // no call found (defensive): stop extending
cut = callIndex;
}
return cut;
}
kept += estimateMessageTokens(messages[i]!);
}
return 0; // the whole history fits inside the kept budget — summarize all
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
* markers have no context, just like native metadata entries. */
function cutMessage(message: LhMessage): LhMessage | null {
if (message.role !== "custom") return message;
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
return message;
}
/** The newest assistant message at or before `before` whose content
* carries the tool call `toolCallId` — the call a result answers. */
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
for (let i = before - 1; i >= 0; i--) {
const m = messages[i]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
* AFTER that entry. If oversized trailing results exhaust the budget,
* retain their preceding assistant call, not the entire session.
* No encrypted-signature sizing and no exact retained-token guarantee.
* Returns the first kept record index; a zero/no-progress cut drops nothing.
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
*/
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
const previous = activeCompactionOf(messages);
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
const start = Math.max(0, previousKept);
const points: number[] = [];
for (let i = start; i < messages.length; i++) {
const message = cutMessage(messages[i]!);
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
}
return -1;
if (!points.length) return start;
let cut = points[0]!;
let accumulated = 0;
for (let i = messages.length - 1; i >= start; i--) {
const message = cutMessage(messages[i]!);
const tokens = message ? estimateMessageTokens(message) : 0;
if (tokens === 0) continue;
accumulated += tokens;
if (accumulated >= keepRecentTokens) {
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
break;
}
}
// Native entries preserve adjacent non-context metadata, but never walk
// backwards across a previous compaction entry.
while (cut > start) {
const before = messages[cut - 1]!;
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
cut--;
}
return cut;
}
// ─── the marker ──────────────────────────────────────────────────────────────
+27
View File
@@ -0,0 +1,27 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { LhMessage } from "./native-messages.ts";
/** Latest completed conversation measurement, never a request-size estimate.
* Ordered compaction markers invalidate every earlier reading. Live callers
* must exclude assistant slots that have not received message_end.
* Reasoning is already inside output; totalTokens is deliberately not used.
*/
export function measuredContextTokens(
messages: readonly LhMessage[],
incompleteIds: ReadonlySet<string> = new Set(),
): 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" || incompleteIds.has(message.id)) continue;
const assistant = message as AssistantMessage;
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
const usage = assistant.usage;
if (!usage) continue;
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
const tokens = buckets.reduce((sum, value) => sum + value, 0);
if (tokens > 0) return tokens;
}
return null;
}
+8 -5
View File
@@ -87,8 +87,8 @@ export interface ChatSettings {
* billed (CallUsage per agent step). Steps, not a sum: cost and context
* folds happen at read time (core/prices.ts). */
export interface ExtractionStageUsage {
/** The model that ran this stage, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran this stage, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
steps: CallUsage[];
}
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
* flattened to the fields we persist (undefined = provider didn't report;
* renderers show absent, never zero). */
export interface CallUsage {
/** Total input (prompt) tokens, cached portion included. */
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
inputTokens: number | undefined;
outputTokens: number | undefined;
/** Cached input tokens READ (billed at the cache-read rate, if any). */
@@ -126,6 +126,9 @@ export interface CallUsage {
/** Input tokens written to the cache this call (OpenRouter bills writes
* for some providers; undefined = not reported/not billed). */
cacheWriteTokens: number | undefined;
/** Subset of cacheWriteTokens written with one-hour retention. Missing
* on older logs; new native captures explicitly record zero if absent. */
cacheWrite1hTokens?: number;
/** The reasoning portion of the output tokens. */
reasoningTokens: number | undefined;
}
@@ -144,8 +147,8 @@ export interface TurnUsage {
* entry to the transcript (the block this turn "belongs" to). */
messageId: string;
at: string;
/** The model that ran the turn, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
/** The model that ran the turn, provider-qualified ("provider:id")
* for key-independent Pi catalog lookup at read time. */
model: string;
/** One entry per agent step (model call) in this turn. */
steps: CallUsage[];
+27
View File
@@ -0,0 +1,27 @@
/** Client-safe status only. Credentials and OAuth transaction data never enter this contract. */
export interface ChatGPTStatus {
available: boolean;
pending: boolean;
/** App-wide ChatGPT background permission, independent of account selection. */
allowBackgroundUse: boolean;
activeId: string | null;
accounts: {
id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean;
/** Main-owned authorization recovery eligibility. Missing means unknown, not eligible.
* False for storage failures, paused/closed connections, and healthy authorization. */
canSignInAgain?: boolean;
}[];
error: string | null;
catalogError?: string | null;
}
export interface ChatGPTControlBridge {
status(): Promise<ChatGPTStatus>;
login(id?: string): Promise<ChatGPTStatus>;
cancel(): Promise<ChatGPTStatus>;
select(id: string): Promise<ChatGPTStatus>;
/** Explicitly forget every local registration/credential for this verified identity. */
disconnect(id: string): Promise<ChatGPTStatus>;
logout(id: string): Promise<ChatGPTStatus>;
consent(enabled: boolean): Promise<ChatGPTStatus>;
onChange(callback: () => void): () => void;
}
+3 -5
View File
@@ -95,11 +95,9 @@ export interface NotesSource {
filePath: string;
/** ISO — when this file was imported (the checkpoint's foldedAt). */
importedAt: string;
/** sha256 of the imported text at the last EXTRACTION run — the
* same-path re-import guard (owner, 2026-09-29): a matching
* fingerprint is a TRUE no-op (no model calls, no usage record);
* a mismatch or a legacy missing stamp re-runs the whole file.
* The mappings ledger used to give this idempotency by accident. */
/** Legacy content fingerprint. Accepted when reading old records, but
* ignored: every explicit notes import processes the whole file. New
* imports do not compute or write this field. No migration is needed. */
contentFp?: string;
/** The run's usage log — same append-only discipline as
* ChatRecord.extractions, idempotent per startedAt. */
+21 -10
View File
@@ -43,7 +43,7 @@ export interface JobRecord {
* money-channel doctrine; cost renders "—", never $0). */
export interface JobUsage {
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
model: string;
/** Running totals summed over the run's model calls so far. */
inputTokens: number | undefined;
@@ -51,6 +51,10 @@ export interface JobUsage {
cacheReadTokens: number | undefined;
cacheWriteTokens: number | undefined;
reasoningTokens: number | undefined;
cacheWrite1hTokens?: number;
/** Individual calls for current-catalog per-call tier pricing. Absent on
* older jobs; never backfill them from cumulative totals. */
calls?: CallUsage[];
/** How many model calls the run has made (each bills separately). */
steps: number;
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
@@ -67,7 +71,7 @@ export interface JobUsage {
* object with these fields folds, including the real thing and test fakes). */
export interface UsageStepLike {
inputTokens?: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
outputTokens?: number | undefined;
outputTokenDetails?: { reasoningTokens?: number } | undefined;
}
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
output: number;
cacheRead: number;
cacheWrite: number;
cacheWrite1h?: number;
reasoning?: number;
}): UsageStepLike {
return {
inputTokens: usage.input,
outputTokens: usage.output,
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
};
}
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
): JobUsage {
const add = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
const call: CallUsage = {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
};
return {
model,
inputTokens: add(total?.inputTokens, step.inputTokens),
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
steps: (total?.steps ?? 0) + 1,
lastStep: {
inputTokens: step.inputTokens,
outputTokens: step.outputTokens,
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
},
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
lastStep: call,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
/** One picker row: everything the UI shows about a model, projected from
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
* units core/prices.ts's generated table carries). */
* units as Pi's server-side pricing calculator). */
export interface ModelCatalogEntry {
id: string;
name: string;
+68
View File
@@ -0,0 +1,68 @@
import type { Api, Model } from "@earendil-works/pi-ai";
/** Native SDK IDs stay distinct from the app's persisted connection routes. */
export type NativeSubscriptionProviderId = "github-copilot" | "xai";
export type NativeSubscriptionLogicalId = "github-copilot-subscription" | "xai-subscription";
export const NATIVE_SUBSCRIPTIONS = [
{ providerId: "github-copilot", logicalId: "github-copilot-subscription", label: "GitHub Copilot" },
{ providerId: "xai", logicalId: "xai-subscription", label: "Grok" },
] as const;
export interface SubscriptionPrompt {
id: string;
type: "text" | "secret" | "select" | "manual_code";
message: string;
placeholder?: string;
options?: readonly { id: string; label: string; description?: string }[];
}
/** Only public device user codes, never the private device_code or tokens. */
export interface SubscriptionInteraction {
attemptId: string;
prompt?: SubscriptionPrompt;
browser?: { id: string; url: string };
userCode?: string;
progress?: string;
}
export interface NativeSubscriptionStatus {
providerId: NativeSubscriptionProviderId;
available: boolean;
connected: boolean;
pending: boolean;
committing?: boolean;
allowBackgroundUse: boolean;
connectionId: string | null;
epoch: number;
authorityRevision: number;
revision: number;
error?: string;
interaction?: SubscriptionInteraction;
}
/** Private inherited IPC also carries safe, SDK-supported catalog metadata. */
export interface NativeSubscriptionSnapshot {
statuses: NativeSubscriptionStatus[];
models: Partial<Record<NativeSubscriptionProviderId, Model<Api>[]>>;
}
export interface NativeSubscriptionScope {
providerId: NativeSubscriptionProviderId;
connectionId: string;
epoch: number;
authorityRevision: number;
selectedModelId: string;
}
export interface NativeSubscriptionControlBridge {
status(): Promise<NativeSubscriptionStatus[]>;
login(providerId: NativeSubscriptionProviderId, enabled?: boolean): Promise<void>;
cancel(providerId: NativeSubscriptionProviderId): Promise<void>;
respond(providerId: NativeSubscriptionProviderId, attemptId: string, promptId: string, value: string): Promise<void>;
open(providerId: NativeSubscriptionProviderId, attemptId: string, actionId: string): Promise<void>;
retry(providerId: NativeSubscriptionProviderId): Promise<void>;
disconnect(providerId: NativeSubscriptionProviderId): Promise<void>;
consent(providerId: NativeSubscriptionProviderId, enabled: boolean): Promise<void>;
onChange(listener: () => void): () => void;
}
+35 -12
View File
@@ -9,7 +9,7 @@
* the same ruling as wave 7's stages.embedding.)
*
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
* core/prices.ts — never stored anywhere. An unpriced model contributes
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
* "— unpriced" honestly, never a silent zero. Tokens always sum.
*
@@ -18,13 +18,16 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { callCost, sumSteps } from "./prices.ts";
import { sumSteps, isSubscriptionModel, subscriptionLabels, type CallPricer } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
export interface StageCost {
/** Subscription stage-runs, excluded from API dollars and unpriced counts. */
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
dollars: number;
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
unpricedRuns: number;
/** Runs that billed this stage. */
runs: number;
@@ -34,6 +37,8 @@ export interface StageCost {
/** One run in the recent-runs list (extraction or naming). */
export interface RecentRun {
subscriptionRuns?: number;
subscriptionProviders?: string[];
startedAt: number;
/** ISO wall-clock. */
at: string;
@@ -51,9 +56,11 @@ export interface RecentRun {
/** The cost pill's whole payload: stage totals + recent runs. */
export interface GraphCostSummary {
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD across all priced stages of all runs. */
totalDollars: number;
/** Runs where EVERY stage was unpriced (totalDollars excludes them). */
/** Runs with no priced API stage and at least one unknown stage. */
unpricedRuns: number;
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
* the embedding stage is gone with the provider embedder; the
@@ -88,10 +95,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
if (isSubscriptionModel(model) || steps.length === 0) return null;
let total = 0;
for (const step of steps) {
const c = callCost(step, model);
const c = price(step, model);
if (c === null) return null;
total += c;
}
@@ -101,6 +109,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null
/** Merge one step-set's tokens into an accumulating CallUsage. */
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
const summed = sumSteps(steps);
// Fold this before updating total writes, so an older write-bearing
// aggregate with a missing split remains unknown even after fresh data.
into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens;
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
@@ -110,7 +121,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
/** Fold every usage channel into the pill's payload. Pure: same input,
* same output — no clock, no fs, no store reads. */
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
const extraction = emptyStage();
const audit = emptyStage();
const runs: RecentRun[] = [];
@@ -120,14 +131,22 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
// Per-stage: tokens always count; dollars only when priced.
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
if (extDollars === null) extraction.unpricedRuns++;
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
if (isSubscriptionModel(stages.extractor.model)) {
extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
extraction.subscriptionProviders = [...new Set([...(extraction.subscriptionProviders ?? []), ...subscriptionLabels([stages.extractor.model])])];
}
else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
if (audDollars === null) audit.unpricedRuns++;
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
if (isSubscriptionModel(stages.auditor.model)) {
audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
audit.subscriptionProviders = [...new Set([...(audit.subscriptionProviders ?? []), ...subscriptionLabels([stages.auditor.model])])];
}
else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
// Run-level: null only when EVERY stage is unpriced.
@@ -135,8 +154,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const runDollars = allStages.every((d) => d === null)
? null
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isSubscriptionModel(stage.model)).length;
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
runs.push({
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: subscriptionLabels([stages.extractor.model, stages.auditor.model]) } : {}),
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
@@ -159,8 +180,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const capped = runs.slice(0, input.recentCap ?? 10);
// A run is "unpriced" when it billed nothing countable at all.
const unpricedRuns = runs.filter((r) => r.dollars === null).length;
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
return {
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: [...new Set([...(extraction.subscriptionProviders ?? []), ...(audit.subscriptionProviders ?? [])])] } : {}),
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit },
+4
View File
@@ -48,6 +48,10 @@ export * from "./input-events.ts";
export * from "./view.ts";
export * from "./tool-parts.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 * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
+5
View File
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
* `\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));
}
export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
+51
View File
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
return last !== undefined && isDismissedResult(last);
}
export type RequestCause = "foreground" | "background";
export type ReconstructedCause = RequestCause | "ambiguous";
export type NativeInputCause = "learner" | "automatic" | "neutral" | "unrecognizable";
/** Native causes only: flattened user roles, IDs' prefixes and prose confer no permission. */
export function nativeInputCause(message: AgentMessage, history: readonly AgentMessage[]): NativeInputCause {
if (message.role === "user") return "learner";
if (message.role === "system" || message.role === "assistant") return "neutral";
if (message.role === "custom") {
if (["probe-handoff", "subagent-steer", "subagent-abort"].includes(message.customType)) return "automatic";
if (["practice-thinking", "practice-next"].includes(message.customType)) return "learner";
return ["graph-context", "plan-block", "problem-dismissed", "compaction"].includes(message.customType) ? "neutral" : "unrecognizable";
}
if (message.role === "toolResult") {
if (!(QUESTION_TOOLS as readonly string[]).includes(message.toolName) || message.isError || message.details == null || (message.details as { dismissed?: boolean }).dismissed) return "neutral";
const details = message.details as Partial<QuestionAnswerData>;
if (!["quiz", "ask", "explain"].includes(details.kind ?? "") || typeof details.questionText !== "string" || typeof details.dontKnow !== "boolean" || !Array.isArray(details.answers) || typeof details.answeredAt !== "string") return "unrecognizable";
const calls = history.flatMap((m) => m.role === "assistant" ? m.content.filter((b) => b.type === "toolCall" && b.id === message.toolCallId) : []);
return calls.length === 1 && calls[0]?.type === "toolCall" && calls[0].name === message.toolName ? "learner" : "unrecognizable";
}
return "unrecognizable";
}
/** Full persisted native record, including pre-compaction causes. Never guessed foreground. */
export function reconstructRequestCause(messages: readonly LhMessage[]): ReconstructedCause {
let inherited: ReconstructedCause = "ambiguous";
let triggers: NativeInputCause[] = [];
const seen = new Map<string, string>();
const history: AgentMessage[] = [];
const resolve = (): ReconstructedCause => {
if (triggers.includes("unrecognizable")) return "ambiguous";
const lastLearner = triggers.lastIndexOf("learner"), lastAutomatic = triggers.lastIndexOf("automatic");
if (lastLearner < 0 && lastAutomatic < 0) return inherited;
if (lastAutomatic < 0) return "foreground";
if (lastLearner < lastAutomatic) return "background";
return "ambiguous";
};
for (const message of messages) {
const cause = nativeInputCause(message, history);
const id = (message as { id?: string }).id;
if (id) {
const value = JSON.stringify(message);
if (seen.has(id)) { if (seen.get(id) !== value) triggers.push("unrecognizable"); continue; }
seen.set(id, value);
} else if (cause !== "neutral") triggers.push("unrecognizable");
if (message.role === "assistant" && !(message.provider === "synthetic" && message.api === "faux" && message.model === "synthetic-conclude" && message.content.some((b) => b.type === "toolCall" && b.name === "conclude" && b.arguments?.outcome === "dismissed"))) {
inherited = resolve(); triggers = [];
} else if (cause !== "neutral") triggers.push(cause);
history.push(message);
}
return resolve();
}
export function needsContinuation(messages: readonly LhMessage[]): boolean {
const last = messages[messages.length - 1];
if (!last || (last as { role?: string }).role !== "toolResult") return false;
+49 -1676
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -30,7 +30,7 @@
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 { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
* 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 healOverescapedCommands(restoreMathEscapes(value));
if (typeof value === "string") return repairMathForDisplay(value);
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
if (typeof value === "object" && value !== null) {
const out: Record<string, unknown> = {};
+52 -10
View File
@@ -7,6 +7,11 @@ 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 { nativeSubscriptionError } from "../agent/subscription-stream.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
import { reconstructRequestCause } from "../core/native-messages.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
@@ -15,6 +20,7 @@ import {
noteSteerVisible,
reconcileUserSteers,
registerLiveAgent,
restoreUncommittedAutomatic,
setJobRespawn,
setTurnRunner,
settleSteers,
@@ -41,7 +47,7 @@ import { persistTurn, triggerExtraction } 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";
import { catalogContextLimits } from "./model-catalog.ts";
import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
import { emit } from "../core/events.ts";
import { settledTurnResponse } from "./turn-runs.ts";
import { asAgentMessages } from "../core/native-messages.ts";
@@ -201,6 +207,12 @@ export async function handleChatInput(
// persist (nothing durable changes); wake-pipeline turns ride the same
// path. The affected tier is strong — the conversation chain's own tier
// (a chat.model override still resolves through it).
if (isSubscriptionProvider(conversation.provider)) {
await refreshConnectionCatalog(conversation.provider);
if (input.type === "steers" && !connectionBackgroundAllowed(conversation.provider)) {
return Response.json({ error: "Background use is off for this connection. Automatic follow-up remains deferred in its queue." }, { status: 403 });
}
}
if (!providerKeyPresent(conversation.provider)) {
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
@@ -216,12 +228,22 @@ export async function handleChatInput(
// placement mirrors the BYOK/key gates — BEFORE appends and
// beginTurn, no liveness effects — and the record re-reads after
// because compaction appends the marker.
const conversationEngine = piEngineForSpec(conversation);
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
let requestCycle: SubscriptionRequestCycle | undefined;
const lifecycle: SubscriptionDispatchLifecycle = {
captured: (scope) => requestCycle?.captured(scope),
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
noDispatch: (kind) => requestCycle?.noDispatch(kind),
};
let conversationEngine: PiEngine;
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : isSubscriptionProvider(conversation.provider) ? nativeSubscriptionError(conversation.provider, "model catalog") : "The selected model is unavailable." }, { status: 503 }); }
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
await ensureCompacted(
{
chatStore: stores.chatStore,
engine: conversationEngine,
engine: piEngineForSpec(conversation, true),
canStart: () => connectionBackgroundAllowed(conversation.provider),
modelId: modelIdForCompaction,
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -287,12 +309,14 @@ 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];
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
stores.chatStore.saveMessages(chatId, merged);
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 appends) {
for (const appended of stagedAutomatic ? [] : appends) {
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
}
} catch (err) {
@@ -310,6 +334,7 @@ export async function handleChatInput(
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
// Optional in pi's Usage — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
});
@@ -339,8 +364,19 @@ export async function handleChatInput(
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// 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),
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; },
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
const agentOpts: AgentBuildOptions = {
engine,
callPurpose,
requestCycle,
messages: asAgentMessages(merged),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
@@ -439,6 +475,7 @@ export async function handleChatInput(
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
const handle = runPiTurn({
agent: constructed,
requestCycle,
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
// default in ui-stream.
@@ -454,13 +491,15 @@ export async function handleChatInput(
// route through here: they ride the error-result path and reach the
// transcript directly (the retry context the model saw).
onError: (error) => {
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
if (isSubscriptionProvider(conversation.provider)) return nativeSubscriptionError(conversation.provider, String(error));
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
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.
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
// 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.
@@ -479,7 +518,7 @@ export async function handleChatInput(
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
// no extraction, no graph, no coverage (v1: the session itself
// is the deliverable).
if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
// Fresh per-turn read of the graph section's chunk budget
// (extraction-UX redesign, 2026-09-24): the trigger compares
// against it and the pipeline's batches share it.
@@ -520,18 +559,20 @@ export async function handleChatInput(
// beneath-threshold record is a silent no-op. Skipped under
// tests (the extraction precedent — scripted chats are
// fixtures; the service itself is pinned directly).
if (process.env.NODE_ENV !== "test")
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
kickCompaction(() => {
const fresh = effectiveSettings();
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
if (!spec) return null;
const kickEngine = piEngineForSpec(spec);
if (!spec || !connectionBackgroundAllowed(spec.provider)) return null;
let kickEngine: PiEngine;
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
const freshRecord = stores.chatStore.getChat(chatId);
if (!freshRecord) return null;
return {
chatStore: stores.chatStore,
engine: kickEngine,
canStart: () => connectionBackgroundAllowed(spec.provider),
modelId: kickModelId,
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -558,6 +599,7 @@ export async function handleChatInput(
// must cost zero permanently-held slots beyond the one live view. The
// turn itself is a server-side process (ruling 2026-09-25) — it never
// needed this connection, only a view did.
if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
return Response.json({ ok: true });
} catch (err) {
// The stream never started, so no onFinish will fire — clear the registry
+6 -2
View File
@@ -89,6 +89,8 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
/** Everything the service needs from its host (chat-handler wires the
* production versions; tests inject). */
export interface CompactionDeps {
/** Eligibility only for NEW runs; structural waits still apply. */
canStart?: () => boolean;
chatStore: ChatStore;
/** The chat's conversation engine — also the summarizer's. */
engine: PiEngine;
@@ -173,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
const cut = compactionCutIndex(messages);
if (cut >= messages.length) return; // nothing to drop (defensive)
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
// The previous compaction's kept boundary (chained compactions
// summarize the old summary + what rode after it).
@@ -238,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
reasoningTokens: usage.reasoning ?? 0,
},
],
@@ -267,6 +270,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
// injected summarizer.
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
await awaitCompaction(chatId);
if (deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
@@ -291,7 +295,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
if (state().inFlight.has(chatId)) return;
const deps = buildDeps();
if (!deps) return;
if (!deps || deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
+17
View File
@@ -0,0 +1,17 @@
import { timingSafeEqual } from "node:crypto";
/** Per-launch desktop HTTP capability. This is not an OS-user malware
* boundary and is not an OAuth credential. Browser-only mode is unchanged. */
export function desktopAPIAuthorized(
headers: Headers,
env: Record<string, string | undefined> = process.env,
): boolean {
const expected = env.LH_DESKTOP_API_SECRET;
if (env.LH_DESKTOP_SERVER !== "1" && !expected) return true;
if (!expected || expected.length < 64) return false;
const supplied = headers.get("x-learnharness-desktop");
if (!supplied) return false;
const actual = Buffer.from(supplied);
const wanted = Buffer.from(expected);
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "../core/graph-deletion.ts";
import { extractionStatuses } from "../agent/subagents/extraction.ts";
import { stores } from "./stores.ts";
import { callCost } from "./pricing.ts";
/**
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
.readSources()
.filter((s) => s.extractions?.length)
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources });
return graphCostSummary({ chats, sources }, callCost);
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
+2
View File
@@ -8,6 +8,7 @@ export * from "./chat-handler.ts";
export * from "./events-stream.ts";
export * from "./chats.ts";
export * from "./graph.ts";
export { NotesImportError } from "../agent/subagents/extraction.ts";
export * from "./commit-positions.ts";
export * from "./jobs.ts";
export * from "./stores.ts";
@@ -42,3 +43,4 @@ 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 { sessionCost } from "./pricing.ts";
+66 -5
View File
@@ -66,7 +66,9 @@ import {
import type { TurnInput } from "../core/input-events.ts";
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts";
import { stores, effectiveSettings } from "./stores.ts";
import { resolveConversationSpec } from "../core/settings-resolution.ts";
import { connectionBackgroundAllowed, isSubscriptionProvider, onSubscriptionChange } from "../agent/subscription-connections.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import type { Agent } from "@earendil-works/pi-agent-core";
@@ -111,6 +113,8 @@ interface WakeState {
pumping: Set<string>;
/** Throttle bookkeeping for the stale-job sweep. */
lastSweepAt: number;
stopConnectionWatch?: () => void;
automaticDeferred: WeakSet<Agent>;
}
interface LhWakeGlobal {
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
function state(): WakeState {
const g = globalThis as typeof globalThis & LhWakeGlobal;
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0 });
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0, automaticDeferred: new WeakSet() });
}
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
* beats the loop's synchronous start-poll drain and the marker rides the
* run's FIRST LLM call (the native equivalent of the old continuation
* coalescing). */
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
if (agent && state().automaticDeferred.has(agent)) return false;
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
return connectionBackgroundAllowed(provider);
}
function handOffEligible(chatId: string, agent: Agent): void {
const queue = state().queues.get(chatId);
if (queue && queue.length > 0) {
// Keep automatic results in their owning registry. Never mix them into a
// foreground batch without express account consent. Learner steers below
// 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") {
agent.steer(entry.steer);
@@ -290,9 +303,25 @@ export function reconcileUserSteers(chatId: string, persisted: readonly LhMessag
/** Clear the live agent at settlement — MUST run before settleSteers so the
* reconciliation's re-pump can never hand a marker to the dying agent. */
export function unregisterLiveAgent(chatId: string): void {
const agent = state().liveAgents.get(chatId);
if (agent) state().automaticDeferred.delete(agent);
state().liveAgents.delete(chatId);
}
/** Restore exact registry objects after native drain but before dispatch.
* The vehicle is disposable; result ownership/promises are not. */
export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>): void {
const s = state();
s.automaticDeferred.add(agent);
for (const entry of s.queues.get(chatId) ?? []) {
if (entry.handedTo === agent && automaticIds.has(entry.steer.id)) entry.handedTo = undefined;
}
agent.clearSteeringQueue();
for (const entry of s.userSteers.get(chatId) ?? []) {
if (!retainedIds.has(entry.message.id)) { agent.steer(entry.message); entry.handedTo = agent; }
}
}
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise<void> {
const s = state();
if (s.pumping.has(chatId)) return;
s.pumping.add(chatId);
let parked = false;
try {
for (let attempt = 0; ; ) {
const queue = s.queues.get(chatId);
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise<void> {
return;
}
s.queues.set(chatId, stillPending);
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
// streaming turn takes every eligible marker; pi injects at the
@@ -525,7 +556,8 @@ async function pump(chatId: string): Promise<void> {
// hands them to that turn once the text-claim lands — until then,
// settlement re-pumps and lands them right after it). The pause is
// derived from the tail, so any user message un-holds by construction.
if (endsInDismissal(fresh.messages)) {
if (endsInDismissal(fresh.messages) || !automaticFollowUpAllowed(chatId)) {
parked = true;
return;
}
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise<void> {
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);
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise<void> {
// 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 (q.some((e) => e.handedTo === undefined)) void pump(chatId);
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
}
}
// Connection changes are the only wake for consent-deferred automatic work.
// No retry timer, inference, or API-key fallback is started while consent is off.
state().stopConnectionWatch?.();
state().stopConnectionWatch = onSubscriptionChange(() => {
const s = state();
for (const [chatId, agent] of s.liveAgents) {
if (!isSubscriptionProvider(agent.state?.model?.provider) || automaticFollowUpAllowed(chatId, agent)) continue;
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
if (pending.length === 0) continue;
agent.clearSteeringQueue();
for (const entry of pending) entry.handedTo = undefined;
for (const entry of s.userSteers.get(chatId) ?? []) if (!consumed.has(entry.message.id)) entry.handedTo = undefined;
handOffEligible(chatId, agent); // only unconsumed learner input; never replay consumed steers
}
for (const chatId of s.queues.keys()) {
if (automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) void pump(chatId);
}
});
/** Flip the job to its final status now that the lesson has resumed. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
+2
View File
@@ -52,6 +52,7 @@ import {
validateKeyShape,
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.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
@@ -200,6 +201,7 @@ 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 (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
}
+26 -7
View File
@@ -20,6 +20,20 @@
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 { 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";
export async function refreshConnectionCatalog(provider?: string): Promise<void> {
await Promise.all([
!provider || provider === "chatgpt" ? refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses")) : Promise.resolve(),
!provider || nativeSubscriptionId(provider) ? refreshSubscriptionModels() : Promise.resolve(),
]);
}
function connectionModel(provider: string, id: string): Model<Api> | 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)]; }
import { settingsStore } from "../store/settings.ts";
import { effectiveSettings } from "./stores.ts";
import { keyValue } from "./keys.ts";
@@ -125,7 +139,10 @@ export function catalogProviders(): CatalogProvider[] {
id: p.id,
envVar: p.envVar,
keyPresent: keyValue(p.envVar) !== undefined,
})).sort((a, b) => a.id.localeCompare(b.id));
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
]).sort((a, b) => a.id.localeCompare(b.id));
}
/** The providers whose key is present — the picker federates exactly
@@ -171,6 +188,7 @@ export function catalogContextLimits(): Map<string, number> {
index.set(`${provider.id}:${model.id}`, model.contextWindow);
}
}
for (const model of [...chatGPTModels(), ...nativeSubscriptionModels()]) index.set(`${model.provider}:${model.id}`, model.contextWindow);
return index;
}
@@ -185,7 +203,8 @@ export function modelCatalog(): ModelCatalogResponse {
.filter((p) => keyed.has(p.id))
.flatMap((p) => piModels().getModels(p.id))
.map(toModelDto)
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
.concat([...chatGPTModels(), ...nativeSubscriptionModels()].map(toModelDto));
return {
models,
defaults: catalogDefaults(),
@@ -207,13 +226,13 @@ export type SettingsValidation =
* the model the picker writes against (settings.model, else config's
* prober/teacher pin; one controller, so the two specs share it). */
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
const providers = piModels().getProviders().map((p) => p.id);
const providers = connectionProviderIds();
if (settings.model) {
const { provider, id } = settings.model;
if (!providers.includes(provider)) {
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
}
if (!piModels().getModel(provider, id)) {
if (!connectionModel(provider, id)) {
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
}
}
@@ -237,13 +256,13 @@ export function validateModelsSettings(settings: ModelsSettings): SettingsValida
}
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
const providers = piModels().getProviders().map((p) => p.id);
const providers = connectionProviderIds();
if (slot.model) {
const { provider, id } = slot.model;
if (!providers.includes(provider)) {
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
}
if (!piModels().getModel(provider, id)) {
if (!connectionModel(provider, id)) {
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
}
}
@@ -265,7 +284,7 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
* or the level already supported — the existence gate rejects unknown
* models separately, so this only ever sees real ones in practice). */
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
const model = piModels().getModel(provider, id);
const model = connectionModel(provider, id);
return model ? clampThinkingLevel(model, level) : level;
}
+5 -3
View File
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
import { listJobs } from "./jobs.ts";
import { jobCost } from "./pricing.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
* model is missing from the catalog (the blob then renders no context
* line — never a fake window). */
export interface ActivityJob extends JobRecord {
costUsd: number | null;
title: string;
contextLimit: number | null;
}
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
* raw usage rides on the records and the client folds it with the pure
* helpers in core/prices.ts (transport stays thin). */
* the blob disappears when the process ends. Costs are joined here through
* the server-side Pi pricing adapter; clients never reconstruct rates. */
/** The dock's whole world + the graph's version stamp (wave 6 session
* deletion): the payload carries graphRevision (max mtime across the
* graph data files — see server/graph.ts) so any view can detect "the
@@ -78,6 +79,7 @@ export function activityFeed(): {
if (job.status === "running") {
jobs.push({
...job,
costUsd: jobCost(job),
title: chat.title ?? chat.id,
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
});
+19 -3
View File
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-c
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";
/**
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
@@ -71,6 +72,7 @@ export type TurnWireEvent =
| { type: "error"; errorText: string };
export interface PiTurnSpec {
requestCycle?: ChatGPTRequestCycle;
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The handler's error-masking policy for LOOP-level failures (the
@@ -117,7 +119,7 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100 } = spec;
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
@@ -248,11 +250,24 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
settleWith();
}
};
agent.subscribe(handle);
requestCycle?.bindRelease(handle);
agent.subscribe((event) => {
if (!requestCycle?.hold(event)) handle(event);
});
void (async () => {
try {
await agent.continue();
do {
await agent.continue();
if (!requestCycle) break;
const denied = requestCycle.deniedBeforeDispatch;
const outcome = requestCycle.finishNativeCycle();
if (denied) agent.state.messages = outcome.messages;
if (outcome.action !== "resumeForeground") {
handle({ type: "agent_end", messages: [...agent.state.messages] });
break;
}
} while (!sawAbort);
} catch (err) {
// A loop-level failure (not a provider error — those ride the
// stream as stopReason "error"): surface, close, persist. If the
@@ -318,6 +333,7 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
abort() {
if (settled) return;
sawAbort = true;
requestCycle?.abort();
agent.abort();
},
};
+50
View File
@@ -0,0 +1,50 @@
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
* Never mutate saved native messages: calculateCost mutates its usage.cost.
*/
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.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;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
const model = catalogModel(modelId);
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
const input = call.inputTokens;
const output = call.outputTokens;
const cacheRead = call.cacheReadTokens ?? 0;
const cacheWrite = call.cacheWriteTokens ?? 0;
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
// Old records discarded this split. Only price them if it cannot affect
// the result; do not invent short-retention precision from a missing field.
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
const usage: Usage = {
input, output, cacheRead, cacheWrite, cacheWrite1h,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const total = calculateCost(model, usage).total;
return Number.isFinite(total) && total >= 0 ? total : null;
}
export function turnCost(turn: TurnUsage): number | null {
return foldTurnCost(turn, callCost);
}
export function sessionCost(record: Pick<ChatRecord, "turns">) {
return foldSessionCost(record, callCost);
}
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
const usage = job.usage;
if (!usage || usage.steps < 1) return null;
const hasCalls = usage.calls?.length === usage.steps;
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
return foldJobCost(job, callCost);
}
+3
View File
@@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server");
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
assert.equal(unknownJob.costUsd, null);
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
assert.equal(bareJob.costUsd, null);
} finally {
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
const jobsDir = path.join(PATHS.data, "jobs");

Some files were not shown because too many files have changed in this diff Show More