diff --git a/docs/android.md b/docs/android.md
index a2f17b46f..6e38a5872 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -67,6 +67,17 @@ npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
+For a production-ID release APK that local Android profiling tools can attach to:
+
+```bash
+PASEO_PROFILE_BUILD=1 npm run android:production
+```
+
+This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
+`` and enables local Android trace markers for workspace mounts
+and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
+and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
+
Or from `packages/app`:
```bash
diff --git a/docs/providers.md b/docs/providers.md
index 92311c463..9d9d43166 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -70,6 +70,8 @@ The daemon keeps provider snapshots per resolved working directory, with a separ
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
+Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
+
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
diff --git a/packages/app/app.config.js b/packages/app/app.config.js
index 83870a465..599d549cf 100644
--- a/packages/app/app.config.js
+++ b/packages/app/app.config.js
@@ -1,9 +1,11 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
+const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
+const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
? {
@@ -193,6 +195,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
+ ...(isProfileBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -201,6 +204,7 @@ export default {
},
extra: {
fdroidBuild: isFdroidBuild,
+ profileBuild: isProfileBuild,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts
index 6a8d60e9c..528a9343f 100644
--- a/packages/app/e2e/browser/agent-message-submission.spec.ts
+++ b/packages/app/e2e/browser/agent-message-submission.spec.ts
@@ -915,39 +915,6 @@ test.describe("Agent message submission", () => {
}
});
- test("reconciles an interrupted submission when live acknowledgements are missed", async ({
- page,
- }) => {
- const gate = await installDaemonWebSocketGate(page);
- const title = "Missed submission acknowledgements";
- const prompt = "Interrupt without delivering live acknowledgements.";
- const agent = await seedMockAgentWorkspace({
- repoPrefix: "submission-missed-acknowledgements-",
- title,
- model: "one-minute-stream",
- });
- try {
- await openAgentRoute(page, agent);
- await expectComposerVisible(page);
- gate.setAgentStreamItemSuppressed("user_message", true);
- gate.setAgentStreamEventSuppressed("turn_canceled", true);
-
- await submitMessage(page, prompt);
- await gate.waitForAgentStreamItem("user_message");
- await expectRunningAgentChrome(page, title);
- await cancelAgent(page);
- await gate.waitForAgentStreamEvent("turn_canceled");
- await agent.client.waitForFinish(agent.agentId, 30_000);
-
- await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
- await expectAgentSurfacesIdle(page, title);
- } finally {
- gate.setAgentStreamItemSuppressed("user_message", false);
- gate.setAgentStreamEventSuppressed("turn_canceled", false);
- await agent.cleanup();
- }
- });
-
test("keeps layout stable when submitting to an agent with existing history", async ({
page,
submissionScenario,
diff --git a/packages/app/modules/paseo-native-trace/android/build.gradle b/packages/app/modules/paseo-native-trace/android/build.gradle
new file mode 100644
index 000000000..bfd0eec72
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/build.gradle
@@ -0,0 +1,15 @@
+apply plugin: 'com.android.library'
+
+group = 'sh.paseo.trace'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+useDefaultAndroidSdkVersions()
+
+android {
+ namespace "sh.paseo.trace"
+}
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/AndroidManifest.xml b/packages/app/modules/paseo-native-trace/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..94cbbcfc3
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt
new file mode 100644
index 000000000..4219993e3
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt
@@ -0,0 +1,19 @@
+package sh.paseo.trace
+
+import android.os.Trace
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class PaseoNativeTraceModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("PaseoNativeTrace")
+
+ Function("beginSection") { name: String ->
+ Trace.beginSection(name.take(127))
+ }
+
+ Function("endSection") {
+ Trace.endSection()
+ }
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/expo-module.config.json b/packages/app/modules/paseo-native-trace/expo-module.config.json
new file mode 100644
index 000000000..e5c1c82ec
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["sh.paseo.trace.PaseoNativeTraceModule"]
+ }
+}
diff --git a/packages/app/modules/paseo-native-trace/package.json b/packages/app/modules/paseo-native-trace/package.json
new file mode 100644
index 000000000..ab66ed52e
--- /dev/null
+++ b/packages/app/modules/paseo-native-trace/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "paseo-native-trace",
+ "version": "0.1.0",
+ "private": true
+}
diff --git a/packages/app/plugins/with-android-profileable.js b/packages/app/plugins/with-android-profileable.js
new file mode 100644
index 000000000..46f0afe23
--- /dev/null
+++ b/packages/app/plugins/with-android-profileable.js
@@ -0,0 +1,15 @@
+const { withAndroidManifest } = require("expo/config-plugins");
+
+function withAndroidProfileable(config) {
+ return withAndroidManifest(config, (modConfig) => {
+ const application = modConfig.modResults.manifest.application?.[0];
+ if (!application) {
+ throw new Error("Could not enable Android profiling without an application manifest entry");
+ }
+
+ application.profileable = [{ $: { "android:shell": "true" } }];
+ return modConfig;
+ });
+}
+
+module.exports = withAndroidProfileable;
diff --git a/packages/app/src/constants/build-profile.ts b/packages/app/src/constants/build-profile.ts
index 7173f0843..428df8004 100644
--- a/packages/app/src/constants/build-profile.ts
+++ b/packages/app/src/constants/build-profile.ts
@@ -2,3 +2,6 @@ import Constants from "expo-constants";
/** F-Droid build without proprietary camera or notification dependencies. */
export const isFdroidBuild = Constants.expoConfig?.extra?.fdroidBuild === true;
+
+/** Production-like Android build with local profiling enabled. */
+export const isProfileBuild = Constants.expoConfig?.extra?.profileBuild === true;
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index afbf13790..8a93e9626 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -10,7 +10,6 @@ import {
createSetAgentInitializing,
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
-import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
createSessionAgentStreamReducerQueue,
@@ -494,19 +493,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
}, [client, serverId, updateSessionServerInfo]);
- useEffect(() => {
- if (!isConnected) {
- return;
- }
-
- const serverInfo = client.getLastServerInfoMessage();
- if (!serverInfo?.features?.providersSnapshot) {
- return;
- }
-
- prefetchProvidersSnapshot(serverId, client);
- }, [client, isConnected, serverId]);
-
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
@@ -739,19 +725,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
viewedTimelineSyncRef.current?.setConnected(isConnected);
}, [isConnected]);
- useEffect(
- () =>
- getHostRuntimeStore().subscribeAgentStoppedRunning(serverId, (agentId) => {
- const session = useSessionStore.getState().sessions[serverId];
- const timeline = selectAgentTimelineState(session, agentId);
- if (timeline.status === "synced" && timeline.newer === "available") {
- return;
- }
- viewedTimelineSyncRef.current?.reconcileAgent(agentId);
- }),
- [serverId],
- );
-
// Daemon message handlers - directly update Zustand store
useEffect(() => {
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts
new file mode 100644
index 000000000..2722c6874
--- /dev/null
+++ b/packages/app/src/data/provider-snapshot-cache.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
+import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
+import { createProviderSnapshotCache } from "./provider-snapshot-cache";
+
+function createStorage() {
+ const values = new Map();
+ return {
+ values,
+ async getItem(key: string) {
+ return values.get(key) ?? null;
+ },
+ async setItem(key: string, value: string) {
+ values.set(key, value);
+ },
+ async removeItem(key: string) {
+ values.delete(key);
+ },
+ };
+}
+
+describe("provider snapshot cache", () => {
+ it("round-trips compact snapshots with shared thinking option references", async () => {
+ const thinkingOptions = [{ id: "high", label: "High", isDefault: true }];
+ const entries: ProviderSnapshotEntry[] = [
+ {
+ provider: "pi",
+ status: "ready",
+ enabled: true,
+ models: ["one", "two"].map((id) => ({
+ provider: "pi",
+ id,
+ label: id,
+ thinkingOptions,
+ defaultThinkingOptionId: "high",
+ })),
+ },
+ ];
+ const storage = createStorage();
+ const cache = createProviderSnapshotCache(storage);
+
+ await cache.write({
+ serverId: "server-1",
+ cwd: "/repo",
+ hash: "snapshot-hash",
+ generatedAt: "2026-08-04T00:00:00.000Z",
+ compactSnapshot: compactProviderSnapshot(entries),
+ });
+ const cached = await cache.read("server-1", "/repo");
+
+ expect(cached?.entries).toEqual(entries);
+ expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe(
+ cached?.entries[0]?.models?.[1]?.thinkingOptions,
+ );
+ });
+
+ it("discards an invalid cache record", async () => {
+ const storage = createStorage();
+ const cache = createProviderSnapshotCache(storage);
+ storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json");
+
+ await expect(cache.read("server-1", "/repo")).resolves.toBeNull();
+ expect(storage.values.size).toBe(0);
+ });
+});
diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts
new file mode 100644
index 000000000..c7da60112
--- /dev/null
+++ b/packages/app/src/data/provider-snapshot-cache.ts
@@ -0,0 +1,129 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
+import {
+ expandProviderSnapshot,
+ type CompactProviderSnapshot,
+} from "@getpaseo/protocol/provider-snapshot-codec";
+import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages";
+
+const CACHE_VERSION = 1;
+const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1";
+
+interface ProviderSnapshotStorage {
+ getItem(key: string): Promise;
+ setItem(key: string, value: string): Promise;
+ removeItem(key: string): Promise;
+}
+
+interface StoredProviderSnapshot {
+ version: typeof CACHE_VERSION;
+ hash: string;
+ generatedAt: string;
+ compactSnapshot: CompactProviderSnapshot;
+}
+
+export interface CachedProviderSnapshot extends StoredProviderSnapshot {
+ entries: ProviderSnapshotEntry[];
+}
+
+export interface ProviderSnapshotCache {
+ read(serverId: string, cwd: string | null): Promise;
+ write(input: {
+ serverId: string;
+ cwd: string | null;
+ hash: string;
+ generatedAt: string;
+ compactSnapshot: CompactProviderSnapshot;
+ }): Promise;
+}
+
+export class ProviderSnapshotCacheMissError extends Error {
+ constructor() {
+ super("Daemon returned not-modified without a cached provider snapshot");
+ this.name = "ProviderSnapshotCacheMissError";
+ }
+}
+
+function cacheKey(serverId: string, cwd: string | null): string {
+ return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`;
+}
+
+function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null {
+ const parsed: unknown = JSON.parse(value);
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const version = Reflect.get(parsed, "version");
+ const hash = Reflect.get(parsed, "hash");
+ const generatedAt = Reflect.get(parsed, "generatedAt");
+ const compactSnapshot = CompactProviderSnapshotSchema.safeParse(
+ Reflect.get(parsed, "compactSnapshot"),
+ );
+ if (
+ version !== CACHE_VERSION ||
+ typeof hash !== "string" ||
+ typeof generatedAt !== "string" ||
+ !compactSnapshot.success
+ ) {
+ return null;
+ }
+ return {
+ version,
+ hash,
+ generatedAt,
+ compactSnapshot: compactSnapshot.data,
+ };
+}
+
+async function discardCachedSnapshot(storage: ProviderSnapshotStorage, key: string): Promise {
+ try {
+ await storage.removeItem(key);
+ } catch (error) {
+ if (!(error instanceof Error)) throw error;
+ }
+}
+
+export function createProviderSnapshotCache(
+ storage: ProviderSnapshotStorage,
+): ProviderSnapshotCache {
+ return {
+ async read(serverId, cwd) {
+ const key = cacheKey(serverId, cwd);
+ let value: string | null;
+ try {
+ value = await storage.getItem(key);
+ } catch (error) {
+ if (error instanceof Error) return null;
+ throw error;
+ }
+ if (value === null) return null;
+ try {
+ const stored = parseStoredProviderSnapshot(value);
+ if (!stored) {
+ await discardCachedSnapshot(storage, key);
+ return null;
+ }
+ return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) };
+ } catch (error) {
+ if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) {
+ throw error;
+ }
+ await discardCachedSnapshot(storage, key);
+ return null;
+ }
+ },
+ async write(input) {
+ const stored: StoredProviderSnapshot = {
+ version: CACHE_VERSION,
+ hash: input.hash,
+ generatedAt: input.generatedAt,
+ compactSnapshot: input.compactSnapshot,
+ };
+ try {
+ await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored));
+ } catch (error) {
+ if (!(error instanceof Error)) throw error;
+ }
+ },
+ };
+}
+
+export const providerSnapshotCache = createProviderSnapshotCache(AsyncStorage);
diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts
index 66d240150..5a59be037 100644
--- a/packages/app/src/data/providers-snapshot.ts
+++ b/packages/app/src/data/providers-snapshot.ts
@@ -21,11 +21,13 @@ export function providersSnapshotQueryKey(serverId: string | null, cwd?: string
export function providersSnapshotRequestOptions(input: {
cwd?: string | null;
providers?: AgentProvider[];
+ ifNoneMatch?: string;
}) {
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
return {
...(normalizedCwd ? { cwd: normalizedCwd } : {}),
...(input.providers ? { providers: input.providers } : {}),
+ ...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
};
}
diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts
index c95333851..0af2e8e68 100644
--- a/packages/app/src/data/push-router.ts
+++ b/packages/app/src/data/push-router.ts
@@ -8,7 +8,12 @@ import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
-import { providersSnapshotQueryKey, providersSnapshotQueryRoot } from "@/data/providers-snapshot";
+import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
+import {
+ normalizeProvidersSnapshotCwd,
+ providersSnapshotQueryKey,
+ providersSnapshotQueryRoot,
+} from "@/data/providers-snapshot";
type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
@@ -184,6 +189,7 @@ export function applyProvidersSnapshotUpdate(input: {
serverId: string;
queryClient: QueryClient;
message: ProvidersSnapshotUpdate;
+ cache?: ProviderSnapshotCache;
}): void {
if (input.message.type !== "providers_snapshot_update") {
return;
@@ -194,6 +200,16 @@ export function applyProvidersSnapshotUpdate(input: {
generatedAt: input.message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
+ const { compactSnapshot, snapshotHash } = input.message.payload;
+ if (compactSnapshot && snapshotHash) {
+ void (input.cache ?? providerSnapshotCache).write({
+ serverId: input.serverId,
+ cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd),
+ hash: snapshotHash,
+ generatedAt: input.message.payload.generatedAt,
+ compactSnapshot,
+ });
+ }
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
exact: false,
diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts
index 73019e421..620668e1f 100644
--- a/packages/app/src/hooks/use-providers-snapshot.test.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.test.ts
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
+import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
+import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
import {
@@ -79,6 +81,21 @@ function codexEntry(
const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const;
const serverId = "server-1";
+function createCache(initial: CachedProviderSnapshot | null = null): ProviderSnapshotCache & {
+ writes: Parameters[0][];
+} {
+ const writes: Parameters[0][] = [];
+ return {
+ writes,
+ async read() {
+ return initial;
+ },
+ async write(input) {
+ writes.push(input);
+ },
+ };
+}
+
describe("providersSnapshotQueryKey", () => {
it("uses separate keys for home and workspace scopes", () => {
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]);
@@ -95,7 +112,7 @@ describe("fetchProvidersSnapshot", () => {
it("sends no cwd for the home scope", async () => {
const client = createClient({ snapshots: [providersSnapshot([])] });
- await fetchProvidersSnapshot({ client, cwd: null });
+ await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() });
expect(client.getCalls).toEqual([{}]);
});
@@ -103,10 +120,78 @@ describe("fetchProvidersSnapshot", () => {
it("sends the workspace cwd for the workspace scope", async () => {
const client = createClient({ snapshots: [providersSnapshot([])] });
- await fetchProvidersSnapshot({ client, cwd: "/repo-a" });
+ await fetchProvidersSnapshot({
+ client,
+ serverId,
+ cwd: "/repo-a",
+ cache: createCache(),
+ });
expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]);
});
+
+ it("reuses a cached snapshot when the daemon reports its hash unchanged", async () => {
+ const entries = [codexEntry("ready", [readyCodexModel])];
+ const compactSnapshot = compactProviderSnapshot(entries);
+ const cache = createCache({
+ version: 1,
+ hash: "snapshot-hash",
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ compactSnapshot,
+ entries,
+ });
+ const client = createClient({
+ snapshots: [
+ {
+ entries: [],
+ snapshotHash: "snapshot-hash",
+ notModified: true,
+ generatedAt: "2026-01-02T00:00:00.000Z",
+ requestId: "snapshot-2",
+ },
+ ],
+ });
+
+ const snapshot = await fetchProvidersSnapshot({
+ client,
+ serverId,
+ cwd: "/repo-a",
+ cache,
+ });
+
+ expect(client.getCalls).toEqual([{ cwd: "/repo-a", ifNoneMatch: "snapshot-hash" }]);
+ expect(snapshot.entries).toBe(entries);
+ expect(cache.writes).toEqual([]);
+ });
+
+ it("persists a changed compact snapshot for the next launch", async () => {
+ const entries = [codexEntry("ready", [readyCodexModel])];
+ const compactSnapshot = compactProviderSnapshot(entries);
+ const cache = createCache();
+ const client = createClient({
+ snapshots: [
+ {
+ entries,
+ compactSnapshot,
+ snapshotHash: "next-hash",
+ generatedAt: "2026-01-02T00:00:00.000Z",
+ requestId: "snapshot-2",
+ },
+ ],
+ });
+
+ await fetchProvidersSnapshot({ client, serverId, cwd: "/repo-a", cache });
+
+ expect(cache.writes).toEqual([
+ {
+ serverId,
+ cwd: "/repo-a",
+ hash: "next-hash",
+ generatedAt: "2026-01-02T00:00:00.000Z",
+ compactSnapshot,
+ },
+ ]);
+ });
});
describe("refreshAndApplyProvidersSnapshot", () => {
@@ -127,6 +212,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
serverId,
cwd: null,
providers: ["codex"],
+ cache: createCache(),
});
expect(client.refreshCalls).toEqual([{ providers: ["codex"] }]);
@@ -147,6 +233,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
serverId,
cwd: "/repo-a",
providers: ["codex"],
+ cache: createCache(),
});
expect(client.refreshCalls).toEqual([{ cwd: "/repo-a", providers: ["codex"] }]);
@@ -166,6 +253,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: null,
+ cache: createCache(),
});
expect(
@@ -186,6 +274,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: "/repo-a",
+ cache: createCache(),
});
expect(queryClient.getQueryState(providersSnapshotQueryKey(serverId))?.isInvalidated).toBe(
@@ -209,6 +298,7 @@ describe("refreshAndApplyProvidersSnapshot", () => {
queryClient,
serverId,
cwd: "/repo-a",
+ cache: createCache(),
});
expect(queryClient.getQueryState(commandsKey)?.isInvalidated).toBe(true);
@@ -266,6 +356,27 @@ describe("applyProvidersSnapshotUpdate", () => {
);
});
+ it("persists compact push updates", () => {
+ const entries = [codexEntry("ready", [readyCodexModel])];
+ const compactSnapshot = compactProviderSnapshot(entries);
+ const cache = createCache();
+ const message = updateMessage(entries, "/repo-a");
+ message.payload.compactSnapshot = compactSnapshot;
+ message.payload.snapshotHash = "push-hash";
+
+ applyProvidersSnapshotUpdate({ serverId, queryClient, message, cache });
+
+ expect(cache.writes).toEqual([
+ {
+ serverId,
+ cwd: "/repo-a",
+ hash: "push-hash",
+ generatedAt: "2026-01-01T00:00:01.000Z",
+ compactSnapshot,
+ },
+ ]);
+ });
+
it("applies Windows daemon updates to app-normalized workspace paths", () => {
const workspaceCwd = "C:/Users/Ezekiel Bulver/project";
const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project";
diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts
index a7597b07c..9dfdae6f3 100644
--- a/packages/app/src/hooks/use-providers-snapshot.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.ts
@@ -8,6 +8,11 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { useSessionStore } from "@/stores/session-store";
import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
+import {
+ providerSnapshotCache,
+ ProviderSnapshotCacheMissError,
+ type ProviderSnapshotCache,
+} from "@/data/provider-snapshot-cache";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
import {
isProvidersSnapshotHomeScope,
@@ -29,9 +34,31 @@ export type ProvidersSnapshotClient = Pick<
export async function fetchProvidersSnapshot(input: {
client: ProvidersSnapshotClient;
+ serverId: string;
cwd: string | null;
+ cache?: ProviderSnapshotCache;
}): Promise {
- return input.client.getProvidersSnapshot(providersSnapshotRequestOptions({ cwd: input.cwd }));
+ const cache = input.cache ?? providerSnapshotCache;
+ const cached = await cache.read(input.serverId, input.cwd);
+ const snapshot = await input.client.getProvidersSnapshot(
+ providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }),
+ );
+ if (snapshot.notModified) {
+ if (!cached) {
+ throw new ProviderSnapshotCacheMissError();
+ }
+ return { ...snapshot, entries: cached.entries };
+ }
+ if (snapshot.compactSnapshot && snapshot.snapshotHash) {
+ await cache.write({
+ serverId: input.serverId,
+ cwd: input.cwd,
+ hash: snapshot.snapshotHash,
+ generatedAt: snapshot.generatedAt,
+ compactSnapshot: snapshot.compactSnapshot,
+ });
+ }
+ return snapshot;
}
export async function refreshAndApplyProvidersSnapshot(input: {
@@ -40,11 +67,17 @@ export async function refreshAndApplyProvidersSnapshot(input: {
serverId: string;
cwd: string | null;
providers?: AgentProvider[];
+ cache?: ProviderSnapshotCache;
}): Promise {
const refreshResult = await input.client.refreshProvidersSnapshot(
providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }),
);
- const snapshot = await fetchProvidersSnapshot({ client: input.client, cwd: input.cwd });
+ const snapshot = await fetchProvidersSnapshot({
+ client: input.client,
+ serverId: input.serverId,
+ cwd: input.cwd,
+ cache: input.cache,
+ });
input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot);
void input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
@@ -113,10 +146,10 @@ export function useProvidersSnapshot(
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
pushEvent: "providers_snapshot_update",
queryFn: async () => {
- if (!client) {
+ if (!client || !serverId) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
- return fetchProvidersSnapshot({ client, cwd });
+ return fetchProvidersSnapshot({ client, serverId, cwd });
},
});
@@ -180,6 +213,6 @@ export function prefetchProvidersSnapshot(
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: Infinity,
- queryFn: () => fetchProvidersSnapshot({ client, cwd }),
+ queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }),
});
}
diff --git a/packages/app/src/performance/native-trace.ts b/packages/app/src/performance/native-trace.ts
new file mode 100644
index 000000000..b5e8e73a6
--- /dev/null
+++ b/packages/app/src/performance/native-trace.ts
@@ -0,0 +1,38 @@
+import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
+import { requireOptionalNativeModule } from "expo-modules-core";
+import { isProfileBuild } from "@/constants/build-profile";
+
+interface PaseoNativeTraceModule {
+ beginSection(name: string): void;
+ endSection(): void;
+}
+
+const traceModule = requireOptionalNativeModule("PaseoNativeTrace");
+
+export const nativePerformanceTrace: DaemonClientTrace = {
+ isEnabled() {
+ return isProfileBuild && traceModule !== null;
+ },
+ beginSection(name, args) {
+ traceModule?.beginSection(formatSectionName(name, args));
+ },
+ endSection() {
+ traceModule?.endSection();
+ },
+};
+
+function formatSectionName(name: string, args?: Record): string {
+ if (!args) {
+ return name;
+ }
+ const fields = Object.entries(args).map(([key, value]) => `${key}=${value}`);
+ return `${name}|${fields.join(";")}`;
+}
+
+export function traceInstant(name: string, args?: Record): void {
+ if (!nativePerformanceTrace.isEnabled()) {
+ return;
+ }
+ nativePerformanceTrace.beginSection(name, args);
+ nativePerformanceTrace.endSection();
+}
diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts
index 3c3af851b..c89bd9d0a 100644
--- a/packages/app/src/runtime/host-runtime.ts
+++ b/packages/app/src/runtime/host-runtime.ts
@@ -56,6 +56,7 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
+import { nativePerformanceTrace } from "@/performance/native-trace";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -483,6 +484,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
capabilities: appCapabilities,
+ trace: nativePerformanceTrace,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
@@ -521,6 +523,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...(host.serverId ? { serverId: host.serverId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
+ trace: nativePerformanceTrace,
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
@@ -1341,7 +1344,6 @@ interface AgentDirectoryRefreshInput {
export class HostRuntimeStore {
private controllers = new Map();
private serverListeners = new Map void>>();
- private agentStoppedRunningListeners = new Map void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
private version = 0;
@@ -1589,12 +1591,11 @@ export class HostRuntimeStore {
rekeyMap(this.controllers, oldServerId, newServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
- rekeyMap(this.agentStoppedRunningListeners, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, {
- onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(newServerId, agentId),
+ onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -1977,7 +1978,7 @@ export class HostRuntimeStore {
this.directorySyncByServer.set(
host.serverId,
new DirectorySync(host.serverId, {
- onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(host.serverId, agentId),
+ onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
@@ -2176,18 +2177,6 @@ export class HostRuntimeStore {
};
}
- subscribeAgentStoppedRunning(serverId: string, listener: (agentId: string) => void): () => void {
- const listeners = this.agentStoppedRunningListeners.get(serverId) ?? new Set();
- listeners.add(listener);
- this.agentStoppedRunningListeners.set(serverId, listeners);
- return () => {
- const current = this.agentStoppedRunningListeners.get(serverId);
- if (!current) return;
- current.delete(listener);
- if (current.size === 0) this.agentStoppedRunningListeners.delete(serverId);
- };
- }
-
subscribeAll(listener: () => void): () => void {
this.globalListeners.add(listener);
return () => {
@@ -2296,13 +2285,6 @@ export class HostRuntimeStore {
listener();
}
}
-
- private onAgentStoppedRunning(serverId: string, agentId: string): void {
- this.drainQueuedAgentMessage(serverId, agentId);
- for (const listener of this.agentStoppedRunningListeners.get(serverId) ?? []) {
- listener(agentId);
- }
- }
}
let singletonHostRuntimeStore: HostRuntimeStore | null = null;
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 51a05b3f1..14da7bd67 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -74,6 +74,7 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
+import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
collectAllTabs,
@@ -938,6 +939,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
recoveryAgentId,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
+ useEffect(() => {
+ traceInstant("paseo.workspace.mount", { serverId, workspaceId });
+ return () => {
+ traceInstant("paseo.workspace.unmount", { serverId, workspaceId });
+ };
+ }, [serverId, workspaceId]);
return (
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
-test("an idle reconciliation waits for an in-flight tail and fetches a fresh snapshot", async () => {
- const world = new TimelineWorld();
- world.sync.setConnected(true);
- world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
- const membership = await world.nextMembership();
- membership.succeed();
- const initialTail = await world.nextFetch("agent-a");
-
- world.sync.reconcileAgent("agent-a");
-
- world.expectNoPendingFetch();
- initialTail.respond({ hasNewer: false });
-
- const reconciliation = await world.nextFetch("agent-a");
- expect(reconciliation.request).toEqual({
- direction: "tail",
- limit: 40,
- projection: "projected",
- });
- reconciliation.respond({ hasNewer: false });
- await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
-});
-
-test("does not reconcile an agent outside the viewed membership", () => {
- const world = new TimelineWorld();
- world.sync.setConnected(true);
-
- world.sync.reconcileAgent("agent-a");
-
- world.expectNoPendingFetch();
- world.expectNoPendingMembership();
-});
-
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts
index 8fafac06a..dce143d15 100644
--- a/packages/app/src/timeline/viewed-timeline-sync.ts
+++ b/packages/app/src/timeline/viewed-timeline-sync.ts
@@ -33,7 +33,6 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
setActive(active: boolean): void;
setConnected(connected: boolean): void;
setDeliveryMode(mode: TimelineDeliveryMode): void;
- reconcileAgent(agentId: string): void;
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
dispose(): void;
}
@@ -451,15 +450,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (deliveryMode === "selective" && connected) void reconcileMembership();
else if (connected) startAcknowledgedCatchUps();
},
- reconcileAgent(agentId) {
- if (!isDesired(agentId)) return;
- const request = planTimelineTailFetch();
- if (catchUps.get(agentId)?.status === "running") {
- pendingCatchUps.set(agentId, request);
- return;
- }
- startCatchUp(agentId, { request, supersede: true });
- },
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
startCatchUp(agentId, {
diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts
index 0a38735bc..c13f0593d 100644
--- a/packages/app/src/utils/test-daemon-connection.test.ts
+++ b/packages/app/src/utils/test-daemon-connection.test.ts
@@ -134,6 +134,27 @@ describe("test-daemon-connection connectToDaemon", () => {
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
});
+ it("passes performance tracing into the connected client", async () => {
+ const { connectToDaemon } = await import("./test-daemon-connection");
+ const trace = {
+ isEnabled: () => true,
+ beginSection: vi.fn(),
+ endSection: vi.fn(),
+ };
+ const result = await connectToDaemon(
+ {
+ id: "direct:lan:6767",
+ type: "directTcp",
+ endpoint: "lan:6767",
+ },
+ { trace },
+ probe.deps,
+ );
+ await result.client.close();
+
+ expect(probe.createdConfigs()[0]?.trace).toBe(trace);
+ });
+
it("uses relay TLS from the stored connection", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const tlsResult = await connectToDaemon(
diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts
index 4f138e3c2..381cb1afb 100644
--- a/packages/app/src/utils/test-daemon-connection.ts
+++ b/packages/app/src/utils/test-daemon-connection.ts
@@ -97,7 +97,10 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string,
- options?: { capabilities?: DaemonClientConfig["capabilities"] },
+ options?: {
+ capabilities?: DaemonClientConfig["capabilities"];
+ trace?: DaemonClientConfig["trace"];
+ },
deps: Pick<
DaemonConnectionDependencies,
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
@@ -112,6 +115,7 @@ export async function buildClientConfig(
suppressSendErrors: true,
reconnect: { enabled: false },
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
+ ...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
localTransportFactory
? { transportFactory: localTransportFactory }
@@ -224,6 +228,7 @@ interface ProbeOptions {
serverId?: string;
timeoutMs?: number;
capabilities?: DaemonClientConfig["capabilities"];
+ trace?: DaemonClientConfig["trace"];
}
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts
index 0e78ce4c2..65d4addcb 100644
--- a/packages/client/src/compat/normalize-provider-models.ts
+++ b/packages/client/src/compat/normalize-provider-models.ts
@@ -1,5 +1,6 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
+import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import type {
GetProvidersSnapshotResponseMessage,
ListProviderModelsResponseMessage,
@@ -59,7 +60,9 @@ export function normalizeListProviderModelsPayload(
export function normalizeProvidersSnapshotPayload<
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
>(payload: T): T {
- const entries = normalizeProviderSnapshotEntries(payload.entries);
+ const entries = payload.compactSnapshot
+ ? expandProviderSnapshot(payload.compactSnapshot)
+ : normalizeProviderSnapshotEntries(payload.entries);
return entries === payload.entries ? payload : { ...payload, entries };
}
diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts
index d3425fb79..1c9b7faf6 100644
--- a/packages/client/src/daemon-client.test.ts
+++ b/packages/client/src/daemon-client.test.ts
@@ -1,6 +1,11 @@
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
-import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
+import {
+ DaemonClient,
+ type DaemonClientTrace,
+ type DaemonTransport,
+ type Logger,
+} from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
@@ -34,6 +39,24 @@ function createMockLogger() {
};
}
+interface TraceRecord {
+ phase: "begin" | "end";
+ name?: string;
+ args?: Record;
+}
+
+function createTraceRecorder(): { trace: DaemonClientTrace; records: TraceRecord[] } {
+ const records: TraceRecord[] = [];
+ return {
+ trace: {
+ isEnabled: () => true,
+ beginSection: (name, args) => records.push({ phase: "begin", name, args }),
+ endSection: () => records.push({ phase: "end" }),
+ },
+ records,
+ };
+}
+
function createMockTransport() {
const sent: Array = [];
@@ -161,6 +184,56 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
+test("traces WebSocket frames, message types, and JSON parse duration", async () => {
+ const mock = createMockTransport();
+ const recorder = createTraceRecorder();
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "trace_unit_test",
+ transportFactory: () => mock.transport,
+ reconnect: { enabled: false },
+ trace: recorder.trace,
+ });
+ clients.push(client);
+
+ const connectPromise = client.connect();
+ mock.triggerOpen({ preserveSent: true });
+ await connectPromise;
+
+ expect(recorder.records).toEqual([
+ {
+ phase: "begin",
+ name: "paseo.ws.message.outbound",
+ args: { envelopeType: "hello", messageType: "hello" },
+ },
+ { phase: "end" },
+ {
+ phase: "begin",
+ name: "paseo.ws.frame.outbound",
+ args: { kind: "text", size: expect.any(String) },
+ },
+ { phase: "end" },
+ {
+ phase: "begin",
+ name: "paseo.ws.frame.inbound",
+ args: { kind: "text", size: expect.any(String) },
+ },
+ {
+ phase: "begin",
+ name: "paseo.ws.json.parse",
+ args: { size: expect.any(String) },
+ },
+ { phase: "end" },
+ {
+ phase: "begin",
+ name: "paseo.ws.message.inbound",
+ args: { envelopeType: "session", messageType: "status" },
+ },
+ { phase: "end" },
+ { phase: "end" },
+ ]);
+});
+
test("does not infer browser automation capabilities from Electron runtime", async () => {
vi.stubGlobal("navigator", {
userAgent:
@@ -664,6 +737,7 @@ test("advertises client capabilities in hello", async () => {
clientType: "cli",
protocolVersion: 1,
capabilities: {
+ compact_provider_snapshots: true,
custom_mode_icons: true,
project_updates: true,
provider_subagents: true,
@@ -4384,6 +4458,81 @@ test("lists available providers via RPC", async () => {
});
});
+test("requests provider snapshots conditionally and expands the compact response", async () => {
+ const mock = createMockTransport();
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "clsk_provider_snapshot_test",
+ reconnect: { enabled: false },
+ transportFactory: () => mock.transport,
+ });
+ clients.push(client);
+
+ const connectPromise = client.connect();
+ mock.triggerOpen();
+ await connectPromise;
+
+ const promise = client.getProvidersSnapshot({ cwd: "/repo", ifNoneMatch: "previous-hash" });
+ const request = parseSentFrame(mock.sent[0]);
+ expect(request).toMatchObject({
+ type: "get_providers_snapshot_request",
+ cwd: "/repo",
+ ifNoneMatch: "previous-hash",
+ });
+
+ mock.triggerMessage(
+ wrapSessionMessage({
+ type: "get_providers_snapshot_response",
+ payload: {
+ entries: [],
+ compactSnapshot: {
+ entries: [
+ {
+ provider: "pi",
+ status: "ready",
+ enabled: true,
+ models: [
+ { id: "model-a", label: "Model A", thinkingSet: 0 },
+ { id: "model-b", label: "Model B", thinkingSet: 0 },
+ ],
+ },
+ ],
+ thinkingSets: [
+ {
+ options: [{ id: "high", label: "High", isDefault: true }],
+ defaultOptionId: "high",
+ },
+ ],
+ },
+ snapshotHash: "next-hash",
+ generatedAt: "2026-08-04T00:00:00.000Z",
+ requestId: request.requestId,
+ },
+ }),
+ );
+
+ const response = await promise;
+ expect(response.entries[0]?.models).toEqual([
+ {
+ provider: "pi",
+ id: "model-a",
+ label: "Model A",
+ thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
+ defaultThinkingOptionId: "high",
+ },
+ {
+ provider: "pi",
+ id: "model-b",
+ label: "Model B",
+ thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
+ defaultThinkingOptionId: "high",
+ },
+ ]);
+ expect(response.entries[0]?.models?.[0]?.thinkingOptions).toBe(
+ response.entries[0]?.models?.[1]?.thinkingOptions,
+ );
+});
+
test("lists commands with draft config via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts
index 1d5d1ea2c..72b0cb61a 100644
--- a/packages/client/src/daemon-client.ts
+++ b/packages/client/src/daemon-client.ts
@@ -317,9 +317,16 @@ export interface DaemonClientConfig {
};
runtimeMetricsIntervalMs?: number;
runtimeMetricsWindowMs?: number;
+ trace?: DaemonClientTrace;
capabilities?: Partial>;
}
+export interface DaemonClientTrace {
+ isEnabled(): boolean;
+ beginSection(name: string, args?: Record): void;
+ endSection(): void;
+}
+
export interface SendMessageOptions {
messageId?: string;
images?: Array<{ data: string; mimeType: string }>;
@@ -1035,6 +1042,26 @@ function concatByteChunks(chunks: Uint8Array[], size: number): Uint8Array {
return bytes;
}
+function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number {
+ if (typeof frame === "string") {
+ return frame.length;
+ }
+ return frame.byteLength;
+}
+
+function describeInboundTransportFrame(
+ frame: unknown,
+ rawBytes: Uint8Array | null,
+): Record {
+ if (typeof frame === "string") {
+ return { kind: "text", size: String(frame.length) };
+ }
+ if (rawBytes) {
+ return { kind: "binary", size: String(rawBytes.byteLength) };
+ }
+ return { kind: "unknown", size: "0" };
+}
+
function hashForLog(value: string): string {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -1535,6 +1562,49 @@ export class DaemonClient {
// Core Send Helpers
// ============================================================================
+ private beginTraceSection(name: string, args?: Record): boolean {
+ const trace = this.config.trace;
+ if (!trace?.isEnabled()) {
+ return false;
+ }
+ trace.beginSection(name, args);
+ return true;
+ }
+
+ private endTraceSection(isOpen: boolean): void {
+ if (isOpen) {
+ this.config.trace?.endSection();
+ }
+ }
+
+ private traceInstant(name: string, args?: Record): void {
+ const isOpen = this.beginTraceSection(name, args);
+ this.endTraceSection(isOpen);
+ }
+
+ private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
+ this.traceInstant("paseo.ws.message.outbound", {
+ envelopeType,
+ messageType,
+ });
+ this.sendTransportFrame(JSON.stringify(message));
+ }
+
+ private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
+ if (!this.transport) {
+ throw new Error("Transport not connected");
+ }
+ const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
+ kind: typeof frame === "string" ? "text" : "binary",
+ size: String(getTransportFrameSize(frame)),
+ });
+ try {
+ this.transport.send(frame);
+ } finally {
+ this.endTraceSection(isOpen);
+ }
+ }
+
/**
* Send a session message. For fire-and-forget messages (heartbeats, etc.),
* failures are suppressed if `suppressSendErrors` is configured.
@@ -1549,7 +1619,7 @@ export class DaemonClient {
}
const payload = SessionInboundMessageSchema.parse(message);
try {
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -1566,7 +1636,11 @@ export class DaemonClient {
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
try {
- this.transport.send(frame);
+ this.traceInstant("paseo.ws.message.outbound", {
+ envelopeType: "binary",
+ messageType: "binary",
+ });
+ this.sendTransportFrame(frame);
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -1587,7 +1661,7 @@ export class DaemonClient {
// If connected, send immediately
if (this.transport && status === "connected") {
const payload = SessionInboundMessageSchema.parse(message);
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
return Promise.resolve();
}
@@ -1623,7 +1697,7 @@ export class DaemonClient {
try {
if (this.transport && this.connectionState.status === "connected") {
const payload = SessionInboundMessageSchema.parse(pending.message);
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
pending.resolve();
} else {
pending.reject(new Error("Connection lost before message could be sent"));
@@ -1777,7 +1851,7 @@ export class DaemonClient {
}
const payload = SessionInboundMessageSchema.parse(message);
try {
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
@@ -1952,7 +2026,7 @@ export class DaemonClient {
this.pingProbe = probe;
try {
- this.transport.send(JSON.stringify({ type: "ping" }));
+ this.sendJsonMessage("ping", "ping", { type: "ping" });
} catch (error) {
this.clearPingProbe();
const sendError = error instanceof Error ? error : new Error(String(error));
@@ -4421,6 +4495,7 @@ export class DaemonClient {
async getProvidersSnapshot(options?: {
cwd?: string;
+ ifNoneMatch?: string;
requestId?: string;
}): Promise {
const payload = await this.sendCorrelatedSessionRequest({
@@ -4428,6 +4503,7 @@ export class DaemonClient {
message: {
type: "get_providers_snapshot_request",
cwd: options?.cwd,
+ ifNoneMatch: options?.ifNoneMatch,
},
responseType: "get_providers_snapshot_response",
});
@@ -5326,23 +5402,22 @@ export class DaemonClient {
}
try {
- this.transport.send(
- JSON.stringify({
- type: "hello",
- clientId: this.config.clientId,
- clientType: this.config.clientType ?? "cli",
- protocolVersion: 1,
- capabilities: {
- [CLIENT_CAPS.customModeIcons]: true,
- [CLIENT_CAPS.reasoningMergeEnum]: true,
- [CLIENT_CAPS.terminalReflowableSnapshot]: true,
- [CLIENT_CAPS.providerSubagents]: true,
- [CLIENT_CAPS.projectUpdates]: true,
- ...this.config.capabilities,
- },
- ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
- }),
- );
+ this.sendJsonMessage("hello", "hello", {
+ type: "hello",
+ clientId: this.config.clientId,
+ clientType: this.config.clientType ?? "cli",
+ protocolVersion: 1,
+ capabilities: {
+ [CLIENT_CAPS.customModeIcons]: true,
+ [CLIENT_CAPS.reasoningMergeEnum]: true,
+ [CLIENT_CAPS.terminalReflowableSnapshot]: true,
+ [CLIENT_CAPS.providerSubagents]: true,
+ [CLIENT_CAPS.projectUpdates]: true,
+ [CLIENT_CAPS.compactProviderSnapshots]: true,
+ ...this.config.capabilities,
+ },
+ ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
+ });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to send hello message";
this.lastErrorValue = message;
@@ -5413,24 +5488,37 @@ export class DaemonClient {
}
const rawBytes = asUint8Array(rawData);
- if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
- return;
+ const isOpen = this.beginTraceSection(
+ "paseo.ws.frame.inbound",
+ describeInboundTransportFrame(rawData, rawBytes),
+ );
+ try {
+ if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
+ return;
+ }
+ const payload = decodeMessageData(rawData);
+ if (!payload) {
+ return;
+ }
+ this.handleJsonPayload(payload, rawBytes?.byteLength);
+ } finally {
+ this.endTraceSection(isOpen);
}
- const payload = decodeMessageData(rawData);
- if (!payload) {
- return;
- }
- this.handleJsonPayload(payload, rawBytes?.byteLength);
}
private handleJsonPayload(payload: string, rawBytesLength: number | undefined): void {
const bytes = rawBytesLength ?? payload.length;
const startMs = perfNow();
let parsedJson: unknown;
+ const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
+ size: String(bytes),
+ });
try {
parsedJson = JSON.parse(payload);
} catch {
return;
+ } finally {
+ this.endTraceSection(parseTraceOpen);
}
const parsed = validateWSOutboundMessage(parsedJson);
@@ -5457,11 +5545,19 @@ export class DaemonClient {
this.consecutiveLivenessFailures = 0;
if (parsed.data.type === "pong") {
+ this.traceInstant("paseo.ws.message.inbound", {
+ envelopeType: "pong",
+ messageType: "pong",
+ });
this.resolvePingProbe();
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
return;
}
+ this.traceInstant("paseo.ws.message.inbound", {
+ envelopeType: "session",
+ messageType: parsed.data.message.type,
+ });
this.handleSessionMessage(parsed.data.message);
const msgType = parsed.data.message.type;
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
@@ -5473,6 +5569,11 @@ export class DaemonClient {
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
+ this.traceInstant("paseo.ws.message.inbound", {
+ envelopeType: "binary",
+ messageType: "file",
+ opcode: String(fileFrame.opcode),
+ });
this.consecutiveLivenessFailures = 0;
this.handleFileTransferFrame(fileFrame);
this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
@@ -5483,6 +5584,11 @@ export class DaemonClient {
if (!frame) {
return false;
}
+ this.traceInstant("paseo.ws.message.inbound", {
+ envelopeType: "binary",
+ messageType: "terminal",
+ opcode: String(frame.opcode),
+ });
this.consecutiveLivenessFailures = 0;
const binaryStartMs = perfNow();
this.terminalStreams.handleFrame(frame);
diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts
index f9ccbeec9..dd2b90369 100644
--- a/packages/protocol/src/client-capabilities.ts
+++ b/packages/protocol/src/client-capabilities.ts
@@ -20,6 +20,10 @@ export const CLIENT_CAPS = {
providerSubagents: "provider_subagents",
// COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15.
projectUpdates: "project_updates",
+ // COMPAT(compactProviderSnapshots): added in v0.2.X. Capable clients receive
+ // provider catalogs with shared thinking sets and may revalidate by content hash.
+ // Remove the legacy snapshot encoding after 2027-02-04.
+ compactProviderSnapshots: "compact_provider_snapshots",
browserHost: "browser_host",
} as const;
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index 09b536d96..1b011bc2d 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -256,7 +256,7 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema,
]);
-const AgentModelDefinitionSchema: z.ZodType = z.object({
+const AgentModelDefinitionSchema = z.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
@@ -266,7 +266,7 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
-});
+}) satisfies z.ZodType;
export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
@@ -282,6 +282,29 @@ export const ProviderSnapshotEntrySchema = z.object({
defaultModeId: z.string().nullable().optional(),
});
+export const CompactProviderSnapshotModelSchema = AgentModelDefinitionSchema.omit({
+ provider: true,
+ thinkingOptions: true,
+}).extend({
+ thinkingSet: z.number().int().nonnegative().optional(),
+});
+
+export const ProviderSnapshotThinkingSetSchema = z.object({
+ options: z.array(AgentSelectOptionSchema),
+ defaultOptionId: z.string().optional(),
+});
+
+export const CompactProviderSnapshotEntrySchema = ProviderSnapshotEntrySchema.omit({
+ models: true,
+}).extend({
+ models: z.array(CompactProviderSnapshotModelSchema).optional(),
+});
+
+export const CompactProviderSnapshotSchema = z.object({
+ entries: z.array(CompactProviderSnapshotEntrySchema),
+ thinkingSets: z.array(ProviderSnapshotThinkingSetSchema),
+});
+
const AgentCapabilityFlagsSchema: z.ZodType = z
.object({
supportsStreaming: z.boolean(),
@@ -1326,6 +1349,8 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
export const GetProvidersSnapshotRequestMessageSchema = z.object({
type: z.literal("get_providers_snapshot_request"),
cwd: z.string().optional(),
+ // COMPAT(compactProviderSnapshots): old daemons ignore this field and return a full snapshot.
+ ifNoneMatch: z.string().optional(),
requestId: z.string(),
});
@@ -4956,6 +4981,9 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({
type: z.literal("get_providers_snapshot_response"),
payload: z.object({
entries: z.array(ProviderSnapshotEntrySchema),
+ compactSnapshot: CompactProviderSnapshotSchema.optional(),
+ snapshotHash: z.string().optional(),
+ notModified: z.boolean().optional(),
generatedAt: z.string(),
requestId: z.string(),
}),
@@ -4967,6 +4995,8 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({
payload: z.object({
cwd: z.string().optional(),
entries: z.array(ProviderSnapshotEntrySchema),
+ compactSnapshot: CompactProviderSnapshotSchema.optional(),
+ snapshotHash: z.string().optional(),
generatedAt: z.string(),
}),
});
@@ -5938,6 +5968,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
[CLIENT_CAPS.projectUpdates]: z.boolean().optional(),
+ [CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
})
.passthrough()
diff --git a/packages/protocol/src/provider-snapshot-codec.test.ts b/packages/protocol/src/provider-snapshot-codec.test.ts
new file mode 100644
index 000000000..b042c5a4e
--- /dev/null
+++ b/packages/protocol/src/provider-snapshot-codec.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import type { ProviderSnapshotEntry } from "./agent-types.js";
+import { compactProviderSnapshot, expandProviderSnapshot } from "./provider-snapshot-codec.js";
+
+const sharedThinking = [
+ { id: "off", label: "Off", description: "No extra reasoning" },
+ { id: "minimal", label: "Minimal", description: "Light reasoning" },
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium", description: "Balanced reasoning" },
+ { id: "high", label: "High", isDefault: true },
+ { id: "xhigh", label: "XHigh", description: "Very deep reasoning" },
+ { id: "max", label: "Max", description: "Extreme reasoning" },
+];
+
+function providerEntry(): ProviderSnapshotEntry {
+ return {
+ provider: "pi",
+ status: "ready",
+ enabled: true,
+ models: [
+ {
+ provider: "pi",
+ id: "openai/gpt-a",
+ label: "GPT A",
+ description: "openai/gpt-a",
+ metadata: { provider: "openai", modelId: "gpt-a" },
+ thinkingOptions: sharedThinking,
+ defaultThinkingOptionId: "high",
+ },
+ {
+ provider: "pi",
+ id: "openai/gpt-b",
+ label: "GPT B",
+ metadata: { provider: "openai", modelId: "gpt-b" },
+ thinkingOptions: structuredClone(sharedThinking),
+ defaultThinkingOptionId: "high",
+ },
+ ],
+ modes: [{ id: "build", label: "Build" }],
+ };
+}
+
+describe("provider snapshot codec", () => {
+ it("shares repeated thinking options while preserving the public snapshot shape", () => {
+ const original = [providerEntry()];
+ const compact = compactProviderSnapshot(original);
+ const expanded = expandProviderSnapshot(compact);
+
+ expect(compact.thinkingSets).toHaveLength(1);
+ expect(compact.entries[0]?.models?.map((model) => model.thinkingSet)).toEqual([0, 0]);
+ expect(expanded).toEqual(original);
+ expect(expanded[0]?.models?.[0]?.thinkingOptions).toBe(
+ expanded[0]?.models?.[1]?.thinkingOptions,
+ );
+ });
+
+ it("shrinks catalogs dominated by one repeated thinking set", () => {
+ const model = providerEntry().models?.[0];
+ if (!model) throw new Error("fixture model missing");
+ const entries: ProviderSnapshotEntry[] = [
+ {
+ provider: "pi",
+ status: "ready",
+ enabled: true,
+ models: Array.from({ length: 200 }, (_, index) => ({
+ ...model,
+ id: `openai/model-${index}`,
+ label: `Model ${index}`,
+ metadata: { provider: "openai", modelId: `model-${index}` },
+ })),
+ },
+ ];
+
+ const legacyBytes = JSON.stringify(entries).length;
+ const compactBytes = JSON.stringify(compactProviderSnapshot(entries)).length;
+
+ expect(compactBytes).toBeLessThan(legacyBytes * 0.5);
+ });
+});
diff --git a/packages/protocol/src/provider-snapshot-codec.ts b/packages/protocol/src/provider-snapshot-codec.ts
new file mode 100644
index 000000000..a90002ee6
--- /dev/null
+++ b/packages/protocol/src/provider-snapshot-codec.ts
@@ -0,0 +1,119 @@
+import type {
+ AgentModelDefinition,
+ AgentSelectOption,
+ ProviderSnapshotEntry,
+} from "./agent-types.js";
+
+export interface CompactProviderSnapshotModel extends Omit<
+ AgentModelDefinition,
+ "provider" | "thinkingOptions"
+> {
+ thinkingSet?: number;
+}
+
+export interface CompactProviderSnapshotEntry extends Omit {
+ models?: CompactProviderSnapshotModel[];
+}
+
+export interface ProviderSnapshotThinkingSet {
+ options: AgentSelectOption[];
+ defaultOptionId?: string;
+}
+
+export interface CompactProviderSnapshot {
+ entries: CompactProviderSnapshotEntry[];
+ thinkingSets: ProviderSnapshotThinkingSet[];
+}
+
+function thinkingSetKey(set: ProviderSnapshotThinkingSet): string {
+ return JSON.stringify(set);
+}
+
+function compactModel(
+ {
+ provider: _provider,
+ thinkingOptions,
+ defaultThinkingOptionId,
+ ...modelFields
+ }: AgentModelDefinition,
+ thinkingSets: ProviderSnapshotThinkingSet[],
+ thinkingSetIndexes: Map,
+): CompactProviderSnapshotModel {
+ const compact: CompactProviderSnapshotModel = modelFields;
+
+ if (thinkingOptions === undefined) {
+ if (defaultThinkingOptionId !== undefined) {
+ compact.defaultThinkingOptionId = defaultThinkingOptionId;
+ }
+ return compact;
+ }
+
+ const thinkingSet: ProviderSnapshotThinkingSet = {
+ options: thinkingOptions,
+ ...(defaultThinkingOptionId !== undefined ? { defaultOptionId: defaultThinkingOptionId } : {}),
+ };
+ const key = thinkingSetKey(thinkingSet);
+ const existingIndex = thinkingSetIndexes.get(key);
+ if (existingIndex !== undefined) {
+ compact.thinkingSet = existingIndex;
+ return compact;
+ }
+
+ const index = thinkingSets.length;
+ thinkingSets.push(thinkingSet);
+ thinkingSetIndexes.set(key, index);
+ compact.thinkingSet = index;
+ return compact;
+}
+
+export function compactProviderSnapshot(entries: ProviderSnapshotEntry[]): CompactProviderSnapshot {
+ const thinkingSets: ProviderSnapshotThinkingSet[] = [];
+ const thinkingSetIndexes = new Map();
+ const compactEntries = entries.map((entry): CompactProviderSnapshotEntry => {
+ if (entry.models === undefined) {
+ return entry;
+ }
+ return {
+ ...entry,
+ models: entry.models.map((model) => compactModel(model, thinkingSets, thinkingSetIndexes)),
+ };
+ });
+ return { entries: compactEntries, thinkingSets };
+}
+
+function expandModel(
+ provider: string,
+ model: CompactProviderSnapshotModel,
+ thinkingSets: ProviderSnapshotThinkingSet[],
+): AgentModelDefinition {
+ const { thinkingSet: thinkingSetIndex, ...modelFields } = model;
+ if (thinkingSetIndex === undefined) {
+ return { provider, ...modelFields };
+ }
+
+ const thinkingSet = thinkingSets[thinkingSetIndex];
+ if (!thinkingSet) {
+ throw new RangeError(`Provider snapshot references missing thinking set ${thinkingSetIndex}`);
+ }
+ return {
+ provider,
+ ...modelFields,
+ thinkingOptions: thinkingSet.options,
+ ...(thinkingSet.defaultOptionId !== undefined
+ ? { defaultThinkingOptionId: thinkingSet.defaultOptionId }
+ : {}),
+ };
+}
+
+export function expandProviderSnapshot(snapshot: CompactProviderSnapshot): ProviderSnapshotEntry[] {
+ return snapshot.entries.map((entry): ProviderSnapshotEntry => {
+ const { models, ...entryFields } = entry;
+ if (models === undefined) return entryFields;
+ return {
+ ...entryFields,
+ models: models.map((model) =>
+ expandModel(entryFields.provider, model, snapshot.thinkingSets),
+ ),
+ };
+ });
+}
diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts
index ffc1795f4..3aede5cb2 100644
--- a/packages/protocol/tests/validation/ws-outbound.test.ts
+++ b/packages/protocol/tests/validation/ws-outbound.test.ts
@@ -138,6 +138,42 @@ const SourceSchema = z.object({
);
});
+ it("accepts a compact provider snapshot envelope", () => {
+ const envelope = {
+ type: "session",
+ message: {
+ type: "get_providers_snapshot_response",
+ payload: {
+ entries: [],
+ compactSnapshot: {
+ entries: [
+ {
+ provider: "pi",
+ status: "ready",
+ enabled: true,
+ models: [{ id: "model-a", label: "Model A", thinkingSet: 0 }],
+ },
+ ],
+ thinkingSets: [
+ {
+ options: [{ id: "high", label: "High", isDefault: true }],
+ defaultOptionId: "high",
+ },
+ ],
+ },
+ snapshotHash: "snapshot-hash",
+ generatedAt: "2026-08-04T00:00:00.000Z",
+ requestId: "provider-snapshot",
+ },
+ },
+ };
+
+ expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
+ success: true,
+ data: envelope,
+ });
+ });
+
it.each([
{
name: "dedicated attention message",
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 6d95d32a1..0ae81a21c 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -841,6 +841,7 @@ export class Session {
emit: (msg) => this.emit(msg),
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons),
+ supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots),
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
},
diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts
index d2e5b7b7d..8ed07b0d5 100644
--- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts
+++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts
@@ -13,12 +13,14 @@ import {
} from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
+import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void;
interface MakeOptions {
visibleProviders?: Set;
supportsCustomModeIcons?: boolean;
+ supportsCompactProviderSnapshots?: boolean;
snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown };
usage?: { [K in keyof ProviderUsageService]?: unknown };
host?: Partial;
@@ -49,6 +51,7 @@ function makeSubsystem(options: MakeOptions = {}) {
emit: (msg) => emitted.push(msg),
isProviderVisibleToClient: (provider) => visible.has(provider),
supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false,
+ supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
...options.host,
@@ -113,6 +116,21 @@ describe("ProviderCatalogSession", () => {
expect(pull?.payload.entries).toEqual(push?.payload.entries);
});
+ it("pushes the compact encoding to capable clients", () => {
+ const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({
+ supportsCustomModeIcons: true,
+ supportsCompactProviderSnapshots: true,
+ });
+
+ subsystem.start();
+ pushSnapshotChange(makeEntries());
+
+ const push = findByType(emitted, "providers_snapshot_update");
+ expect(push?.payload.entries).toEqual([]);
+ expect(push?.payload.snapshotHash).toEqual(expect.any(String));
+ expect(expandProviderSnapshot(push!.payload.compactSnapshot!)).toEqual([makeEntries()[0]]);
+ });
+
it("preserves custom mode icons when the client supports them", async () => {
const { subsystem, emitted } = makeSubsystem({
supportsCustomModeIcons: true,
@@ -128,6 +146,60 @@ describe("ProviderCatalogSession", () => {
expect(pull?.payload.entries[0]?.modes?.[0]?.icon).toBe("Sparkles");
});
+ it("sends capable clients a compact snapshot and returns not-modified for its hash", async () => {
+ const thinkingOptions = [
+ { id: "low", label: "Low" },
+ { id: "high", label: "High", isDefault: true },
+ ];
+ const entries: ProviderSnapshotEntry[] = [
+ {
+ provider: "codex",
+ status: "ready",
+ enabled: true,
+ models: ["one", "two"].map((id) => ({
+ provider: "codex",
+ id,
+ label: id,
+ thinkingOptions,
+ defaultThinkingOptionId: "high",
+ })),
+ },
+ ];
+ const { subsystem, emitted } = makeSubsystem({
+ supportsCompactProviderSnapshots: true,
+ snapshot: { getSnapshot: () => entries },
+ });
+
+ await subsystem.handleGetProvidersSnapshotRequest({
+ type: "get_providers_snapshot_request",
+ requestId: "compact-1",
+ });
+
+ const first = findByType(emitted, "get_providers_snapshot_response");
+ expect(first?.payload.entries).toEqual([]);
+ expect(first?.payload.snapshotHash).toEqual(expect.any(String));
+ expect(first?.payload.compactSnapshot?.thinkingSets).toHaveLength(1);
+ expect(expandProviderSnapshot(first!.payload.compactSnapshot!)).toEqual(entries);
+
+ await subsystem.handleGetProvidersSnapshotRequest({
+ type: "get_providers_snapshot_request",
+ requestId: "compact-2",
+ ifNoneMatch: first?.payload.snapshotHash,
+ });
+
+ const responses = emitted.filter(
+ (message) => message.type === "get_providers_snapshot_response",
+ );
+ const second = responses[1];
+ expect(second?.payload).toMatchObject({
+ entries: [],
+ snapshotHash: first?.payload.snapshotHash,
+ notModified: true,
+ requestId: "compact-2",
+ });
+ expect(second?.payload.compactSnapshot).toBeUndefined();
+ });
+
it("reports a disabled provider on list_provider_models without warming the snapshot", async () => {
// warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called,
// so the disabled short-circuit is proven by the absence of a throw.
diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts
index dbcf94075..a394e4832 100644
--- a/packages/server/src/server/session/provider/provider-catalog-session.ts
+++ b/packages/server/src/server/session/provider/provider-catalog-session.ts
@@ -1,5 +1,10 @@
import type pino from "pino";
+import { createHash } from "node:crypto";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
+import {
+ compactProviderSnapshot,
+ type CompactProviderSnapshot,
+} from "@getpaseo/protocol/provider-snapshot-codec";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import {
isGlobalProviderSnapshotKey,
@@ -39,6 +44,7 @@ export interface ProviderCatalogSessionHost {
isProviderVisibleToClient(provider: string): boolean;
// COMPAT(customModeIcons): reads clientCapabilities live.
supportsCustomModeIcons(): boolean;
+ supportsCompactProviderSnapshots(): boolean;
listProviderAvailability(): Promise;
listDraftFeatures(config: AgentSessionConfig): Promise;
}
@@ -50,6 +56,19 @@ export interface ProviderCatalogSessionOptions {
logger: pino.Logger;
}
+interface EncodedProviderSnapshot {
+ compactSnapshot: CompactProviderSnapshot;
+ snapshotHash: string;
+}
+
+function encodeProviderSnapshot(entries: ProviderSnapshotEntry[]): EncodedProviderSnapshot {
+ const compactSnapshot = compactProviderSnapshot(entries);
+ const snapshotHash = createHash("sha256")
+ .update(JSON.stringify(compactSnapshot))
+ .digest("base64url");
+ return { compactSnapshot, snapshotHash };
+}
+
/**
* A client's provider catalog surface: model / mode / feature listing, the providers
* snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and
@@ -78,11 +97,25 @@ export class ProviderCatalogSession {
this.host.isProviderVisibleToClient(entry.provider),
);
const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd;
+ const clientEntries = this.downgradeEntryModesForClient(visibleEntries);
+ if (this.host.supportsCompactProviderSnapshots()) {
+ const encoded = encodeProviderSnapshot(clientEntries);
+ this.host.emit({
+ type: "providers_snapshot_update",
+ payload: {
+ ...(snapshotCwd ? { cwd: snapshotCwd } : {}),
+ entries: [],
+ ...encoded,
+ generatedAt: new Date().toISOString(),
+ },
+ });
+ return;
+ }
this.host.emit({
type: "providers_snapshot_update",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
- entries: this.downgradeEntryModesForClient(visibleEntries),
+ entries: clientEntries,
generatedAt: new Date().toISOString(),
},
});
@@ -360,11 +393,29 @@ export class ProviderCatalogSession {
const entries = this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.filter((entry) => this.host.isProviderVisibleToClient(entry.provider));
+ const clientEntries = this.downgradeEntryModesForClient(entries);
+
+ if (this.host.supportsCompactProviderSnapshots()) {
+ const encoded = encodeProviderSnapshot(clientEntries);
+ const notModified = msg.ifNoneMatch === encoded.snapshotHash;
+ this.host.emit({
+ type: "get_providers_snapshot_response",
+ payload: {
+ entries: [],
+ ...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}),
+ snapshotHash: encoded.snapshotHash,
+ ...(notModified ? { notModified: true } : {}),
+ generatedAt: new Date().toISOString(),
+ requestId: msg.requestId,
+ },
+ });
+ return;
+ }
this.host.emit({
type: "get_providers_snapshot_response",
payload: {
- entries: this.downgradeEntryModesForClient(entries),
+ entries: clientEntries,
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
},