From 480a29aaeafa80ed48173a57f93f47d522877d05 Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Sat, 24 Oct 2026 21:56:57 +0300
Subject: [PATCH 1/9] 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.
---
electron-builder.yml | 5 +
electron/main.cjs | 6 +-
electron/server-title-guard.cjs | 24 +++++
tests/server-title-guard.test.ts | 158 +++++++++++++++++++++++++++++++
4 files changed, 192 insertions(+), 1 deletion(-)
create mode 100644 electron/server-title-guard.cjs
create mode 100644 tests/server-title-guard.test.ts
diff --git a/electron-builder.yml b/electron-builder.yml
index bd28156..cb0dce6 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -100,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
diff --git a/electron/main.cjs b/electron/main.cjs
index 521f47a..61c5af3 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -196,7 +196,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: {
diff --git a/electron/server-title-guard.cjs b/electron/server-title-guard.cjs
new file mode 100644
index 0000000..de186d4
--- /dev/null
+++ b/electron/server-title-guard.cjs
@@ -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);
+ },
+ });
+}
diff --git a/tests/server-title-guard.test.ts b/tests/server-title-guard.test.ts
new file mode 100644
index 0000000..503b68c
--- /dev/null
+++ b/tests/server-title-guard.test.ts
@@ -0,0 +1,158 @@
+/** Backend title isolation without launching Electron or touching a user profile. */
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { describe, it } from "node:test";
+import { fileURLToPath } from "node:url";
+import { runInNewContext } from "node:vm";
+
+const require = createRequire(import.meta.url);
+const root = fileURLToPath(new URL("../", import.meta.url));
+const mainFile = path.join(root, "electron/main.cjs");
+const mainSource = readFileSync(mainFile, "utf8");
+const mainRequire = createRequire(mainFile);
+const guardSource = readFileSync(path.join(root, "electron/server-title-guard.cjs"), "utf8");
+const backendEnv = { ELECTRON_RUN_AS_NODE: "1", LH_DESKTOP_SERVER: "1" };
+
+function titleFixture(platform = "darwin", env: Record = backendEnv) {
+ const nativeWrites: unknown[] = [];
+ let nativeReads = 0;
+ const process = { platform, env } as { platform: string; env: Record; title: unknown };
+ Object.defineProperty(process, "title", {
+ configurable: true, enumerable: true,
+ get() { nativeReads++; return "initial native title"; },
+ set(value) { nativeWrites.push(value); },
+ });
+ const original = Object.getOwnPropertyDescriptor(process, "title");
+ runInNewContext(guardSource, { process });
+ return { process, nativeWrites, original, nativeReads: () => nativeReads };
+}
+
+describe("macOS backend process.title guard", () => {
+ it("reads the initial title once, then isolates strict-mode writes from the native setter", () => {
+ const fixture = titleFixture();
+ assert.equal(fixture.nativeReads(), 1);
+ assert.equal(fixture.process.title, "initial native title");
+ runInNewContext('"use strict"; process.title = "next-server (v16.3.2)";', { process: fixture.process });
+ assert.equal(fixture.process.title, "next-server (v16.3.2)");
+ fixture.process.title = "next-router-worker";
+ assert.equal(fixture.process.title, "next-router-worker");
+ assert.deepEqual(fixture.nativeWrites, []);
+ assert.equal(fixture.nativeReads(), 1);
+ const descriptor = Object.getOwnPropertyDescriptor(fixture.process, "title")!;
+ assert.equal(typeof descriptor.get, "function");
+ assert.equal(typeof descriptor.set, "function");
+ assert.equal(descriptor.configurable, true);
+ assert.equal(descriptor.enumerable, true);
+ });
+
+ it("uses the approved String(value) contract, without native length truncation", () => {
+ const fixture = titleFixture();
+ for (const value of [42, true, null, undefined, Symbol("title"), "x".repeat(4096)]) {
+ fixture.process.title = value;
+ assert.equal(fixture.process.title, String(value));
+ }
+ let coercions = 0;
+ fixture.process.title = { toString() { coercions++; return "custom title"; } };
+ assert.equal(fixture.process.title, "custom title");
+ assert.equal(coercions, 1);
+ assert.throws(() => { fixture.process.title = { toString() { throw new Error("conversion failed"); } }; }, /conversion failed/);
+ assert.equal(fixture.process.title, "custom title");
+ assert.deepEqual(fixture.nativeWrites, []);
+ });
+
+ it("can be loaded again without restoring the native accessor", () => {
+ const fixture = titleFixture();
+ fixture.process.title = "remembered";
+ runInNewContext(guardSource, { process: fixture.process });
+ assert.equal(fixture.process.title, "remembered");
+ fixture.process.title = "next-server";
+ assert.deepEqual(fixture.nativeWrites, []);
+ assert.equal(fixture.nativeReads(), 1);
+ });
+
+ for (const [label, platform, env] of [
+ ["Windows", "win32", backendEnv],
+ ["Linux", "linux", backendEnv],
+ ["full-Electron browser helper", "darwin", {}],
+ ["non-backend Node child", "darwin", { ELECTRON_RUN_AS_NODE: "1" }],
+ ["non-Node-mode process", "darwin", { LH_DESKTOP_SERVER: "1" }],
+ ] as const) {
+ it(`leaves the original property untouched for ${label}`, () => {
+ const fixture = titleFixture(platform, env);
+ assert.deepEqual(Object.getOwnPropertyDescriptor(fixture.process, "title"), fixture.original);
+ assert.equal(fixture.nativeReads(), 0);
+ assert.deepEqual(fixture.nativeWrites, []);
+ });
+ }
+});
+
+async function serverLaunch(platform: string) {
+ let launch!: { binary: string; args: string[]; options: { cwd: string; env: Record; stdio: string[] } };
+ const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() });
+ const context = {
+ require: (id: string) => id === "electron" ? {
+ app: { setName() {}, requestSingleInstanceLock: () => false, quit() {}, getPath: () => "/fixture/user-data" },
+ } : id === "node:child_process" ? {
+ spawn(binary: string, args: string[], options: typeof launch.options) {
+ launch = JSON.parse(JSON.stringify({ binary, args, options }));
+ return child;
+ },
+ } : mainRequire(id),
+ process: { platform, argv: [], env: { FIXTURE: "kept" }, on() {}, execPath: "/fixture/LearnHarness", resourcesPath: "/fixture/resources with spaces" },
+ __dirname: path.dirname(mainFile),
+ };
+ runInNewContext(mainSource, context, { filename: mainFile });
+ await runInNewContext("startServer(54321)", context);
+ return launch;
+}
+
+describe("backend-only preload wiring and packaging", () => {
+ for (const platform of ["darwin", "win32", "linux"]) {
+ it(`${platform} uses the real server launch with the intended argument order`, async () => {
+ const launch = await serverLaunch(platform);
+ const entry = path.join("/fixture/resources with spaces", "server", "standalone", "server.js");
+ assert.equal(launch.binary, "/fixture/LearnHarness");
+ assert.deepEqual(launch.args, platform === "darwin"
+ ? ["--require", path.join("/fixture/resources with spaces", "server-title-guard.cjs"), entry]
+ : [entry]);
+ assert.equal(launch.options.cwd, path.dirname(entry));
+ assert.equal(launch.options.env.NODE_OPTIONS, undefined, "do not add NODE_OPTIONS to the child environment");
+ assert.equal(launch.options.env.ELECTRON_RUN_AS_NODE, "1");
+ assert.equal(launch.options.env.LH_DESKTOP_SERVER, "1");
+ assert.equal(launch.options.env.LH_DATA_DIR, path.join("/fixture/user-data", "data"));
+ assert.equal(launch.options.env.LH_AUTH_FILE, path.join("/fixture/user-data", "auth.json"));
+ assert.equal(launch.options.env.PORT, "54321");
+ assert.equal(launch.options.env.HOSTNAME, "127.0.0.1");
+ assert.equal(launch.options.env.FIXTURE, "kept");
+ assert.deepEqual(launch.options.stdio, ["ignore", "pipe", "pipe", "ipc"]);
+ });
+ }
+
+ it("electron-builder copies the guard outside ASAR only on macOS, retaining shared resources", async () => {
+ const yaml = require("js-yaml");
+ const config = yaml.load(readFileSync(path.join(root, "electron-builder.yml"), "utf8"));
+ const { getFileMatchers, copyFiles } = require("app-builder-lib/out/fileMatcher.js");
+ const destination = mkdtempSync(path.join(tmpdir(), "lh-title-resource-"));
+ try {
+ for (const platform of ["mac", "win", "linux"]) {
+ const matchers = getFileMatchers(config, "extraResources", destination, {
+ defaultSrc: root, customBuildOptions: config[platform], macroExpander: (value: string) => value,
+ globalOutDir: path.join(root, "dist"),
+ });
+ assert.ok(matchers.some((matcher: { to: string }) => matcher.to === path.join(destination, "server")), "shared standalone payload remains included");
+ const guards = matchers.filter((matcher: { to: string }) => matcher.to === path.join(destination, "server-title-guard.cjs"));
+ assert.equal(guards.length, platform === "mac" ? 1 : 0);
+ if (platform === "mac") {
+ await copyFiles(guards, null, false);
+ assert.equal(readFileSync(path.join(destination, "server-title-guard.cjs"), "utf8"), guardSource);
+ }
+ }
+ } finally {
+ rmSync(destination, { recursive: true, force: true });
+ }
+ });
+});
From e8c81faad991a5556482213332b70d66c67039eb Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Sat, 24 Oct 2026 23:02:10 +0300
Subject: [PATCH 2/9] ci: notify website when a stable desktop release is
published
---
.github/workflows/notify-website.yml | 49 ++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 .github/workflows/notify-website.yml
diff --git a/.github/workflows/notify-website.yml b/.github/workflows/notify-website.yml
new file mode 100644
index 0000000..cbf11f6
--- /dev/null
+++ b/.github/workflows/notify-website.yml
@@ -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.'
From 671d2c8bb7a07ab45a96f845a5f68d68f238ada5 Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Sat, 24 Oct 2026 23:55:27 +0300
Subject: [PATCH 3/9] fix: restore final provider failure in chat error banner
---
components/session-core.ts | 37 +++++----
tests/session-core.test.ts | 152 ++++++++++++++++++++++++++++++++++++-
2 files changed, 173 insertions(+), 16 deletions(-)
diff --git a/components/session-core.ts b/components/session-core.ts
index 2d765fa..c0d7247 100644
--- a/components/session-core.ts
+++ b/components/session-core.ts
@@ -227,7 +227,10 @@ export class SessionModel {
/** 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;
@@ -258,12 +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],
- contextTokens: measuredContextTokens([...this.base, ...this.tail], this.incompleteUsageIds),
+ messages: projectView(messages, live),
+ nativeMessages: messages,
+ contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
status: this.statusValue,
- error: this.errorValue,
+ error: this.errorValue ?? providerError,
steerQueue: this.steerQueueValue,
};
}
@@ -279,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?.();
@@ -369,8 +383,10 @@ 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);
- else this.incompleteUsageIds.add(id);
+ if (agent.type === "message_end") {
+ this.incompleteUsageIds.delete(id);
+ if (message.role === "assistant" && message.stopReason === "error") this.liveFailureId = id;
+ } else this.incompleteUsageIds.add(id);
if (agent.type === "message_start") {
this.streamingMessageId = id;
this.streamingIds.add(id);
@@ -385,15 +401,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
}
diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts
index f13e614..631769f 100644
--- a/tests/session-core.test.ts
+++ b/tests/session-core.test.ts
@@ -25,7 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c
import type { ChatInput } from "../src/core/input-events.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts";
-import { projectView } from "../src/core/index.ts";
+import { newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts";
import { dismissedAnswer } from "../src/core/question-core.ts";
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
@@ -483,6 +483,156 @@ it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to
});
});
+// ─── provider failures: one final-entry rule for live and saved records ──────
+
+describe("provider-error banner (live and saved)", () => {
+ function failed(id = "failed", errorMessage: string | undefined = "Google 500: fixture failure"): LhMessage {
+ return { ...assistantMsg(id, ""), content: [], stopReason: "error", errorMessage } as LhMessage;
+ }
+
+ it("restores an empty failed response on load and full sync without adding a transcript card", () => {
+ const record = [newUserMessage("topic"), failed()];
+ const m = model({ initialMessages: record });
+ assert.equal(m.getState().error?.message, "Google 500: fixture failure");
+ assert.equal(m.getState().status, "ready");
+ assert.equal(m.readyRef, true, "a restored provider failure does not block retry");
+ assert.deepEqual(m.getState().nativeMessages, record, "diagnostic history stays intact");
+ assert.equal(m.getState().messages.length, 1, "only the user bubble, no permanent error card");
+ const synced = model();
+ synced.setMessages(record);
+ assert.equal(synced.getState().error?.message, m.getState().error?.message);
+ const adopted = model({ initialMessages: record.slice(0, 1) });
+ adopted.adoptRecord(record);
+ assert.equal(adopted.getState().error?.message, m.getState().error?.message);
+ });
+
+ it("uses the existing fallback wording when the saved failure has no error text", () => {
+ const withoutText = { ...failed(), errorMessage: undefined } as LhMessage;
+ assert.equal(model({ initialMessages: [withoutText] }).getState().error?.message, "An error occurred.");
+ assert.equal(model().getState().error, null);
+ });
+
+ it("any later entry supersedes the failure, including plan/compaction markers and aborts", () => {
+ const failure = failed();
+ const later: LhMessage[] = [
+ newUserMessage("retry"),
+ assistantMsg("success", "Hello"),
+ { ...assistantMsg("aborted", ""), stopReason: "aborted", errorMessage: "Aborted" } as LhMessage,
+ newThinkingMessage("problem", "my attempt"),
+ resultMsg("call"),
+ newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }),
+ newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }),
+ ];
+ for (const entry of later) {
+ const m = model({ initialMessages: [failure] });
+ m.applyWireEvent({ type: "message_end", message: failure });
+ assert.ok(m.getState().error, "the live failure is initially visible");
+ m.setMessages([failure, entry]);
+ assert.equal(m.getState().error, null, `live banner superseded by ${entry.role}`);
+ assert.equal(model({ initialMessages: [failure, entry] }).getState().error, null, "reopened view agrees");
+ }
+ });
+
+ it("a newer live message supersedes the failure before its response finishes", () => {
+ const m = model();
+ m.applyWireEvent({ type: "message_end", message: failed() });
+ assert.ok(m.getState().error);
+ m.applyWireEvent({ type: "message_start", message: assistantMsg("next", "") });
+ assert.equal(m.getState().error, null);
+ m.applyWireEvent({ type: "message_end", message: assistantMsg("next", "Hello") });
+ assert.equal(m.getState().error, null);
+ });
+
+ it("does not treat an unfinished assistant's error-shaped partial as a completed failure", () => {
+ const m = model();
+ const failure = failed();
+ m.applyWireEvent({ type: "message_start", message: failure });
+ assert.equal(m.getState().error, null);
+ m.applyWireEvent({ type: "message_update", message: failure });
+ assert.equal(m.getState().error, null);
+ m.applyWireEvent({ type: "message_end", message: failure });
+ assert.equal(m.getState().error?.message, "Google 500: fixture failure");
+ });
+
+ for (const input of [{ type: "text", text: "retry" }, { type: "continue" }] as const) {
+ it(`hides the old failure during the optimistic ${input.type} wait, then shows a new live failure`, async () => {
+ let acknowledge!: (response: Response) => void;
+ const ack = new Promise((resolve) => { acknowledge = resolve; });
+ const old = failed("old", "old failure");
+ const fresh = failed("fresh", "new failure");
+ let finish!: () => void;
+ const stream = new ReadableStream({ start(controller) {
+ finish = () => {
+ controller.enqueue(new TextEncoder().encode(sse("[DONE]")));
+ controller.close();
+ };
+ } });
+ const m = model({ initialMessages: [old], fetch: async (url) => {
+ if (url === "/api/chat") return ack;
+ if (url === "/api/chats/chat_x") return Response.json({ messages: [old] });
+ if (url === "/api/chats/chat_x/stream") return new Response(stream);
+ throw new Error(`unexpected fetch: ${url}`);
+ } });
+ assert.equal(m.getState().error?.message, "old failure");
+ const sending = m.send(input);
+ assert.equal(m.getState().status, "submitted");
+ assert.equal(m.getState().error, null, "hidden even before the acknowledgement");
+ acknowledge(Response.json({ ok: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(m.getState().error, null, "refetching the old failed record does not resurrect it");
+ m.applyWireEvent({ type: "message_start", message: fresh });
+ assert.equal(m.getState().error, null, "no error from an unfinished partial");
+ m.applyWireEvent({ type: "message_end", message: fresh });
+ assert.equal(m.getState().status, "submitted");
+ assert.equal(m.getState().error?.message, "new failure", "new failure appears before stream closure");
+ finish();
+ await sending;
+ assert.equal(m.getState().error?.message, "new failure");
+ });
+ }
+
+ it("hides the old failure immediately on fresh-mount reattach, and restores final-entry truth at idle", async () => {
+ let settle!: (response: Response) => void;
+ const response = new Promise((resolve) => { settle = resolve; });
+ const m = model({ initialMessages: [failed()], fetch: async () => response });
+ const attaching = m.resumeStream(false);
+ assert.equal(m.getState().status, "submitted");
+ assert.equal(m.getState().error, null);
+ settle(sseResponse({ type: "turn-settled" }, "[DONE]"));
+ await attaching;
+ assert.equal(m.getState().error?.message, "Google 500: fixture failure", "no newer entry superseded it");
+ });
+
+ it("recovers a failure that settled before the submitter could attach to its stream", async () => {
+ const record = [newUserMessage("topic"), failed()];
+ const m = model({ fetch: async (url) => {
+ if (url === "/api/chat") return Response.json({ ok: true });
+ if (url === "/api/chats/chat_x") return Response.json({ messages: record });
+ if (url === "/api/chats/chat_x/stream") return sseResponse({ type: "turn-settled" }, "[DONE]");
+ throw new Error(`unexpected fetch: ${url}`);
+ } });
+ m.setReattachSettled(() => m.setMessages(record));
+ await m.send({ type: "text", text: "topic" });
+ assert.equal(m.getState().status, "ready");
+ assert.equal(m.getState().error?.message, "Google 500: fixture failure");
+ });
+
+ it("keeps loop/request errors transient, with the existing precedence and clearError behavior", async () => {
+ const m = model({ initialMessages: [failed()] });
+ m.applyWireEvent({ type: "error", errorText: "loop failure" });
+ assert.equal(m.getState().error?.message, "loop failure");
+ assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error?.message, "Google 500: fixture failure");
+ m.clearError();
+ assert.equal(m.getState().error?.message, "Google 500: fixture failure");
+ m.fetch = async () => new Response("request refused", { status: 500 });
+ await m.send({ type: "text", text: "retry" });
+ assert.equal(m.getState().error?.message, "request refused");
+ assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error, null, "request errors are not fabricated as saved messages");
+ m.clearError();
+ assert.equal(m.getState().error, null);
+ });
+});
+
// ─── reattach (resumeStream) ────────────────────────────────────────────────
describe("resumeStream (the reattach path)", () => {
From 5cf4e589be25165d550e2a6012cc8eb481e6fa74 Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Sun, 25 Oct 2026 00:00:29 +0300
Subject: [PATCH 4/9] fix: keep current provider failures authoritative across
reattach
---
components/session-core.ts | 29 ++++++++++++++++------
tests/session-core.test.ts | 51 ++++++++++++++++++++++++++++++++++++++
2 files changed, 72 insertions(+), 8 deletions(-)
diff --git a/components/session-core.ts b/components/session-core.ts
index c0d7247..657d0da 100644
--- a/components/session-core.ts
+++ b/components/session-core.ts
@@ -385,7 +385,12 @@ export class SessionModel {
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;
+ 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;
@@ -440,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 {
+ async consumeStream(res: Response, controller = this.consumeAbort): Promise {
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);
@@ -460,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");
+ }
}
}
@@ -512,8 +522,10 @@ 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!;
@@ -534,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");
}
diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts
index 631769f..7e5ecd7 100644
--- a/tests/session-core.test.ts
+++ b/tests/session-core.test.ts
@@ -617,6 +617,57 @@ describe("provider-error banner (live and saved)", () => {
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
});
+ it("a newer live provider failure replaces an earlier attachment error immediately", async () => {
+ let attempts = 0;
+ const seen: Array<{ status: string; error: string | undefined }> = [];
+ const m = model({ fetch: async () => {
+ if (++attempts === 1) return new Response("earlier attach failure", { status: 500 });
+ return sseResponse(
+ { type: "message_start", message: failed("new", "new provider failure") },
+ { type: "message_end", message: failed("new", "new provider failure") },
+ "[DONE]",
+ );
+ } });
+ await m.resumeStream(false);
+ assert.equal(m.getState().error?.message, "earlier attach failure");
+ m.onChange = () => { seen.push({ status: m.status, error: m.getState().error?.message }); };
+ await m.resumeStream(false);
+ assert.ok(seen.some((s) => s.status === "streaming" && s.error === "new provider failure"), "new failure wins before stream closure");
+ assert.equal(m.getState().error?.message, "new provider failure");
+ assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error?.message, "new provider failure", "live and reopened views agree");
+ });
+
+ it("an aborted older consume cannot restore the old banner during a replacement reattach", async () => {
+ let attempts = 0;
+ let firstRead!: () => void;
+ const reading = new Promise((resolve) => { firstRead = resolve; });
+ let settleReplacement!: (response: Response) => void;
+ const replacement = new Promise((resolve) => { settleReplacement = resolve; });
+ const m = model({ initialMessages: [failed()], fetch: async (_url, init) => {
+ if (++attempts > 1) return replacement;
+ return new Response(new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(sse({ type: "agent_start" })));
+ init?.signal?.addEventListener("abort", () => controller.error(new DOMException("Aborted", "AbortError")), { once: true });
+ },
+ pull() { firstRead(); },
+ }));
+ } });
+ const first = m.resumeStream(false);
+ await reading;
+ const second = m.resumeStream(false);
+ await first;
+ assert.equal(m.getState().status, "submitted", "old cleanup must not settle the replacement attempt");
+ assert.equal(m.getState().error, null, "old saved failure stays hidden during the replacement's Thinking wait");
+ settleReplacement(sseResponse(
+ { type: "message_start", message: failed("new", "replacement failure") },
+ { type: "message_end", message: failed("new", "replacement failure") },
+ "[DONE]",
+ ));
+ await second;
+ assert.equal(m.getState().error?.message, "replacement failure");
+ });
+
it("keeps loop/request errors transient, with the existing precedence and clearError behavior", async () => {
const m = model({ initialMessages: [failed()] });
m.applyWireEvent({ type: "error", errorText: "loop failure" });
From d6e317c6fef6cf4e71e9e0f098a211aef7c81889 Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Sun, 25 Oct 2026 00:27:52 +0300
Subject: [PATCH 5/9] feat: add app-owned Copilot and Grok subscription
connections
---
components/activity-dock.tsx | 8 +-
components/graph-view.tsx | 17 +-
components/settings/agent-section.tsx | 3 +-
components/settings/chatgpt-connection.tsx | 43 +--
components/settings/connection-controls.tsx | 38 +++
components/settings/keys-section.tsx | 4 +-
components/settings/model-menu.tsx | 8 +-
components/settings/settings-dialog.tsx | 5 +-
.../settings/subscription-connections.tsx | 131 +++++++++
components/setup-dialog.tsx | 7 +-
components/sidebar.tsx | 3 +-
components/stats-bar.tsx | 11 +-
components/usage-format.ts | 6 +-
docs/architecture.md | 12 +-
electron/main.cjs | 31 ++-
electron/preload.cjs | 15 ++
electron/subscription-ipc.cjs | 45 ++++
electron/subscription-store.cjs | 64 +++++
electron/subscriptions.cjs | 251 ++++++++++++++++++
next.config.ts | 5 +
scripts/smoke-provider-runtime.mjs | 23 ++
src/agent/chatgpt-request-cycle.ts | 126 +--------
src/agent/models.ts | 8 +-
src/agent/subagents/pi-run.ts | 2 +
src/agent/subscription-bridge.ts | 132 +++++++++
src/agent/subscription-connections.ts | 23 ++
src/agent/subscription-request-cycle.ts | 128 +++++++++
src/agent/subscription-stream.ts | 85 ++++++
src/agent/teacher-agent.ts | 3 +-
src/core/contracts/subscriptions.ts | 68 +++++
src/core/cost-summary.ts | 25 +-
src/core/index.ts | 2 +
src/core/prices.ts | 18 +-
src/server/chat-handler.ts | 32 +--
src/server/jobs.ts | 8 +-
src/server/keys.ts | 4 +-
src/server/model-catalog.ts | 22 +-
src/server/pricing.ts | 4 +-
tests/chatgpt-connection-ui.test.tsx | 6 +-
tests/chatgpt-ui.test.tsx | 2 +-
tests/chatgpt-usage.test.ts | 6 +-
tests/desktop-platform.test.ts | 3 +-
tests/graph-pricing.test.tsx | 4 +-
tests/provider-tracing.test.ts | 2 +
tests/subscription-auth.test.ts | 221 +++++++++++++++
tests/subscription-background.test.ts | 62 +++++
tests/subscription-connection-ui.test.tsx | 81 ++++++
tests/subscription-stream.test.ts | 107 ++++++++
tests/subscription-usage.test.tsx | 43 +++
49 files changed, 1709 insertions(+), 248 deletions(-)
create mode 100644 components/settings/connection-controls.tsx
create mode 100644 components/settings/subscription-connections.tsx
create mode 100644 electron/subscription-ipc.cjs
create mode 100644 electron/subscription-store.cjs
create mode 100644 electron/subscriptions.cjs
create mode 100644 src/agent/subscription-bridge.ts
create mode 100644 src/agent/subscription-connections.ts
create mode 100644 src/agent/subscription-request-cycle.ts
create mode 100644 src/agent/subscription-stream.ts
create mode 100644 src/core/contracts/subscriptions.ts
create mode 100644 tests/subscription-auth.test.ts
create mode 100644 tests/subscription-background.test.ts
create mode 100644 tests/subscription-connection-ui.test.tsx
create mode 100644 tests/subscription-stream.test.ts
create mode 100644 tests/subscription-usage.test.tsx
diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx
index 32d7469..41f456e 100644
--- a/components/activity-dock.tsx
+++ b/components/activity-dock.tsx
@@ -4,7 +4,7 @@ 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 } from "@/src/core";
+import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core";
import { formatTokens, planCostLabel } from "@/components/usage-format";
import { useServerEvent, useServerResync } from "@/components/server-events";
@@ -350,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
Background agents
{blobs.length > 0 && (
- {planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))}
+ {planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
)}
@@ -425,7 +425,7 @@ function ActivityBlobRow({
)}
- {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
+ {planCostLabel(blob.cost, subscriptionLabel(blob.model))}
{formatElapsed(now - blob.startedAt)}
@@ -464,7 +464,7 @@ function ActivityBlobRow({
Cost
- {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
+ {planCostLabel(blob.cost, subscriptionLabel(blob.model))}
diff --git a/components/graph-view.tsx b/components/graph-view.tsx
index 293bf44..9cde6cf 100644
--- a/components/graph-view.tsx
+++ b/components/graph-view.tsx
@@ -68,6 +68,7 @@ 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";
@@ -294,16 +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, subscription = false): string {
- if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`;
- 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
): string {
const stages = [costs.stages.extraction, costs.stages.audit];
- const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0);
+ 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 plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—";
+ return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`);
}
/**
@@ -405,7 +408,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{label}
- {fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 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) && (
+{stage.unpricedRuns} unpriced
)}
@@ -430,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
)}
- {fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}
+ {fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}
))}
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index 5395eec..bd7c810 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -73,6 +73,7 @@ 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). */
@@ -648,7 +649,7 @@ export function AgentSection({
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
>
- {slot !== "strong" && }
+ {slot !== "strong" && <>>}
void; busy: boolean }) {
- const ref = useRef(null);
- const closeRef = useRef(onClose);
- closeRef.current = onClose;
- const backdropDismiss = useBackdropDismiss(onClose);
- useEffect(() => {
- const previous = document.activeElement as HTMLElement | null;
- const focusable = () => [...(ref.current?.querySelectorAll('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(); }
- }
- };
- // Capture claims the nested dialog's Escape before outer Settings/setup.
- document.addEventListener("keydown", key, true);
- return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
- }, []);
- return createPortal(
-
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}
-
, document.body);
+ return {children};
}
/** Compact settings row, or setup's secondary action only. */
@@ -357,15 +332,11 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected);
return
-
- {!setup && active && ChatGPT}
- {active && !setup ? <>
-
-
- > : }
-
+
active ? ui.manage(active.id) : ui.connect()}>
+ {active && !setup && }
+
{ui.inlineError &&
{ui.inlineError}
}
{ui.warning &&
{ui.warning}
}
;
diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx
new file mode 100644
index 0000000..2486cc0
--- /dev/null
+++ b/components/settings/connection-controls.tsx
@@ -0,0 +1,38 @@
+"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
+ {label}
+ {children}
+
+
;
+}
+
+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(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('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(
+
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}
+
, document.body);
+}
diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx
index f1f2652..3269ca8 100644
--- a/components/settings/keys-section.tsx
+++ b/components/settings/keys-section.tsx
@@ -26,7 +26,7 @@
import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
-import { ChatGPTConnection } from "./chatgpt-connection.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";
@@ -456,7 +456,7 @@ export function KeysSection({
-
+
{(["Dictation", "Search"] as const).map((label) =>
diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx
index 8f46238..19697b9 100644
--- a/components/settings/model-menu.tsx
+++ b/components/settings/model-menu.tsx
@@ -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({
>
{m.name}
-
- {m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
+
+ {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
- {m.provider} · {m.id}
+ {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}
{formatTokens(m.contextWindow)}
diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx
index f0cca9f..1142bdf 100644
--- a/components/settings/settings-dialog.tsx
+++ b/components/settings/settings-dialog.tsx
@@ -50,6 +50,7 @@ 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
@@ -92,12 +93,12 @@ export function SettingsDialogProvider({
setShow(true);
}, []);
return (
-
+
{children}
{show && (
setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)}
-
+
);
}
diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx
new file mode 100644
index 0000000..b5c69a5
--- /dev/null
+++ b/components/settings/subscription-connections.tsx
@@ -0,0 +1,131 @@
+"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, 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([]);
+ const [dialog, setDialog] = useState(null);
+ 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(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 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);
+ setBackground(statusesRef.current.find((s) => s.providerId === providerId)?.allowBackgroundUse ?? true); setDialog(providerId);
+ }
+ async function control(action: () => Promise, 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);
+ }
+ 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
+ {children}
+ {dialog && descriptor && status &&
+ {confirm ? `Disconnect ${descriptor.label}?` : status.connectionId ? descriptor.label : `Connect to ${descriptor.label}`}
+ {confirm ? {subscriptionDisconnectCopy(descriptor.label)}
: <>
+ {subscriptionNotice(descriptor.label)}
+ {dialog === "github-copilot" && {COPILOT_POLICY_NOTICE}
}
+
+ { setBackground(enabled); if (status.connectionId && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
+ Allow background use{SUBSCRIPTION_BACKGROUND_HINT}
+
+ {!status.available && Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.
}
+ {status.connected && Connected
}
+ {interaction &&
+ {interaction.progress &&
{interaction.progress}
}
+ {interaction.userCode &&
Device code: {interaction.userCode}
}
+ {interaction.browser &&
}
+ {prompt &&
}
+
}
+ >}
+ {(error || status.error) && {error || status.error}
}
+
+ {!confirm && status.connectionId && !status.pending && <>
+
+ {status.error && }
+
+ >}
+
+ {confirm ? : !status.connectionId && }
+
+ }
+ ;
+}
+
+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
+
s.pending)} onAction={() => ui.open(providerId)}>
+ {status.connected && Connected}
+
+ {status.error &&
{status.error}
}
+
;
+}
+export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
+ return {NATIVE_SUBSCRIPTIONS.map((p) => )}
;
+}
+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 ;
+}
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
index 2372d01..461e507 100644
--- a/components/setup-dialog.tsx
+++ b/components/setup-dialog.tsx
@@ -44,7 +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, ChatGPTConnection } from "@/components/settings/chatgpt-connection";
+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";
@@ -211,7 +212,7 @@ export function SetupKeysBody({
)}
-
+ Subscriptions
);
}
@@ -477,7 +478,7 @@ export function SetupDialog({
)}
{SLOT_META[slot].roster}
- {slot !== "strong" && }
+ {slot !== "strong" && <>>}