feat: add verified desktop ChatGPT subscription connection

This commit is contained in:
amosblomqvist
2026-10-24 12:17:08 +03:00
parent ae2fad56c4
commit 0fc6d4ab94
53 changed files with 2557 additions and 140 deletions
+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 { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.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 provider !== "chatgpt" || chatGPTBackgroundAllowed();
}
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 = onChatGPTChange(() => {
const s = state();
for (const [chatId, agent] of s.liveAgents) {
if (agent.state?.model?.provider !== "chatgpt" || 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);