import { ExtensionServerMessageSchema, type AskAnswerPayload, type AskCancelPayload, type AskCreatePayload, type AskOption, OTHER_OPTION_VALUE, type AskResult, type AskReceipt, type AskBatchReceipt, AskBatchReceiptSchema, type AskBatchQuestionDraft, AskReceiptSchema, AnswerReadResultSchema, type AnswerReadResult, type ExtensionClientMessage, type ProposeAnswerPayload, type ProposeAnswerResult, type SemanticState, type ServerProfileIdentity, type SessionRegisterPayload, type SessionShutdownReason } from "../protocol.js"; import type { QuestionChatEvent, QuestionChatAvailabilityError, QuestionChatSendPayload, QuestionChatSendResponse, QuestionChatSnapshot, QuestionChatSource, QuestionChatStopPayload, QuestionChatStopResponse } from "../protocol.js"; import { QuestionChatRuntimeError, type QuestionChatReconciliationDecision, type QuestionChatReconciliationResult, type QuestionChatRecoveryOffer } from "../questionChatRuntime.js"; import { randomUUID } from "node:crypto"; import { HealthResponseSchema, PROTOCOL_VERSION, type LocalQuestionImage, type StagedQuestionImage } from "../protocol.js"; import { prepareLocalImages, ImagePreparationError } from "../imagePreparation.js"; import WebSocket from "ws"; import type { ResolvedServerTarget, ResolveServerTargetResult } from "../serverTargetResolver.js"; import { createUrlStatusSnapshot, enrichStatusSnapshotFromLocalServer, type PostboxConnectionState, type PostboxServerIdentity, type PostboxStatusSnapshot, type PostboxStatusTailscaleInspector } from "../status.js"; import type { PostboxAutostartStatusSnapshot } from "../autostart.js"; import { MemoryAnswerNotificationInbox, type AnswerNotificationInbox } from "../answerNotificationInbox.js"; interface WebSocketLike { readyState: number; send(data: string): void; close(): void; on(event: "open" | "close" | "error" | "message", listener: (...args: unknown[]) => void): void; } type WebSocketConstructor = new (url: string) => WebSocketLike; export interface PostboxClientOptions { serverUrl: string; registration: SessionRegisterPayload; heartbeatMs?: number; reconnectMs?: number; reconnectMaxMs?: number; reconnect?: boolean; askUnavailableAfterMs?: number; resolveTarget?: () => Promise; profilePollingEnabled?: boolean; profilePollMs?: number; targetAffinityTimeoutMs?: number; proposalTimeoutMs?: number; answerReadTimeoutMs?: number; targetSource?: string; targetProfile?: ServerProfileIdentity; targetIdentity?: PostboxServerIdentity; inspectTailscale?: PostboxStatusTailscaleInspector; WebSocketImpl?: WebSocketConstructor; onStatus?: (status: string) => void; onLocalFallbackStatus?: (status: LocalFallbackStatus | undefined) => void; onOwnerQuestionStateChanged?: () => void; /** Transport is at-least-once. Consumer must apply the stable deliveryId idempotently. */ onAnswerAvailable?: (notification: { questionId: string; question: string; answerId: string }, deliveryId: string) => void; answerNotificationInbox?: AnswerNotificationInbox; questionChats?: { activate(input: { requestId: string; ownerSessionId: string; source: QuestionChatSource }): Promise; getSnapshot(requestId: string, ownerSessionId: string): Promise; send(requestId: string, ownerSessionId: string, command: QuestionChatSendPayload): Promise; stop(requestId: string, ownerSessionId: string, command: QuestionChatStopPayload): Promise; subscribe(requestId: string, listener: (event: QuestionChatEvent) => void): () => void; cleanup(requestId: string): Promise; listRecoveryOffers?(): QuestionChatRecoveryOffer[]; reconcile?(ownerSessionId: string, decisions: QuestionChatReconciliationDecision[]): Promise; }; } export interface PendingAskSnapshot { requestId: string; prompt: string; mode: AskCreatePayload["mode"]; options: AskOption[]; sentAtLeastOnce: boolean; expiresAt?: string; } export interface LocalFallbackStatus { requestId: string; serverUrl: string; message: string; } export interface LocalAnswerInput { requestId?: string; selectedValues: string[]; note?: string; } export interface LocalCancelInput { requestId?: string; note?: string; } interface LocalResolution { payload: AskCreatePayload; result: AskResult; message: ExtensionClientMessage; originServerUrl: string; targetAffinityTimer?: NodeJS.Timeout; } interface PendingAsk { payload: AskCreatePayload; resolve: (result: AskResult) => void; reject: (error: Error) => void; cleanup: () => void; sentAtLeastOnce: boolean; createdServerUrl: string; originServerUrl?: string; unavailableTimer?: NodeJS.Timeout; expiryTimer?: NodeJS.Timeout; targetAffinityTimer?: NodeJS.Timeout; createCommandId: string; signal?: AbortSignal; abort?: () => void; } interface PendingCreateReceipt { resolve(receipt: AskReceipt): void; reject(error: Error): void; } interface PendingAnswerRead { questionId: string; resolve: (result: AnswerReadResult) => void; reject: (error: Error) => void; cleanup: () => void; } interface PendingProposal { requestId: string; resolve(result: ProposeAnswerResult): void; timer: NodeJS.Timeout; signal?: AbortSignal; abort?: () => void; } interface PendingQuery { resolve(value: unknown): void; reject(error: Error): void; ownerQuestionStateChanged?: boolean; } const DEFAULT_UNAVAILABLE_AFTER_MS = 30_000; const DEFAULT_RECONNECT_MAX_MS = 30_000; const DEFAULT_PROFILE_POLL_MS = 5_000; const DEFAULT_TARGET_AFFINITY_TIMEOUT_MS = 30_000; const DEFAULT_PROPOSAL_TIMEOUT_MS = 10_000; const DEFAULT_ANSWER_READ_TIMEOUT_MS = 10_000; export class PostboxClient { private socket: WebSocketLike | undefined; private stopped = false; private heartbeatTimer: NodeJS.Timeout | undefined; private reconnectTimer: NodeJS.Timeout | undefined; private readonly heartbeatMs: number; private readonly reconnectMs: number; private readonly reconnectMaxMs: number; private nextReconnectMs: number; private readonly reconnect: boolean; private readonly askUnavailableAfterMs: number; private readonly answerReadTimeoutMs: number; private readonly WebSocketImpl: WebSocketConstructor; private readonly pendingAsks = new Map(); private readonly asynchronousAskCreates = new Set(); private readonly pendingCreateReceipts = new Map(); private readonly answerNotificationInbox: AnswerNotificationInbox; private answerNotificationOperation: Promise = Promise.resolve(); private readonly localResolutions = new Map(); private currentSemanticState: SemanticState; private imageUploadToken?: string; private readonly imageTargets = new Map(); private readonly imageGalleryTargets = new WeakMap(); private currentServerUrl: string; private connectionState: PostboxConnectionState = "disconnected"; private connectionDiagnostics: string[] = ["websocket:disconnected"]; private currentTargetSource: string | undefined; private currentTargetProfile: ServerProfileIdentity | undefined; private currentTargetIdentity: PostboxServerIdentity | undefined; private profilePollTimer: NodeJS.Timeout | undefined; private deferredTarget: ResolvedServerTarget | undefined; private readonly suppressReconnectOnClose = new WeakSet(); private readonly questionChatSubscriptions = new Map void>(); private readonly pendingRecoveryOffers = new Map(); private readonly pendingProposals = new Map(); private readonly pendingAnswerReads = new Map(); private readonly pendingQueries = new Map(); private readonly pendingPostboxWaits = new Map(); private readonly liveQuestionChats = new Map(); private readonly terminalQuestionChats = new Map(); private recoveryOffers: QuestionChatRecoveryOffer[] = []; private recoveryOfferIndex = 0; private recoveryCompleteSent = false; constructor(private readonly options: PostboxClientOptions) { this.heartbeatMs = options.heartbeatMs ?? 15_000; this.reconnectMs = options.reconnectMs ?? 5_000; this.reconnectMaxMs = options.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS; this.nextReconnectMs = this.reconnectMs; this.reconnect = options.reconnect ?? true; this.askUnavailableAfterMs = options.askUnavailableAfterMs ?? DEFAULT_UNAVAILABLE_AFTER_MS; this.answerReadTimeoutMs = options.answerReadTimeoutMs ?? DEFAULT_ANSWER_READ_TIMEOUT_MS; this.WebSocketImpl = options.WebSocketImpl ?? (WebSocket as unknown as WebSocketConstructor); this.answerNotificationInbox = options.answerNotificationInbox ?? new MemoryAnswerNotificationInbox(); this.currentSemanticState = options.registration.session.semanticState; this.currentServerUrl = options.serverUrl; this.currentTargetSource = options.targetSource; this.currentTargetProfile = options.targetProfile; this.currentTargetIdentity = options.targetIdentity; } start(): void { this.stopped = false; this.connect(); this.startProfilePolling(); } stop(): void { this.stopped = true; this.connectionState = "disconnected"; if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.profilePollTimer) clearInterval(this.profilePollTimer); for (const [, pending] of this.pendingAsks) { this.rejectCreateReceipt(pending.payload.requestId, new Error("Postbox client stopped before the Question was persisted.")); pending.cleanup(); pending.reject(new Error("Postbox client stopped")); } this.pendingAsks.clear(); for (const requestId of [...this.localResolutions.keys()]) { this.deleteLocalResolution(requestId); } this.publishLocalFallbackStatus(); for (const unsubscribe of this.questionChatSubscriptions.values()) unsubscribe(); this.questionChatSubscriptions.clear(); this.pendingRecoveryOffers.clear(); this.recoveryOffers = []; this.recoveryOfferIndex = 0; this.recoveryCompleteSent = false; this.failPendingProposals("Question Chat proposal stopped before the server responded."); this.failPendingAnswerReads("Postbox stopped before the Answer was returned."); for (const [waitRequestId, sessionId] of this.pendingPostboxWaits) { this.send({ type: "postbox.wait.cancel", requestId: `wait_cancel_${randomUUID()}`, payload: { sessionId, waitRequestId } }); } this.pendingPostboxWaits.clear(); this.failPendingQueries("Postbox stopped before the query was returned."); this.liveQuestionChats.clear(); this.terminalQuestionChats.clear(); this.socket?.close(); } updateSemanticState(state: SemanticState): boolean { this.currentSemanticState = state; return this.send({ type: "session.update", payload: { sessionId: this.options.registration.session.sessionId, semanticState: state } }); } updateQuestionSource(source: { cwd: string; agentSessionPath: string; leafId: string }): boolean { this.options.registration = { ...this.options.registration, session: { ...this.options.registration.session, ...source } }; return this.send({ type: "session.update", payload: { sessionId: this.options.registration.session.sessionId, cwd: source.cwd, agentSessionPath: source.agentSessionPath, leafId: source.leafId } }); } proposeAnswer(requestId: string, proposal: ProposeAnswerPayload, signal?: AbortSignal): Promise { if (this.terminalQuestionChats.has(requestId)) return Promise.resolve(proposalTerminalError()); if (signal?.aborted) return Promise.resolve(proposalTransportError("Question Chat proposal was aborted.")); const commandId = `chat_proposal_${randomUUID()}`; return new Promise((resolve) => { const timeoutMs = Math.max(1, Math.min(this.options.proposalTimeoutMs ?? DEFAULT_PROPOSAL_TIMEOUT_MS, 60_000)); const timer = setTimeout(() => { this.finishProposal(commandId, proposalTransportError("Question Chat proposal timed out.")); }, timeoutMs); timer.unref?.(); const abort = signal ? () => this.finishProposal(commandId, proposalTransportError("Question Chat proposal was aborted.")) : undefined; const pending: PendingProposal = { requestId, resolve, timer, signal, abort }; this.pendingProposals.set(commandId, pending); signal?.addEventListener("abort", abort!, { once: true }); if (!this.send({ type: "chat.propose-answer", requestId: commandId, payload: { requestId, proposal } })) { this.finishProposal(commandId, proposalTransportError("Question Chat proposal could not be sent while Postbox is disconnected.")); } }); } shutdownSession(reason?: SessionShutdownReason): boolean { return this.send({ type: "session.shutdown", payload: { sessionId: this.options.registration.session.sessionId, reason } }); } ask(payload: AskCreatePayload, signal?: AbortSignal): Promise { if (this.stopped) { return Promise.resolve(unavailableResult(payload.requestId, "Pi Postbox client is stopped.")); } if (signal?.aborted) { return Promise.reject(new Error("write_question was aborted")); } return new Promise((resolve, reject) => { const cleanup = () => { this.pendingAsks.delete(payload.requestId); this.asynchronousAskCreates.delete(payload.requestId); if (pending.signal && pending.abort) pending.signal.removeEventListener("abort", pending.abort); if (pending.unavailableTimer) clearTimeout(pending.unavailableTimer); if (pending.expiryTimer) clearTimeout(pending.expiryTimer); if (pending.targetAffinityTimer) clearTimeout(pending.targetAffinityTimer); this.publishLocalFallbackStatus(); this.tryApplyDeferredTarget(); }; const complete = (result: AskResult) => { this.rejectCreateReceipt(payload.requestId, new Error(`Question ended as ${result.status} before persistence acknowledgement.`)); cleanup(); resolve(result); }; const abort = () => { this.rejectCreateReceipt(payload.requestId, new Error("write_question was aborted before persistence acknowledgement")); this.cancelAskOnAbort(pending); cleanup(); reject(new Error("write_question was aborted")); }; const pending: PendingAsk = { payload, resolve: complete, reject: (error) => { cleanup(); reject(error); }, cleanup, sentAtLeastOnce: false, createdServerUrl: this.currentServerUrl, createCommandId: `ask_create_${randomUUID()}`, signal, abort }; this.pendingAsks.set(payload.requestId, pending); signal?.addEventListener("abort", abort, { once: true }); this.startExpiryTimer(pending); this.startUnavailableTimerIfNeeded(pending); this.ensureConnection(); this.publishLocalFallbackStatus(); this.sendPendingAsk(pending); }); } createAsk(payload: AskCreatePayload, signal?: AbortSignal): Promise { if (this.stopped) return Promise.reject(new Error("Pi Postbox client is stopped; the Question was not persisted.")); if (signal?.aborted) return Promise.reject(new Error("write_question was aborted before persistence acknowledgement")); if (!this.isConnected()) return Promise.reject(new Error("Pi Postbox is disconnected; the Question was not persisted.")); this.asynchronousAskCreates.add(payload.requestId); const receipt = new Promise((resolve, reject) => this.pendingCreateReceipts.set(payload.requestId, { resolve, reject })); const answer = this.ask(payload, signal); // The answer continues to be tracked independently for notification and local compatibility. void answer.catch(() => undefined); return receipt; } async prepareImages(images: LocalQuestionImage[], signal?: AbortSignal, requestId?: string): Promise { if (!images.length) return []; if (requestId) { const origin = { url: this.currentServerUrl, token: this.imageUploadToken }; const existing = await this.query("questions.get", { questionIds: [requestId], view: "control" }) as unknown[]; signal?.throwIfAborted(); if (existing.length) { const replay: StagedQuestionImage[] = []; this.imageGalleryTargets.set(replay, origin); return replay; } } const url = this.currentServerUrl; const token = this.imageUploadToken; const identity = this.currentTargetIdentity; const profile = this.currentTargetProfile; const healthResponse = await fetch(new URL("/healthz", url), { signal, redirect: "error" }); if (!healthResponse.ok) throw new ImagePreparationError("image_target_unavailable", "Image target health check failed", true); const health = HealthResponseSchema.parse(await healthResponse.json()); if (!health.mediaInstanceId || !token || (profile && profile.id !== health.profile.id) || (identity?.instanceId && identity.instanceId !== health.instance?.instanceId) || (identity?.buildId && identity.buildId !== health.buildId)) throw new ImagePreparationError("image_target_changed", "Image target changed; reconnect and retry", true); const assertTarget = () => { signal?.throwIfAborted(); if (url !== this.currentServerUrl || token !== this.imageUploadToken || !this.isConnected()) throw new ImagePreparationError("image_target_changed", "Image target changed; reconnect and retry", true); }; for (const [id, target] of this.imageTargets) if (target.expires <= Date.now()) this.imageTargets.delete(id); const prepared = await prepareLocalImages(images, this.options.registration.session.cwd, async (bytes, mediaType) => { assertTarget(); let response: Response; try { response = await fetch(new URL("/api/images/stage", url), { method: "POST", redirect: "error", signal, headers: { "content-type": mediaType, "x-postbox-session": this.options.registration.session.sessionId, "x-postbox-upload-token": token, "x-postbox-media-instance": health.mediaInstanceId!, "x-postbox-protocol-version": PROTOCOL_VERSION }, body: new Uint8Array(bytes) }); } catch { signal?.throwIfAborted(); throw new ImagePreparationError("image_upload_failed", "Image upload failed; retry staging", true); } assertTarget(); const result = await response.json() as { uploadId?: string; error?: string; message?: string; retryable?: boolean }; if (!response.ok || !result.uploadId) throw new ImagePreparationError(result.error ?? "image_upload_failed", result.message ?? "Image upload failed", result.retryable ?? true); this.imageTargets.set(result.uploadId, { url, token, expires: Date.now() + 3_600_000 }); return { uploadId: result.uploadId }; }, signal); assertTarget(); return prepared; } private assertImageTargets(images?: StagedQuestionImage[]): void { const gallery = images && this.imageGalleryTargets.get(images); if (gallery && (gallery.url !== this.currentServerUrl || gallery.token !== this.imageUploadToken)) throw new ImagePreparationError("image_target_changed", "Image target changed; retry on the verified target", true); for (const image of images ?? []) { const target = this.imageTargets.get(image.uploadId); if (target && (target.url !== this.currentServerUrl || target.token !== this.imageUploadToken)) throw new ImagePreparationError("image_target_changed", "Image target changed; restage the gallery", true); } } createAskBatch(payload: { sessionId: string; questions: AskBatchQuestionDraft[] }, signal?: AbortSignal): Promise { for (const draft of payload.questions) this.assertImageTargets(draft.images); if (!this.isConnected()) return Promise.reject(new Error("Pi Postbox is disconnected; the Question batch was not persisted.")); if (signal?.aborted) return Promise.reject(Object.assign(new Error("write_question create_batch was aborted before persistence acknowledgement"), { name: "AbortError" })); const requestId = `ask_batch_${randomUUID()}`; return new Promise((resolve, reject) => { const abort = () => { if (!this.pendingQueries.delete(requestId)) return; reject(Object.assign(new Error("write_question create_batch was aborted before persistence acknowledgement"), { name: "AbortError" })); }; this.pendingQueries.set(requestId, { resolve: (value) => { signal?.removeEventListener("abort", abort); resolve(AskBatchReceiptSchema.parse(value)); }, reject: (error) => { signal?.removeEventListener("abort", abort); reject(error); } }); signal?.addEventListener("abort", abort, { once: true }); if (!this.send({ type: "ask.batch.create", requestId, payload })) { this.pendingQueries.delete(requestId); signal?.removeEventListener("abort", abort); reject(new Error("Pi Postbox disconnected before the Question batch could be sent.")); } }); } getAnswer(questionId: string, signal?: AbortSignal): Promise { if (!this.isConnected()) return Promise.reject(new Error("Pi Postbox is disconnected; the Answer cannot be read.")); if (signal?.aborted) return Promise.reject(Object.assign(new Error("get_answer was aborted before the Answer was read."), { name: "AbortError" })); const commandId = `answer_get_${randomUUID()}`; return new Promise((resolve, reject) => { let timer: NodeJS.Timeout | undefined; const abort = () => { if (!this.pendingAnswerReads.delete(commandId)) return; pending.cleanup(); reject(Object.assign(new Error("get_answer was aborted before the Answer was read."), { name: "AbortError" })); }; const pending: PendingAnswerRead = { questionId, resolve, reject, cleanup: () => { if (timer) clearTimeout(timer); signal?.removeEventListener("abort", abort); } }; this.pendingAnswerReads.set(commandId, pending); signal?.addEventListener("abort", abort, { once: true }); timer = setTimeout(() => { if (!this.pendingAnswerReads.delete(commandId)) return; pending.cleanup(); reject(new Error("get_answer timed out; the Question may not exist or may belong to another Postbox session.")); }, this.answerReadTimeoutMs); timer.unref?.(); if (!this.send({ type: "answer.get", requestId: commandId, payload: { questionId } })) { this.pendingAnswerReads.delete(commandId); pending.cleanup(); reject(new Error("Pi Postbox disconnected before get_answer could be sent.")); } }); } query(type: "question.list" | "questions.get" | "question.status.list" | "owner.status.get" | "question.update" | "question.history.get" | "question.answer.recover", payload: any): Promise { if (type === "question.update") this.assertImageTargets(payload.update?.images); if (!this.isConnected()) return Promise.reject(new Error("Pi Postbox is disconnected.")); const requestId = `query_${randomUUID()}`; return new Promise((resolve, reject) => { this.pendingQueries.set(requestId, { resolve, reject, ownerQuestionStateChanged: type === "question.update" }); if (!this.send({ type, requestId, payload } as ExtensionClientMessage)) { this.pendingQueries.delete(requestId); reject(new Error("Query could not be sent.")); } }); } waitForPostbox(sessionId: string, signal?: AbortSignal): Promise> { const requestId = `wait_${randomUUID()}`; return new Promise((resolve, reject) => { const abort = () => { if (!this.pendingQueries.delete(requestId)) return; this.pendingPostboxWaits.delete(requestId); this.send({ type: "postbox.wait.cancel", requestId: `wait_cancel_${randomUUID()}`, payload: { sessionId, waitRequestId: requestId } }); reject(Object.assign(new Error("Postbox wait was aborted"), { name: "AbortError" })); }; if (signal?.aborted) return abort(); this.pendingPostboxWaits.set(requestId, sessionId); this.pendingQueries.set(requestId, { resolve: (value) => { this.pendingPostboxWaits.delete(requestId); signal?.removeEventListener("abort", abort); resolve(value as Record); }, reject: (error) => { this.pendingPostboxWaits.delete(requestId); signal?.removeEventListener("abort", abort); reject(error); } }); signal?.addEventListener("abort", abort, { once: true }); if (!this.send({ type: "postbox.wait", requestId, payload: { sessionId } })) { this.pendingPostboxWaits.delete(requestId); this.pendingQueries.delete(requestId); reject(new Error("Postbox wait could not be sent.")); } }); } listPendingAsks(): PendingAskSnapshot[] { return [...this.pendingAsks.values()].map((pending) => ({ requestId: pending.payload.requestId, prompt: pending.payload.question.prompt, mode: pending.payload.mode, options: pending.payload.options, sentAtLeastOnce: pending.sentAtLeastOnce, expiresAt: pending.payload.expiresAt })); } async getStatusSnapshot( autostart: PostboxAutostartStatusSnapshot = { enabled: true, startedByThisSession: false } ): Promise { let openQuestionCount = this.pendingAsks.size; const owner = this.options.registration.session.owner; if (owner && this.isConnected()) { try { const [status] = await this.query("owner.status.get", { owners: [owner] }) as Array<{ activeQuestionCount?: unknown; }>; if (Number.isInteger(status?.activeQuestionCount) && Number(status.activeQuestionCount) >= 0) { const awaitingPersistence = [...this.pendingCreateReceipts.keys()] .filter((requestId) => this.pendingAsks.has(requestId)) .length; openQuestionCount = Math.max( openQuestionCount, Number(status.activeQuestionCount) + awaitingPersistence ); } } catch { // Pending client state remains a safe fallback while the durable status query is unavailable. } } const snapshot = createUrlStatusSnapshot({ state: this.connectionState, activeUrl: this.currentServerUrl, openQuestionCount, autostart, diagnostics: this.connectionDiagnostics, source: this.currentTargetSource, profile: this.currentTargetProfile, server: this.currentTargetIdentity }); return enrichStatusSnapshotFromLocalServer(snapshot, { profile: this.currentTargetProfile, inspectTailscale: this.options.inspectTailscale }); } answerPendingAsk(input: LocalAnswerInput): AskResult { const pending = this.findPendingAsk(input.requestId); this.validateSelectedValues(pending.payload, input.selectedValues); const answer: AskAnswerPayload = { selectedValues: input.selectedValues, note: input.note }; const result: AskResult = { status: "answered", requestId: pending.payload.requestId, selectedValues: answer.selectedValues, note: answer.note, resolvedAt: new Date().toISOString() }; this.resolveLocally(pending, result, { type: "ask.answer", requestId: pending.payload.requestId, payload: { requestId: pending.payload.requestId, answer } }); return result; } cancelPendingAsk(input: LocalCancelInput = {}): AskResult { const pending = this.findPendingAsk(input.requestId); const cancel: AskCancelPayload = { note: input.note }; const result: AskResult = { status: "cancelled", requestId: pending.payload.requestId, note: cancel.note, resolvedAt: new Date().toISOString() }; this.resolveLocally(pending, result, { type: "ask.cancel", requestId: pending.payload.requestId, payload: { requestId: pending.payload.requestId, cancel } }); return result; } private connect(): void { if (this.stopped) return; try { this.socket = new this.WebSocketImpl(toExtensionSocketUrl(this.currentServerUrl)); } catch (error) { this.connectionState = "disconnected"; this.recordConnectionDiagnostic(`connect-error:${messageFrom(error)}`); this.options.onStatus?.(`connect-error:${messageFrom(error)}`); this.scheduleReconnect(); return; } const socket = this.socket; socket.on("open", () => { this.connectionState = "connected"; this.connectionDiagnostics = []; this.options.onStatus?.("connected"); this.nextReconnectMs = this.reconnectMs; this.send({ type: "session.register", payload: { ...this.options.registration, session: { ...this.options.registration.session, semanticState: this.currentSemanticState } } }); this.startHeartbeat(); this.replayPendingAsks(); this.flushLocalResolutions(); }); socket.on("message", (raw) => { try { const text = Buffer.isBuffer(raw) ? raw.toString() : String(raw); const parsed = ExtensionServerMessageSchema.safeParse(JSON.parse(text)); if (!parsed.success) return; if (parsed.data.type === "registered") { this.imageUploadToken = parsed.data.payload.imageUploadToken; this.offerQuestionChatRecovery(); return; } if (parsed.data.type === "ask.created") { const pending = [...this.pendingAsks.values()].find((candidate) => candidate.createCommandId === parsed.data.requestId); if (pending && parsed.data.payload.questionId === pending.payload.requestId) { this.resolveCreateReceipt(pending.payload.requestId, AskReceiptSchema.parse(parsed.data.payload)); } return; } if (parsed.data.type === "answer.available") { const notification = parsed.data; this.options.onOwnerQuestionStateChanged?.(); this.answerNotificationOperation = this.answerNotificationOperation.then( () => this.deliverAnswerNotification(notification.requestId, notification.payload) ); return; } if (parsed.data.type === "answer.result") { const pending = this.pendingAnswerReads.get(parsed.data.requestId); const result = AnswerReadResultSchema.parse(parsed.data.payload); const questionId = result.questionId; if (!pending || pending.questionId !== questionId) return; this.pendingAnswerReads.delete(parsed.data.requestId); pending.cleanup(); pending.resolve(result); return; } if (parsed.data.type === "query.result" || parsed.data.type === "question.list.result" || parsed.data.type === "postbox.wait.result" || parsed.data.type === "ask.batch.result") { const pending = this.pendingQueries.get(parsed.data.requestId); if (pending) { this.pendingQueries.delete(parsed.data.requestId); pending.resolve(parsed.data.payload); if (parsed.data.type === "ask.batch.result" || pending.ownerQuestionStateChanged) { this.options.onOwnerQuestionStateChanged?.(); } } return; } if (parsed.data.type === "chat.reconcile") { void this.reconcileQuestionChat(parsed.data.requestId, parsed.data.payload); return; } if (parsed.data.type === "chat.activate") { void this.activateQuestionChat(parsed.data.requestId, parsed.data.payload); return; } if (parsed.data.type === "chat.snapshot") { void this.snapshotQuestionChat(parsed.data.requestId, parsed.data.payload); return; } if (parsed.data.type === "chat.send") { void this.sendQuestionChat(parsed.data.requestId, parsed.data.payload); return; } if (parsed.data.type === "chat.stop") { void this.stopQuestionChat(parsed.data.requestId, parsed.data.payload); return; } if (parsed.data.type === "chat.cleanup") { this.markQuestionChatTerminal(parsed.data.payload.requestId); this.questionChatSubscriptions.get(parsed.data.payload.requestId)?.(); this.questionChatSubscriptions.delete(parsed.data.payload.requestId); void this.options.questionChats?.cleanup(parsed.data.payload.requestId); return; } if (parsed.data.type === "chat.propose-answer.result") { const pending = this.pendingProposals.get(parsed.data.requestId); if (!pending || pending.requestId !== parsed.data.payload.requestId) return; this.finishProposal(parsed.data.requestId, parsed.data.payload.result); return; } if (parsed.data.type === "error") { this.options.onStatus?.(`server-error:${parsed.data.error.code}`); if (parsed.data.requestId) { const error = Object.assign(new Error(parsed.data.error.message), { code: parsed.data.error.code }); const create = [...this.pendingAsks.values()].find((candidate) => candidate.createCommandId === parsed.data.requestId); if (create) this.rejectCreateReceipt(create.payload.requestId, error); if (create) create.reject(error); const read = this.pendingAnswerReads.get(parsed.data.requestId); if (read) { this.pendingAnswerReads.delete(parsed.data.requestId); read.cleanup(); read.reject(error); } const query = this.pendingQueries.get(parsed.data.requestId); if (query) { this.pendingQueries.delete(parsed.data.requestId); query.reject(error); } this.pendingAsks.get(parsed.data.requestId)?.reject(error); } } if (parsed.data.type === "ask.resolved") { this.options.onOwnerQuestionStateChanged?.(); this.resolveCreateReceipt(parsed.data.payload.requestId, { questionId: parsed.data.payload.requestId, revision: 1, ownerRevision: 1, status: "pending", disposition: "idempotent" }); this.markQuestionChatTerminal(parsed.data.payload.requestId); this.questionChatSubscriptions.get(parsed.data.payload.requestId)?.(); this.questionChatSubscriptions.delete(parsed.data.payload.requestId); void this.options.questionChats?.cleanup(parsed.data.payload.requestId); this.pendingAsks.get(parsed.data.payload.requestId)?.resolve(parsed.data.payload); } } catch { this.options.onStatus?.("server-error:invalid-json"); } }); socket.on("error", (error) => { if (!this.isConnected()) this.connectionState = "disconnected"; this.recordConnectionDiagnostic(`socket-error:${messageFrom(error)}`); this.options.onStatus?.(`socket-error:${messageFrom(error)}`); }); socket.on("close", () => { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); this.failPendingProposals("Postbox disconnected before the Question Chat proposal completed."); this.failPendingAnswerReads("Postbox disconnected before the Answer was returned."); this.failPendingQueries("Postbox disconnected before the query was returned."); this.connectionState = "disconnected"; this.recordConnectionDiagnostic("websocket:disconnected"); if (this.stopped) return; if (this.suppressReconnectOnClose.has(socket)) return; this.options.onStatus?.("disconnected"); this.startTargetAffinityTimersForDisconnectedOrigin(); this.scheduleReconnect(); }); } private async activateQuestionChat( commandId: string, payload: { requestId: string; ownerSessionId: string; source: QuestionChatSource } ): Promise { await this.handleQuestionChatActivation(commandId, payload, (input) => this.options.questionChats!.activate(input)); } private async handleQuestionChatActivation( commandId: string, payload: { requestId: string; ownerSessionId: string; source: Source }, activate: (input: { requestId: string; ownerSessionId: string; source: Source }) => Promise ): Promise { if (payload.ownerSessionId !== this.options.registration.session.sessionId) { this.sendQuestionChatError(commandId, payload.requestId, { code: "wrong_owner", message: "Question Chat activation was routed to the wrong Pi Session." }); return; } if (!this.options.questionChats) { this.sendQuestionChatError(commandId, payload.requestId, { code: "runtime_failure", message: "Question Chat runtime is not configured." }); return; } try { if (this.terminalQuestionChats.has(payload.requestId)) return; const snapshot = await activate({ requestId: payload.requestId, ownerSessionId: payload.ownerSessionId, source: payload.source }); if (this.terminalQuestionChats.has(payload.requestId)) return; this.liveQuestionChats.set(payload.requestId, payload.ownerSessionId); this.subscribeQuestionChat(payload.requestId); this.send({ type: "chat.ready", requestId: commandId, payload: snapshot }); } catch (error) { const runtimeError = error instanceof QuestionChatRuntimeError ? error : undefined; this.sendQuestionChatError(commandId, payload.requestId, { code: runtimeError?.code ?? "runtime_failure", message: runtimeError?.message ?? (error instanceof Error ? error.message : "Question Chat activation failed.") }); } } private offerQuestionChatRecovery(): void { this.pendingRecoveryOffers.clear(); this.recoveryOffers = this.options.questionChats?.listRecoveryOffers?.() ?? []; this.recoveryOfferIndex = 0; this.recoveryCompleteSent = false; this.sendNextQuestionChatRecoveryOffer(); } private sendNextQuestionChatRecoveryOffer(): void { if (this.pendingRecoveryOffers.size > 0 || this.recoveryCompleteSent) return; const offer = this.recoveryOffers[this.recoveryOfferIndex]; if (offer) { const commandId = `chat_recover_${randomUUID()}`; if (this.send({ type: "chat.recover.offer", requestId: commandId, payload: offer })) { this.pendingRecoveryOffers.set(commandId, offer); this.recoveryOfferIndex += 1; } return; } if (this.send({ type: "chat.recover.complete", requestId: `chat_recover_complete_${randomUUID()}`, payload: { ownerSessionId: this.options.registration.session.sessionId } })) this.recoveryCompleteSent = true; } private async reconcileQuestionChat( commandId: string, payload: QuestionChatReconciliationDecision ): Promise { const offered = this.pendingRecoveryOffers.get(commandId); if (!offered || offered.requestId !== payload.requestId || offered.forkKind !== payload.forkKind) return; this.pendingRecoveryOffers.delete(commandId); let result: QuestionChatReconciliationResult; try { const [reconciled] = await this.options.questionChats?.reconcile?.( this.options.registration.session.sessionId, [{ requestId: payload.requestId, forkKind: payload.forkKind, action: payload.action }] ) ?? []; result = reconciled ?? { status: "failed", requestId: payload.requestId, message: "Question Chat recovery is not configured." }; if (result.status === "recovered" && !this.terminalQuestionChats.has(payload.requestId)) { this.liveQuestionChats.set(payload.requestId, this.options.registration.session.sessionId); this.subscribeQuestionChat(payload.requestId); } } catch (error) { result = { status: "failed", requestId: payload.requestId, message: error instanceof Error ? error.message : "Question Chat recovery failed." }; } const wireResult = result.status === "recovered" ? { status: "recovered" as const, snapshot: result.snapshot } : result.status === "deleted" ? { status: "deleted" as const } : { status: "failed" as const, message: result.message.slice(0, 2_000) }; const sent = this.send({ type: "chat.reconciled", requestId: commandId, payload: { requestId: payload.requestId, forkKind: payload.forkKind, result: wireResult } }); if (sent) this.sendNextQuestionChatRecoveryOffer(); } private subscribeQuestionChat(requestId: string): void { if (this.questionChatSubscriptions.has(requestId) || !this.options.questionChats) return; const unsubscribe = this.options.questionChats.subscribe(requestId, (event) => { if (this.liveQuestionChats.has(requestId) && !this.terminalQuestionChats.has(requestId)) { this.send({ type: "chat.event", payload: event }); } }); this.questionChatSubscriptions.set(requestId, unsubscribe); } private async snapshotQuestionChat( commandId: string, payload: { requestId: string; ownerSessionId: string } ): Promise { if (!this.validateQuestionChatCommandOwner(commandId, payload, "snapshot")) return; try { const snapshot = await this.options.questionChats!.getSnapshot(payload.requestId, payload.ownerSessionId); if (!this.isLiveQuestionChat(payload.requestId, payload.ownerSessionId)) return; this.send({ type: "chat.snapshot", requestId: commandId, payload: snapshot }); } catch (error) { this.sendRuntimeQuestionChatError(commandId, payload.requestId, error, "Question Chat snapshot failed."); } } private async sendQuestionChat( commandId: string, payload: { requestId: string; ownerSessionId: string; command: QuestionChatSendPayload } ): Promise { if (!this.validateQuestionChatCommandOwner(commandId, payload, "send")) return; try { const response = await this.options.questionChats!.send(payload.requestId, payload.ownerSessionId, payload.command); if (!this.isLiveQuestionChat(payload.requestId, payload.ownerSessionId)) return; this.send({ type: "chat.send.accepted", requestId: commandId, payload: { requestId: payload.requestId, response } }); } catch (error) { this.sendRuntimeQuestionChatError(commandId, payload.requestId, error, "Question Chat send failed."); } } private async stopQuestionChat( commandId: string, payload: { requestId: string; ownerSessionId: string; command: QuestionChatStopPayload } ): Promise { if (!this.validateQuestionChatCommandOwner(commandId, payload, "stop")) return; try { const response = await this.options.questionChats!.stop(payload.requestId, payload.ownerSessionId, payload.command); if (!this.isLiveQuestionChat(payload.requestId, payload.ownerSessionId)) return; this.send({ type: "chat.stop.accepted", requestId: commandId, payload: { requestId: payload.requestId, response } }); } catch (error) { this.sendRuntimeQuestionChatError(commandId, payload.requestId, error, "Question Chat stop failed."); } } private validateQuestionChatCommandOwner( commandId: string, payload: { requestId: string; ownerSessionId: string }, action: string ): boolean { if (payload.ownerSessionId !== this.options.registration.session.sessionId) { this.sendQuestionChatError(commandId, payload.requestId, { code: "wrong_owner", message: `Question Chat ${action} was routed to the wrong Pi Session.` }); return false; } if (this.terminalQuestionChats.has(payload.requestId)) { this.sendQuestionChatError(commandId, payload.requestId, { code: "request_not_pending", message: "The Postbox Question is already terminal." }); return false; } if (!this.isLiveQuestionChat(payload.requestId, payload.ownerSessionId)) { this.sendQuestionChatError(commandId, payload.requestId, { code: "chat_not_started", message: "Question Chat has not started for this Pi Session." }); return false; } if (!this.options.questionChats) { this.sendQuestionChatError(commandId, payload.requestId, { code: "runtime_failure", message: "Question Chat runtime is not configured." }); return false; } return true; } private sendRuntimeQuestionChatError(commandId: string, requestId: string, error: unknown, fallback: string): void { const runtimeError = error instanceof QuestionChatRuntimeError ? error : undefined; this.sendQuestionChatError(commandId, requestId, { code: runtimeError?.code ?? "runtime_failure", message: runtimeError?.message ?? (error instanceof Error ? error.message : fallback) }); } private sendQuestionChatError( commandId: string, requestId: string, error: QuestionChatAvailabilityError ): void { this.send({ type: "chat.error", requestId: commandId, payload: { requestId, error } }); } private isLiveQuestionChat(requestId: string, ownerSessionId: string): boolean { return this.liveQuestionChats.get(requestId) === ownerSessionId && !this.terminalQuestionChats.has(requestId); } private markQuestionChatTerminal(requestId: string): void { const ownerSessionId = this.liveQuestionChats.get(requestId) ?? this.options.registration.session.sessionId; this.liveQuestionChats.delete(requestId); if (!this.terminalQuestionChats.has(requestId) && this.terminalQuestionChats.size >= 256) { this.terminalQuestionChats.delete(this.terminalQuestionChats.keys().next().value!); } this.terminalQuestionChats.set(requestId, ownerSessionId); for (const [commandId, proposal] of this.pendingProposals) { if (proposal.requestId === requestId) this.finishProposal(commandId, proposalTerminalError()); } } private startHeartbeat(): void { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); this.heartbeatTimer = setInterval(() => { this.send({ type: "heartbeat", payload: { sessionId: this.options.registration.session.sessionId, semanticState: this.currentSemanticState } }); }, this.heartbeatMs); this.heartbeatTimer.unref?.(); } private send(message: ExtensionClientMessage): boolean { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false; try { this.socket.send(JSON.stringify(message)); return true; } catch (error) { this.recordConnectionDiagnostic(`send-error:${messageFrom(error)}`); return false; } } private finishProposal(commandId: string, result: ProposeAnswerResult): void { const pending = this.pendingProposals.get(commandId); if (!pending) return; clearTimeout(pending.timer); if (pending.signal && pending.abort) pending.signal.removeEventListener("abort", pending.abort); this.pendingProposals.delete(commandId); pending.resolve(result); } private failPendingProposals(message: string): void { for (const commandId of [...this.pendingProposals.keys()]) { this.finishProposal(commandId, proposalTransportError(message)); } } private ensureConnection(): void { if (this.stopped) return; if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) return; this.connect(); } private replayPendingAsks(): void { for (const pending of this.pendingAsks.values()) { this.sendPendingAsk(pending); } } private sendPendingAsk(pending: PendingAsk): boolean { try { this.assertImageTargets(pending.payload.images); } catch (error) { this.rejectCreateReceipt(pending.payload.requestId, error as Error); pending.reject(error as Error); return false; } if (pending.originServerUrl && pending.originServerUrl !== this.currentServerUrl) return false; const sent = this.send({ type: "ask.create", requestId: pending.createCommandId, payload: pending.payload }); if (sent) { pending.sentAtLeastOnce = true; pending.originServerUrl ??= this.currentServerUrl; if (pending.unavailableTimer) { clearTimeout(pending.unavailableTimer); pending.unavailableTimer = undefined; } } if (!sent && this.asynchronousAskCreates.has(pending.payload.requestId)) { const error = new Error("Pi Postbox could not send the Question for persistence."); this.rejectCreateReceipt(pending.payload.requestId, error); pending.reject(error); } return sent; } private resolveCreateReceipt(requestId: string, receipt: AskReceipt): void { const pending = this.pendingCreateReceipts.get(requestId); if (!pending) return; this.pendingCreateReceipts.delete(requestId); this.detachAskAbortSignal(requestId); pending.resolve(receipt); } private detachAskAbortSignal(requestId: string): void { const pending = this.pendingAsks.get(requestId); if (!pending?.signal || !pending.abort) return; pending.signal.removeEventListener("abort", pending.abort); pending.signal = undefined; pending.abort = undefined; } private async deliverAnswerNotification( commandId: string | undefined, notification: { questionId: string; question: string; answerId: string } ): Promise { try { const state = await this.answerNotificationInbox.begin(notification.answerId); if (state !== "delivered") { this.options.onAnswerAvailable?.(notification, notification.answerId); await this.answerNotificationInbox.markDelivered(notification.answerId); } if (commandId) this.send({ type: "answer.available.ack", requestId: commandId, payload: { answerId: notification.answerId } }); } catch (error) { this.options.onStatus?.(`answer-notification-persist-error:${messageFrom(error)}`); } } private rejectCreateReceipt(requestId: string, error: Error): void { const pending = this.pendingCreateReceipts.get(requestId); if (!pending) return; this.pendingCreateReceipts.delete(requestId); pending.reject(error); } private failPendingAnswerReads(message: string): void { for (const [commandId, pending] of this.pendingAnswerReads) { this.pendingAnswerReads.delete(commandId); pending.cleanup(); pending.reject(new Error(message)); } } private failPendingQueries(message: string): void { for (const pending of this.pendingQueries.values()) pending.reject(new Error(message)); this.pendingQueries.clear(); } private startUnavailableTimerIfNeeded(pending: PendingAsk): void { if (this.isConnected()) return; if (pending.unavailableTimer) clearTimeout(pending.unavailableTimer); pending.unavailableTimer = setTimeout(() => { if (!pending.sentAtLeastOnce) { pending.resolve(unavailableResult(pending.payload.requestId, "Pi Postbox is unavailable before the request could be sent.")); } }, this.askUnavailableAfterMs); pending.unavailableTimer.unref?.(); } private startExpiryTimer(pending: PendingAsk): void { if (!pending.payload.expiresAt) return; const dueMs = Date.parse(pending.payload.expiresAt) - Date.now(); if (dueMs <= 0) { queueMicrotask(() => pending.resolve(expiredResult(pending.payload.requestId))); return; } pending.expiryTimer = setTimeout(() => pending.resolve(expiredResult(pending.payload.requestId)), dueMs); pending.expiryTimer.unref?.(); } private resolveLocally(pending: PendingAsk, result: AskResult, message: ExtensionClientMessage): void { this.enqueueLocalResolution(pending, result, message); pending.resolve(result); this.flushLocalResolutions(); } /** * The agent abandoned this ask (the tool call was aborted), so cancel it server-side too; * otherwise the question lingers as pending in every Postbox inbox until it expires. Skipped * when the ask never reached a server, because there is nothing to cancel there. */ private cancelAskOnAbort(pending: PendingAsk): void { if (!pending.sentAtLeastOnce) return; const requestId = pending.payload.requestId; const cancel: AskCancelPayload = { note: "The agent stopped waiting for this question." }; const result: AskResult = { status: "cancelled", requestId, note: cancel.note, resolvedAt: new Date().toISOString() }; this.enqueueLocalResolution(pending, result, { type: "ask.cancel", requestId, payload: { requestId, cancel } }); this.flushLocalResolutions(); } private enqueueLocalResolution(pending: PendingAsk, result: AskResult, message: ExtensionClientMessage): void { const originServerUrl = pending.originServerUrl ?? pending.createdServerUrl; const resolution: LocalResolution = { payload: pending.payload, result, message, originServerUrl }; this.localResolutions.set(pending.payload.requestId, resolution); if (!this.isConnected()) this.startLocalResolutionTargetAffinityTimer(pending.payload.requestId, resolution); } private findPendingAsk(requestId?: string): PendingAsk { if (requestId) { const pending = this.pendingAsks.get(requestId); if (!pending) throw new LocalFallbackError("request_not_pending", `No pending Postbox ask found for ${requestId}`); return pending; } const pending = [...this.pendingAsks.values()]; if (pending.length === 0) throw new LocalFallbackError("no_pending_request", "No pending Postbox ask is available for local fallback"); if (pending.length > 1) { throw new LocalFallbackError("ambiguous_request", "Multiple Postbox asks are pending; include the request id"); } return pending[0]; } private validateSelectedValues(payload: AskCreatePayload, selectedValues: string[]): void { if (selectedValues.length === 0) throw new LocalFallbackError("invalid_selection", "Select at least one option value"); if (payload.mode === "single" && selectedValues.length !== 1) { throw new LocalFallbackError("invalid_selection", "Single-choice asks require exactly one selected value"); } const allowed = new Set([...payload.options.map((option) => option.value), OTHER_OPTION_VALUE]); const invalid = selectedValues.find((value) => !allowed.has(value)); if (invalid) throw new LocalFallbackError("invalid_selection", `Unknown option value: ${invalid}`); } private flushLocalResolutions(): void { if (!this.isConnected()) return; for (const [requestId, resolution] of [...this.localResolutions]) { if (resolution.originServerUrl !== this.currentServerUrl) continue; const createSent = this.send({ type: "ask.create", requestId, payload: resolution.payload }); const resolutionSent = createSent && this.send(resolution.message); if (resolutionSent) this.deleteLocalResolution(requestId); } this.tryApplyDeferredTarget(); } private publishLocalFallbackStatus(): void { if (!this.options.onLocalFallbackStatus) return; const active = this.listPendingAsks()[0]; if (!active) { this.options.onLocalFallbackStatus(undefined); return; } const values = [...active.options.map((option) => option.value), OTHER_OPTION_VALUE].join(","); const deferred = this.deferredTarget ? ` Active-local switch to ${this.deferredTarget.url} is deferred until pinned Postbox work is resolved.` : ""; this.options.onLocalFallbackStatus({ requestId: active.requestId, serverUrl: this.currentServerUrl, message: `Postbox waiting ${active.requestId}. Open ${this.currentServerUrl} to answer. Local fallback: /postbox-answer ${active.requestId} ${values} [--note ...] or /postbox-cancel ${active.requestId} [--note ...]${deferred}` }); } private isConnected(): boolean { return !!this.socket && this.socket.readyState === WebSocket.OPEN; } private recordConnectionDiagnostic(diagnostic: string): void { this.connectionDiagnostics = [...new Set([...this.connectionDiagnostics, diagnostic])].slice(-5); } private removeConnectionDiagnostics(prefix: string): void { this.connectionDiagnostics = this.connectionDiagnostics.filter((diagnostic) => !diagnostic.startsWith(prefix)); } private scheduleReconnect(): void { if (this.stopped || !this.reconnect) return; if (this.reconnectTimer) clearTimeout(this.reconnectTimer); const delay = this.nextReconnectMs; this.nextReconnectMs = Math.min(this.nextReconnectMs * 2, this.reconnectMaxMs); const diagnostic = `reconnect-scheduled:delay=${delay}ms;target=${this.currentServerUrl}`; this.recordConnectionDiagnostic(diagnostic); this.options.onStatus?.(diagnostic); this.reconnectTimer = setTimeout(() => { this.removeConnectionDiagnostics("reconnect-scheduled:"); void this.reconnectToResolvedTarget(); }, delay); this.reconnectTimer.unref?.(); } private profilePollingEnabled(): boolean { return !!this.options.resolveTarget && this.options.profilePollingEnabled !== false; } private startProfilePolling(): void { if (!this.profilePollingEnabled()) return; if (this.profilePollTimer) clearInterval(this.profilePollTimer); const intervalMs = this.options.profilePollMs ?? DEFAULT_PROFILE_POLL_MS; this.profilePollTimer = setInterval(() => { void this.checkForProfileTargetChange(); }, intervalMs); this.profilePollTimer.unref?.(); } private async reconnectToResolvedTarget(): Promise { if (this.stopped) return; await this.checkForProfileTargetChange({ connectWhenDisconnected: false }); if (this.stopped) return; this.connect(); } private async checkForProfileTargetChange(options: { connectWhenDisconnected?: boolean } = {}): Promise { if (this.stopped || !this.profilePollingEnabled() || !this.options.resolveTarget) return; let result: ResolveServerTargetResult; try { result = await this.options.resolveTarget(); } catch (error) { this.options.onStatus?.(`target-resolve-error:${messageFrom(error)}`); return; } if (this.stopped || result.status !== "selected") return; const target = result.target; const targetUrl = target.url; if (targetUrl === this.currentServerUrl && !this.deferredTarget) { this.applyTargetIdentity(target); return; } if (this.hasPinnedWorkBlocking(targetUrl)) { this.deferTargetSwitch(target); return; } this.deferredTarget = undefined; this.removeConnectionDiagnostics("target-switch-deferred:"); this.applyTargetIdentity(target); if (targetUrl === this.currentServerUrl) return; this.retargetNow(targetUrl, options.connectWhenDisconnected ?? true); } private deferTargetSwitch(target: ResolvedServerTarget): void { this.deferredTarget = target; const timeoutMs = this.options.targetAffinityTimeoutMs ?? DEFAULT_TARGET_AFFINITY_TIMEOUT_MS; const diagnostic = `target-switch-deferred:${target.url}:pinned-origin-affinity<=${timeoutMs}ms`; this.recordConnectionDiagnostic(diagnostic); this.options.onStatus?.(diagnostic); this.startTargetAffinityTimersForPinnedWork(); this.publishLocalFallbackStatus(); } private tryApplyDeferredTarget(): void { const target = this.deferredTarget; if (this.stopped || !target || this.hasPinnedWorkBlocking(target.url)) return; this.deferredTarget = undefined; this.removeConnectionDiagnostics("target-switch-deferred:"); this.applyTargetIdentity(target); if (target.url !== this.currentServerUrl) this.retargetNow(target.url, true); this.publishLocalFallbackStatus(); } private applyTargetIdentity(target: ResolvedServerTarget): void { this.currentTargetSource = target.source; this.currentTargetProfile = target.profile; this.currentTargetIdentity = { version: target.version, protocolVersion: target.protocolVersion, instanceId: target.instanceId, buildId: target.buildId }; } private retargetNow(targetUrl: string, connectWhenDisconnected: boolean): void { this.currentServerUrl = targetUrl; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); const socket = this.socket; const shouldConnect = !socket || socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING || connectWhenDisconnected; if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { this.suppressReconnectOnClose.add(socket); socket.close(); } if (shouldConnect) this.connect(); } private hasPinnedWorkBlocking(targetUrl: string): boolean { for (const pending of this.pendingAsks.values()) { if (pending.sentAtLeastOnce && pending.originServerUrl && pending.originServerUrl !== targetUrl) return true; } for (const resolution of this.localResolutions.values()) { if (resolution.originServerUrl !== targetUrl) return true; } return false; } private startTargetAffinityTimersForDisconnectedOrigin(): void { for (const pending of this.pendingAsks.values()) { if (pending.sentAtLeastOnce && pending.originServerUrl === this.currentServerUrl) { this.startTargetAffinityTimer(pending); } } for (const [requestId, resolution] of this.localResolutions) { if (resolution.originServerUrl === this.currentServerUrl) { this.startLocalResolutionTargetAffinityTimer(requestId, resolution); } } } private startTargetAffinityTimersForPinnedWork(): void { for (const pending of this.pendingAsks.values()) { if (pending.sentAtLeastOnce) this.startTargetAffinityTimer(pending); } for (const [requestId, resolution] of this.localResolutions) { this.startLocalResolutionTargetAffinityTimer(requestId, resolution); } } private startTargetAffinityTimer(pending: PendingAsk): void { if (pending.targetAffinityTimer) return; pending.targetAffinityTimer = setTimeout(() => { pending.targetAffinityTimer = undefined; if (!this.pendingAsks.has(pending.payload.requestId)) return; pending.resolve( unavailableResult( pending.payload.requestId, "Pinned Postbox request became undeliverable because its origin target is unavailable." ) ); }, this.options.targetAffinityTimeoutMs ?? DEFAULT_TARGET_AFFINITY_TIMEOUT_MS); pending.targetAffinityTimer.unref?.(); } private startLocalResolutionTargetAffinityTimer(requestId: string, resolution: LocalResolution): void { if (resolution.targetAffinityTimer) return; resolution.targetAffinityTimer = setTimeout(() => { resolution.targetAffinityTimer = undefined; if (this.localResolutions.get(requestId) !== resolution) return; this.deleteLocalResolution(requestId); this.options.onStatus?.( `target-affinity-undeliverable:${requestId}:origin ${resolution.originServerUrl} unavailable before local resolution could be delivered` ); this.tryApplyDeferredTarget(); }, this.options.targetAffinityTimeoutMs ?? DEFAULT_TARGET_AFFINITY_TIMEOUT_MS); resolution.targetAffinityTimer.unref?.(); } private deleteLocalResolution(requestId: string): void { const resolution = this.localResolutions.get(requestId); if (!resolution) return; if (resolution.targetAffinityTimer) clearTimeout(resolution.targetAffinityTimer); this.localResolutions.delete(requestId); } } export function toExtensionSocketUrl(serverUrl: string): string { const url = new URL(serverUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; url.pathname = "/api/extension/ws"; url.search = ""; return url.toString(); } function unavailableResult(requestId: string, note: string): AskResult { return { status: "unavailable", requestId, note, resolvedAt: new Date().toISOString() }; } function expiredResult(requestId: string): AskResult { return { status: "expired", requestId, note: "Postbox request expired before an answer was submitted.", resolvedAt: new Date().toISOString() }; } function proposalTransportError(message: string): ProposeAnswerResult { return { status: "error", error: { code: "internal_error", message } }; } function proposalTerminalError(): ProposeAnswerResult { return { status: "error", error: { code: "request_terminal", message: "The Postbox Question is already terminal." } }; } function messageFrom(error: unknown): string { return error instanceof Error ? error.message : String(error); } export class LocalFallbackError extends Error { constructor( public readonly code: string, message: string ) { super(message); this.name = "LocalFallbackError"; } }