diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 7cca7987d..7e7f72c9d 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -49,6 +49,7 @@ import {
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
+import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
polyfillCrypto();
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -442,47 +443,49 @@ function MissingDaemonView() {
export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 89d7c5c3a..a463e0f08 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -65,10 +65,26 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore();
- const runtimeVersion = useSyncExternalStore(
+ const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
- () => runtime.getVersion(),
- () => runtime.getVersion()
+ () =>
+ daemons
+ .map(
+ (daemon) =>
+ `${daemon.serverId}:${
+ runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
+ }`
+ )
+ .join("|"),
+ () =>
+ daemons
+ .map(
+ (daemon) =>
+ `${daemon.serverId}:${
+ runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
+ }`
+ )
+ .join("|")
);
const activeServerIdFromPath = useMemo(
() => parseServerIdFromPathname(pathname),
@@ -99,7 +115,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
),
})),
- [daemons, runtime, runtimeVersion]
+ [daemons, runtime, runtimeConnectionStatusSignature]
);
const hostTriggerRef = useRef(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
@@ -386,6 +402,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) : (
) : (
0;
const projectIconRequests = useMemo(() => {
+ if (!isOpen) {
+ return [];
+ }
const unique = new Map();
for (const option of projectFilterOptions) {
if (!option.serverId || !option.workingDir) {
@@ -513,7 +526,7 @@ export function SidebarAgentList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd });
}
return Array.from(unique.values());
- }, [entries, projectFilterOptions]);
+ }, [entries, isOpen, projectFilterOptions]);
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -526,8 +539,10 @@ export function SidebarAgentList({
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
+ select: toProjectIconDataUri,
enabled: Boolean(
- getHostRuntimeStore().getClient(request.serverId) &&
+ isOpen &&
+ getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(
getHostRuntimeStore().getSnapshot(request.serverId)
) &&
@@ -548,10 +563,7 @@ export function SidebarAgentList({
if (!request) {
continue;
}
- const icon = projectIconQueries[i]?.data ?? null;
- const dataUri = icon
- ? `data:${icon.mimeType};base64,${icon.data}`
- : null;
+ const dataUri = projectIconQueries[i]?.data ?? null;
map.set(`${request.serverId}:${request.cwd}`, dataUri);
}
return map;
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 014b70729..c6c322065 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -205,6 +205,9 @@ function SessionProviderInternal({
const setAgentLastActivity = useSessionStore(
(state) => state.setAgentLastActivity
);
+ const flushAgentLastActivity = useSessionStore(
+ (state) => state.flushAgentLastActivity
+ );
const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions
);
@@ -376,10 +379,11 @@ function SessionProviderInternal({
// If the client drops mid-initialization, clear pending flags
useEffect(() => {
if (!isConnected) {
+ flushAgentLastActivity();
pendingAgentUpdatesRef.current.clear();
setInitializingAgents(serverId, new Map());
}
- }, [serverId, isConnected, setInitializingAgents]);
+ }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
const applyAgentUpdatePayload = useCallback(
(update: AgentUpdatePayload) => {
@@ -430,7 +434,6 @@ function SessionProviderInternal({
next.delete(agentId);
return next;
});
-
return;
}
diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts
index c3777a6d4..cb651f95e 100644
--- a/packages/app/src/hooks/use-sidebar-agents-list.ts
+++ b/packages/app/src/hooks/use-sidebar-agents-list.ts
@@ -1,10 +1,8 @@
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
getHostRuntimeStore,
- isHostRuntimeDirectoryLoading,
- useHostRuntimeSession,
} from "@/runtime/host-runtime";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
@@ -124,6 +122,7 @@ function toAggregatedAgent(params: {
source: Agent;
serverId: string;
serverLabel: string;
+ lastActivityAt: Date;
}): AggregatedAgent & { createdAt: Date } {
const source = params.source;
return {
@@ -133,7 +132,7 @@ function toAggregatedAgent(params: {
title: source.title ?? null,
status: source.status,
createdAt: source.createdAt,
- lastActivityAt: source.lastActivityAt,
+ lastActivityAt: params.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
requiresAttention: source.requiresAttention,
@@ -149,14 +148,28 @@ export function useSidebarAgentsList(options?: {
serverId?: string | null;
selectedProjectFilterKeys?: string[];
}): SidebarAgentsListResult {
+ const isSidebarOpen = options?.isOpen ?? true;
const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore();
+ const daemonLabelSignature = useMemo(
+ () =>
+ daemons
+ .map((daemon) => `${daemon.serverId}:${daemon.label ?? ""}`)
+ .join("|"),
+ [daemons]
+ );
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}, [options?.serverId]);
+ const serverLabel = useMemo(() => {
+ if (!serverId) {
+ return "";
+ }
+ return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
+ }, [daemonLabelSignature, serverId]);
const selectedProjectFilterKeys = useMemo(
() =>
@@ -168,13 +181,40 @@ export function useSidebarAgentsList(options?: {
[options?.selectedProjectFilterKeys]
);
+ const isActive = Boolean(serverId && isSidebarOpen);
const liveAgents = useSessionStore((state) =>
- serverId ? state.sessions[serverId]?.agents ?? null : null
+ isActive && serverId ? state.sessions[serverId]?.agents ?? null : null
);
- const { snapshot } = useHostRuntimeSession(serverId ?? "");
+ const agentLastActivity = useSessionStore((state) =>
+ isActive ? state.agentLastActivity : null
+ );
+ const runtimeStatusSignature = useSyncExternalStore(
+ (onStoreChange) =>
+ isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
+ () => {
+ if (!isActive || !serverId) {
+ return "idle:idle";
+ }
+ const snapshot = runtime.getSnapshot(serverId);
+ const connectionStatus = snapshot?.connectionStatus ?? "idle";
+ const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle";
+ return `${connectionStatus}:${directoryStatus}`;
+ },
+ () => {
+ if (!isActive || !serverId) {
+ return "idle:idle";
+ }
+ const snapshot = runtime.getSnapshot(serverId);
+ const connectionStatus = snapshot?.connectionStatus ?? "idle";
+ const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle";
+ return `${connectionStatus}:${directoryStatus}`;
+ }
+ );
+ const [connectionStatus = "idle", directoryStatus = "idle"] =
+ runtimeStatusSignature.split(":", 2);
const { entries, projectFilterOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
- if (!serverId || !liveAgents) {
+ if (!isActive || !serverId || !liveAgents) {
return {
entries: [] as SidebarAgentListEntry[],
projectFilterOptions: [] as SidebarProjectFilterOption[],
@@ -183,8 +223,6 @@ export function useSidebarAgentsList(options?: {
};
}
- const serverLabel =
- daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const seenAgentIds = new Set();
const byProject = new Map();
const mergedEntries: SidebarAgentListEntry[] = [];
@@ -232,10 +270,13 @@ export function useSidebarAgentsList(options?: {
projectPlacement: live.projectPlacement ?? null,
cwd: live.cwd,
});
+ const effectiveLastActivity =
+ agentLastActivity?.get(live.id) ?? live.lastActivityAt;
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
+ lastActivityAt: effectiveLastActivity,
});
pushEntry({ agent, project });
}
@@ -255,22 +296,26 @@ export function useSidebarAgentsList(options?: {
return left.projectName.localeCompare(right.projectName);
});
- return {
+ const result = {
entries: ordered.entries,
projectFilterOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
- }, [daemons, liveAgents, selectedProjectFilterKeys, serverId]);
+ return result;
+ }, [agentLastActivity, isActive, liveAgents, selectedProjectFilterKeys, serverId, serverLabel]);
const refreshAll = useCallback(() => {
- if (!serverId || snapshot?.connectionStatus !== "online") {
+ if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
- }, [runtime, serverId, snapshot?.connectionStatus]);
+ }, [connectionStatus, isActive, runtime, serverId]);
- const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
+ const isDirectoryLoading =
+ isActive &&
+ Boolean(serverId) &&
+ (directoryStatus === "initial_loading" || directoryStatus === "revalidating");
const isInitialLoad = isDirectoryLoading && !hasAnyData;
const isRevalidating = isDirectoryLoading && hasAnyData;
diff --git a/packages/app/src/runtime/activity/index.ts b/packages/app/src/runtime/activity/index.ts
new file mode 100644
index 000000000..f740e744d
--- /dev/null
+++ b/packages/app/src/runtime/activity/index.ts
@@ -0,0 +1,9 @@
+export type {
+ ActivityFlushHandle,
+ AgentLastActivityCoalescer,
+ AgentLastActivityCommitter,
+ AgentLastActivityUpdates,
+} from "./types";
+
+export { scheduleAgentLastActivityFlush } from "./last-activity-scheduler";
+export { createAgentLastActivityCoalescer } from "./last-activity-coalescer";
diff --git a/packages/app/src/runtime/activity/last-activity-coalescer.ts b/packages/app/src/runtime/activity/last-activity-coalescer.ts
new file mode 100644
index 000000000..fc7e30a73
--- /dev/null
+++ b/packages/app/src/runtime/activity/last-activity-coalescer.ts
@@ -0,0 +1,67 @@
+import { scheduleAgentLastActivityFlush } from "./last-activity-scheduler";
+import type {
+ ActivityFlushHandle,
+ AgentLastActivityCoalescer,
+ AgentLastActivityCommitter,
+} from "./types";
+
+export function createAgentLastActivityCoalescer(): AgentLastActivityCoalescer {
+ let pending = new Map();
+ let scheduledFlush: ActivityFlushHandle | null = null;
+ let committer: AgentLastActivityCommitter | null = null;
+
+ const cancelScheduledFlush = () => {
+ if (!scheduledFlush) {
+ return;
+ }
+ scheduledFlush.cancel();
+ scheduledFlush = null;
+ };
+
+ const flushNow = () => {
+ cancelScheduledFlush();
+ if (pending.size === 0) {
+ return;
+ }
+ const updates = pending;
+ pending = new Map();
+ committer?.(updates);
+ };
+
+ const scheduleFlush = () => {
+ if (scheduledFlush) {
+ return;
+ }
+ scheduledFlush = scheduleAgentLastActivityFlush(() => {
+ scheduledFlush = null;
+ flushNow();
+ });
+ };
+
+ return {
+ setCommitter(nextCommitter) {
+ committer = nextCommitter;
+ },
+
+ enqueue(agentId, timestamp) {
+ const current = pending.get(agentId);
+ if (current && current.getTime() >= timestamp.getTime()) {
+ return;
+ }
+ pending.set(agentId, timestamp);
+ scheduleFlush();
+ },
+
+ flushNow,
+
+ deletePending(agentId) {
+ pending.delete(agentId);
+ },
+
+ dispose() {
+ cancelScheduledFlush();
+ pending.clear();
+ committer = null;
+ },
+ };
+}
diff --git a/packages/app/src/runtime/activity/last-activity-scheduler.ts b/packages/app/src/runtime/activity/last-activity-scheduler.ts
new file mode 100644
index 000000000..a1110e85c
--- /dev/null
+++ b/packages/app/src/runtime/activity/last-activity-scheduler.ts
@@ -0,0 +1,26 @@
+import type { ActivityFlushHandle } from "./types";
+
+const DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS = 16;
+
+export function scheduleAgentLastActivityFlush(
+ callback: () => void,
+ fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS
+): ActivityFlushHandle {
+ if (typeof requestAnimationFrame === "function") {
+ const handle = requestAnimationFrame(callback);
+ return {
+ cancel: () => {
+ if (typeof cancelAnimationFrame === "function") {
+ cancelAnimationFrame(handle);
+ }
+ },
+ };
+ }
+
+ const handle = setTimeout(callback, fallbackIntervalMs);
+ return {
+ cancel: () => {
+ clearTimeout(handle);
+ },
+ };
+}
diff --git a/packages/app/src/runtime/activity/types.ts b/packages/app/src/runtime/activity/types.ts
new file mode 100644
index 000000000..59371add3
--- /dev/null
+++ b/packages/app/src/runtime/activity/types.ts
@@ -0,0 +1,17 @@
+export type AgentLastActivityUpdates = Map;
+
+export type AgentLastActivityCommitter = (
+ updates: AgentLastActivityUpdates
+) => void;
+
+export interface ActivityFlushHandle {
+ cancel: () => void;
+}
+
+export interface AgentLastActivityCoalescer {
+ setCommitter: (committer: AgentLastActivityCommitter | null) => void;
+ enqueue: (agentId: string, timestamp: Date) => void;
+ flushNow: () => void;
+ deletePending: (agentId: string) => void;
+ dispose: () => void;
+}
diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts
index 0b84fc904..6fc05684e 100644
--- a/packages/app/src/runtime/host-runtime.ts
+++ b/packages/app/src/runtime/host-runtime.ts
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
import {
DaemonClient,
type ConnectionState,
+ type DaemonClientDiagnosticsEvent,
type FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
@@ -19,6 +20,10 @@ import {
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
+import {
+ recordDaemonClientDiagnostics,
+ recordHostRuntimeCreateClient,
+} from "@/runtime/perf-diagnostics/host-runtime-diagnostics";
export type HostRuntimeConnectionStatus =
| "idle"
@@ -375,12 +380,22 @@ function selectInitialConnectionId(host: HostProfile): string | null {
function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
+ recordHostRuntimeCreateClient({
+ serverId: host.serverId,
+ connectionType: connection.type,
+ endpoint:
+ connection.type === "direct"
+ ? connection.endpoint
+ : connection.relayEndpoint,
+ });
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
suppressSendErrors: true,
clientId,
clientType: "mobile" as const,
runtimeGeneration,
+ onDiagnosticsEvent: (event: DaemonClientDiagnosticsEvent) =>
+ recordDaemonClientDiagnostics(host.serverId, event),
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
@@ -1099,10 +1114,10 @@ export class HostRuntimeStore {
filter: input.filter ?? { labels: { ui: "true" } },
...(input.subscribe ? { subscribe: input.subscribe } : {}),
});
- const { agents } = applyFetchedAgentDirectory({
+ const agents = applyFetchedAgentDirectory({
serverId: input.serverId,
entries: payload.entries,
- });
+ }).agents;
controller.markAgentDirectorySyncReady();
return {
agents,
diff --git a/packages/app/src/runtime/perf-diagnostics/debug-tools.ts b/packages/app/src/runtime/perf-diagnostics/debug-tools.ts
new file mode 100644
index 000000000..f4818e80b
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/debug-tools.ts
@@ -0,0 +1,38 @@
+import {
+ consumePersistedPerfDiagnosticReports,
+ getPerfDiagnosticsSnapshot,
+ isPerfDiagnosticsEnabled,
+ peekPersistedPerfDiagnosticReports,
+} from "./engine";
+
+export function installPerfDiagnosticsDebugTools(): () => void {
+ if (!isPerfDiagnosticsEnabled()) {
+ return () => {};
+ }
+
+ (
+ globalThis as {
+ __PASEO_PERF_DIAGNOSTICS_DEBUG__?: {
+ snapshot: (limit?: number) => ReturnType;
+ consumeReports: () => Promise<
+ Awaited>
+ >;
+ peekReports: () => Promise<
+ Awaited>
+ >;
+ };
+ }
+ ).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = {
+ snapshot: (limit?: number) => getPerfDiagnosticsSnapshot(limit ?? 120),
+ consumeReports: () => consumePersistedPerfDiagnosticReports(),
+ peekReports: () => peekPersistedPerfDiagnosticReports(),
+ };
+
+ return () => {
+ (
+ globalThis as {
+ __PASEO_PERF_DIAGNOSTICS_DEBUG__?: unknown;
+ }
+ ).__PASEO_PERF_DIAGNOSTICS_DEBUG__ = undefined;
+ };
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/engine.ts b/packages/app/src/runtime/perf-diagnostics/engine.ts
new file mode 100644
index 000000000..796b4cf2a
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/engine.ts
@@ -0,0 +1,311 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { Platform } from "react-native";
+import { getNowMs } from "@/utils/perf";
+import type {
+ PerfDiagnosticBreadcrumb,
+ PerfDiagnosticFields,
+ PerfDiagnosticsReport,
+ PerfDiagnosticsSnapshot,
+ RecordPerfDiagnosticMarkOptions,
+} from "./types";
+
+const STORAGE_KEY = "paseo:perf-diagnostics:v1";
+const LEGACY_STORAGE_KEY = "paseo:js-hang-diagnostics:v1";
+const MONITOR_INTERVAL_MS = 100;
+const STALL_THRESHOLD_MS = 250;
+const SAMPLE_EVERY_N = 40;
+const MAX_BREADCRUMBS = 600;
+const BREADCRUMBS_PER_REPORT = 220;
+const MAX_STORED_REPORTS = 20;
+
+const state = {
+ breadcrumbs: [] as PerfDiagnosticBreadcrumb[],
+ sampleCursor: 0,
+ monitorHandle: null as ReturnType | null,
+ monitorLastTickMs: 0,
+ monitorScope: null as string | null,
+ monitorRefCount: 0,
+ loadLogged: false,
+ persistQueue: Promise.resolve(),
+};
+
+function isPrimitive(value: unknown): value is string | number | boolean | null {
+ return (
+ value === null ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ );
+}
+
+function sanitizeFieldValue(value: unknown): unknown {
+ if (isPrimitive(value)) {
+ if (typeof value === "string" && value.length > 180) {
+ return `${value.slice(0, 180)}...`;
+ }
+ return value;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ if (Array.isArray(value)) {
+ return `[array:${value.length}]`;
+ }
+ if (value instanceof Map) {
+ return `[map:${value.size}]`;
+ }
+ if (value instanceof Set) {
+ return `[set:${value.size}]`;
+ }
+ if (typeof value === "bigint") {
+ return value.toString();
+ }
+ if (value === undefined) {
+ return "[undefined]";
+ }
+ return "[object]";
+}
+
+function sanitizeFields(
+ fields?: PerfDiagnosticFields
+): PerfDiagnosticFields | undefined {
+ if (!fields) {
+ return undefined;
+ }
+ const next: PerfDiagnosticFields = {};
+ let count = 0;
+ for (const [key, value] of Object.entries(fields)) {
+ if (count >= 16) {
+ next.__truncated__ = true;
+ break;
+ }
+ next[key] = sanitizeFieldValue(value);
+ count += 1;
+ }
+ return next;
+}
+
+function shouldEnableDiagnostics(): boolean {
+ const globalFlag = (
+ globalThis as {
+ __PASEO_PERF_DIAGNOSTICS__?: unknown;
+ __PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
+ }
+ ).__PASEO_PERF_DIAGNOSTICS__;
+ const legacyFlag = (
+ globalThis as {
+ __PASEO_PERF_DIAGNOSTICS__?: unknown;
+ __PASEO_JS_HANG_DIAGNOSTICS__?: unknown;
+ }
+ ).__PASEO_JS_HANG_DIAGNOSTICS__;
+ if (typeof globalFlag === "boolean") {
+ return globalFlag;
+ }
+ if (typeof legacyFlag === "boolean") {
+ return legacyFlag;
+ }
+ const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
+ return Platform.OS === "android" || isDev;
+}
+
+function shouldSample(): boolean {
+ state.sampleCursor += 1;
+ if (state.sampleCursor >= SAMPLE_EVERY_N) {
+ state.sampleCursor = 0;
+ return true;
+ }
+ return false;
+}
+
+function pushBreadcrumb(item: PerfDiagnosticBreadcrumb): void {
+ state.breadcrumbs.push(item);
+ if (state.breadcrumbs.length > MAX_BREADCRUMBS) {
+ state.breadcrumbs.splice(0, state.breadcrumbs.length - MAX_BREADCRUMBS);
+ }
+}
+
+function safeParseReports(raw: string | null): PerfDiagnosticsReport[] {
+ if (!raw) {
+ return [];
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+ return parsed.filter(
+ (entry) => entry && typeof entry === "object"
+ ) as PerfDiagnosticsReport[];
+ } catch {
+ return [];
+ }
+}
+
+async function readPersistedReports(): Promise {
+ const stored = safeParseReports(await AsyncStorage.getItem(STORAGE_KEY));
+ if (stored.length > 0) {
+ return stored;
+ }
+ const legacy = safeParseReports(await AsyncStorage.getItem(LEGACY_STORAGE_KEY));
+ if (legacy.length > 0) {
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
+ await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
+ }
+ return legacy;
+}
+
+async function persistReport(report: PerfDiagnosticsReport): Promise {
+ state.persistQueue = state.persistQueue
+ .then(async () => {
+ const existing = await readPersistedReports();
+ existing.push(report);
+ if (existing.length > MAX_STORED_REPORTS) {
+ existing.splice(0, existing.length - MAX_STORED_REPORTS);
+ }
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
+ })
+ .catch((error) => {
+ console.warn("[PerfDiagnostics] Failed to persist report", {
+ message: error instanceof Error ? error.message : String(error),
+ });
+ });
+ await state.persistQueue;
+}
+
+function buildReport(
+ scope: string,
+ atMs: number,
+ lagMs: number
+): PerfDiagnosticsReport {
+ const id = `${Math.round(atMs)}-${Math.floor(Math.random() * 1_000_000)}`;
+ const breadcrumbs = state.breadcrumbs.slice(-BREADCRUMBS_PER_REPORT);
+ const wallTimeMs = Date.now();
+ return {
+ id,
+ scope,
+ atMs,
+ wallTimeMs,
+ wallTimeIso: new Date(wallTimeMs).toISOString(),
+ lagMs,
+ platform: Platform.OS,
+ breadcrumbs,
+ };
+}
+
+export function recordPerfDiagnosticMark(
+ name: string,
+ fields?: PerfDiagnosticFields,
+ options?: RecordPerfDiagnosticMarkOptions
+): void {
+ if (!shouldEnableDiagnostics()) {
+ return;
+ }
+ const force = options?.force === true;
+ if (!force && !shouldSample()) {
+ return;
+ }
+ pushBreadcrumb({
+ atMs: getNowMs(),
+ kind: "mark",
+ name,
+ fields: sanitizeFields(fields),
+ });
+}
+
+export function installPerfDiagnosticsMonitor(scope: string): () => void {
+ if (!shouldEnableDiagnostics()) {
+ return () => {};
+ }
+
+ if (state.monitorRefCount === 0) {
+ state.monitorScope = scope;
+ state.monitorLastTickMs = getNowMs();
+ state.monitorHandle = setInterval(() => {
+ const nowMs = getNowMs();
+ const lagMs = nowMs - state.monitorLastTickMs - MONITOR_INTERVAL_MS;
+ if (lagMs >= STALL_THRESHOLD_MS) {
+ const report = buildReport(state.monitorScope ?? scope, nowMs, lagMs);
+ recordPerfDiagnosticMark(
+ "perf.stall_detected",
+ {
+ scope: report.scope,
+ lagMs: Math.round(lagMs),
+ reportId: report.id,
+ breadcrumbs: report.breadcrumbs.length,
+ },
+ { force: true }
+ );
+ console.warn("[PerfDiagnostics] JS stall detected", {
+ scope: report.scope,
+ lagMs: Math.round(lagMs),
+ reportId: report.id,
+ breadcrumbs: report.breadcrumbs.length,
+ });
+ void persistReport(report);
+ }
+ state.monitorLastTickMs = nowMs;
+ }, MONITOR_INTERVAL_MS);
+ }
+
+ state.monitorRefCount += 1;
+
+ if (!state.loadLogged) {
+ state.loadLogged = true;
+ void readPersistedReports()
+ .then((reports) => {
+ if (reports.length > 0) {
+ console.warn("[PerfDiagnostics] Recovered persisted stall reports", {
+ count: reports.length,
+ latestReportId: reports[reports.length - 1]?.id ?? null,
+ });
+ }
+ })
+ .catch(() => undefined);
+ }
+
+ return () => {
+ state.monitorRefCount = Math.max(0, state.monitorRefCount - 1);
+ if (state.monitorRefCount === 0 && state.monitorHandle) {
+ clearInterval(state.monitorHandle);
+ state.monitorHandle = null;
+ state.monitorScope = null;
+ }
+ };
+}
+
+export async function consumePersistedPerfDiagnosticReports(): Promise<
+ PerfDiagnosticsReport[]
+> {
+ const reports = await readPersistedReports();
+ await AsyncStorage.removeItem(STORAGE_KEY);
+ await AsyncStorage.removeItem(LEGACY_STORAGE_KEY);
+ return reports;
+}
+
+export async function peekPersistedPerfDiagnosticReports(): Promise<
+ PerfDiagnosticsReport[]
+> {
+ return readPersistedReports();
+}
+
+export function isPerfDiagnosticsEnabled(): boolean {
+ return shouldEnableDiagnostics();
+}
+
+export function getPerfDiagnosticBreadcrumbs(
+ limit = 120
+): PerfDiagnosticBreadcrumb[] {
+ if (limit <= 0) {
+ return [];
+ }
+ return state.breadcrumbs.slice(-Math.floor(limit));
+}
+
+export function getPerfDiagnosticsSnapshot(limit = 120): PerfDiagnosticsSnapshot {
+ return {
+ enabled: shouldEnableDiagnostics(),
+ monitorScope: state.monitorScope,
+ breadcrumbCount: state.breadcrumbs.length,
+ breadcrumbs: getPerfDiagnosticBreadcrumbs(limit),
+ };
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts
new file mode 100644
index 000000000..1d445defb
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/host-runtime-diagnostics.ts
@@ -0,0 +1,121 @@
+import type { DaemonClientDiagnosticsEvent } from "@server/client/daemon-client";
+import { recordPerfDiagnosticMark } from "./engine";
+
+let fastTransportSampleCursor = 0;
+const transportRateByServer = new Map<
+ string,
+ {
+ startedAtMs: number;
+ messageCount: number;
+ bytes: number;
+ errorCount: number;
+ slowCount: number;
+ }
+>();
+
+function shouldSampleFastTransportEvent(): boolean {
+ fastTransportSampleCursor += 1;
+ if (fastTransportSampleCursor >= 50) {
+ fastTransportSampleCursor = 0;
+ return true;
+ }
+ return false;
+}
+
+export function recordHostRuntimeCreateClient(params: {
+ serverId: string;
+ connectionType: "direct" | "relay";
+ endpoint: string;
+}): void {
+ recordPerfDiagnosticMark(
+ "host_runtime.create_client",
+ {
+ serverId: params.serverId,
+ connectionType: params.connectionType,
+ endpoint: params.endpoint,
+ },
+ { force: true }
+ );
+}
+
+export function recordDaemonClientDiagnostics(
+ serverId: string,
+ event: DaemonClientDiagnosticsEvent
+): void {
+ if (event.type === "transport_message_timing") {
+ const nowWallTimeMs = Date.now();
+ const current =
+ transportRateByServer.get(serverId) ?? {
+ startedAtMs: nowWallTimeMs,
+ messageCount: 0,
+ bytes: 0,
+ errorCount: 0,
+ slowCount: 0,
+ };
+ current.messageCount += 1;
+ current.bytes += event.payloadBytes;
+ if (event.outcome !== "ok") {
+ current.errorCount += 1;
+ }
+ if (event.totalMs >= 8) {
+ current.slowCount += 1;
+ }
+ if (nowWallTimeMs - current.startedAtMs >= 1000) {
+ recordPerfDiagnosticMark(
+ "daemon_client.message_rate",
+ {
+ serverId,
+ windowMs: nowWallTimeMs - current.startedAtMs,
+ messageCount: current.messageCount,
+ bytes: current.bytes,
+ errorCount: current.errorCount,
+ slowCount: current.slowCount,
+ },
+ { force: current.errorCount > 0 || current.slowCount > 0 }
+ );
+ current.startedAtMs = nowWallTimeMs;
+ current.messageCount = 0;
+ current.bytes = 0;
+ current.errorCount = 0;
+ current.slowCount = 0;
+ }
+ transportRateByServer.set(serverId, current);
+ }
+
+ if (event.type === "transport_binary_frame") {
+ if (event.payloadBytes < 16_384) {
+ return;
+ }
+ recordPerfDiagnosticMark(
+ "daemon_client.binary_frame",
+ {
+ serverId,
+ channel: event.channel,
+ messageType: event.messageType,
+ payloadBytes: event.payloadBytes,
+ },
+ { force: true }
+ );
+ return;
+ }
+
+ const isSlow =
+ event.totalMs >= 8 || event.parseMs >= 4 || event.validateMs >= 4;
+ const isError = event.outcome !== "ok";
+ if (!isSlow && !isError && !shouldSampleFastTransportEvent()) {
+ return;
+ }
+ recordPerfDiagnosticMark(
+ "daemon_client.transport_message",
+ {
+ serverId,
+ messageType: event.messageType,
+ outcome: event.outcome,
+ payloadBytes: event.payloadBytes,
+ parseMs: Math.round(event.parseMs * 100) / 100,
+ validateMs: Math.round(event.validateMs * 100) / 100,
+ totalMs: Math.round(event.totalMs * 100) / 100,
+ },
+ { force: isSlow || isError }
+ );
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/index.ts b/packages/app/src/runtime/perf-diagnostics/index.ts
new file mode 100644
index 000000000..cbb7f0f26
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/index.ts
@@ -0,0 +1,22 @@
+export type {
+ PerfDiagnosticBreadcrumb,
+ PerfDiagnosticFields,
+ PerfDiagnosticsReport,
+ PerfDiagnosticsSnapshot,
+ PerfDiagnosticsReporter,
+ RecordPerfDiagnosticMarkOptions,
+} from "./types";
+
+export {
+ consumePersistedPerfDiagnosticReports,
+ getPerfDiagnosticBreadcrumbs,
+ getPerfDiagnosticsSnapshot,
+ installPerfDiagnosticsMonitor,
+ isPerfDiagnosticsEnabled,
+ peekPersistedPerfDiagnosticReports,
+ recordPerfDiagnosticMark,
+} from "./engine";
+
+export { getPerfDiagnosticsReporter } from "./reporter";
+export { PerfDiagnosticsProvider } from "./perf-diagnostics-provider";
+export { usePerfDiagnostics } from "./use-perf-diagnostics";
diff --git a/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts
new file mode 100644
index 000000000..703c612c2
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-context.ts
@@ -0,0 +1,21 @@
+import { createContext } from "react";
+import type { PerfDiagnosticsReporter, PerfDiagnosticsSnapshot } from "./types";
+
+const EMPTY_SNAPSHOT: PerfDiagnosticsSnapshot = {
+ enabled: false,
+ monitorScope: null,
+ breadcrumbCount: 0,
+ breadcrumbs: [],
+};
+
+const NOOP_REPORTER: PerfDiagnosticsReporter = {
+ mark: () => {},
+ installMonitor: () => () => {},
+ consumeReports: async () => [],
+ peekReports: async () => [],
+ isEnabled: () => false,
+ getSnapshot: () => EMPTY_SNAPSHOT,
+};
+
+export const PerfDiagnosticsContext =
+ createContext(NOOP_REPORTER);
diff --git a/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx
new file mode 100644
index 000000000..907950ee7
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/perf-diagnostics-provider.tsx
@@ -0,0 +1,53 @@
+import { useEffect, useMemo, type ReactNode } from "react";
+import { installPerfDiagnosticsDebugTools } from "./debug-tools";
+import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
+import { getPerfDiagnosticsReporter } from "./reporter";
+
+interface PerfDiagnosticsProviderProps {
+ children: ReactNode;
+ scope?: string;
+}
+
+export function PerfDiagnosticsProvider({
+ children,
+ scope = "root_layout",
+}: PerfDiagnosticsProviderProps) {
+ const reporter = useMemo(() => getPerfDiagnosticsReporter(), []);
+
+ useEffect(() => {
+ if (!reporter.isEnabled()) {
+ return;
+ }
+
+ const removeDebugTools = installPerfDiagnosticsDebugTools();
+ const stopMonitor = reporter.installMonitor(scope);
+
+ void reporter
+ .peekReports()
+ .then((reports) => {
+ if (reports.length === 0) {
+ return;
+ }
+ const latest = reports[reports.length - 1];
+ console.warn("[PerfDiagnostics] Loaded persisted stall reports", {
+ count: reports.length,
+ latestReportId: latest?.id ?? null,
+ latestWallTime: latest?.wallTimeIso ?? null,
+ latestLagMs: latest ? Math.round(latest.lagMs) : null,
+ latestBreadcrumbs: latest?.breadcrumbs.length ?? null,
+ });
+ })
+ .catch(() => undefined);
+
+ return () => {
+ removeDebugTools();
+ stopMonitor();
+ };
+ }, [scope, reporter]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/reporter.ts b/packages/app/src/runtime/perf-diagnostics/reporter.ts
new file mode 100644
index 000000000..490b339b5
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/reporter.ts
@@ -0,0 +1,22 @@
+import {
+ consumePersistedPerfDiagnosticReports,
+ getPerfDiagnosticsSnapshot,
+ installPerfDiagnosticsMonitor,
+ isPerfDiagnosticsEnabled,
+ peekPersistedPerfDiagnosticReports,
+ recordPerfDiagnosticMark,
+} from "./engine";
+import type { PerfDiagnosticsReporter } from "./types";
+
+const reporter: PerfDiagnosticsReporter = {
+ mark: recordPerfDiagnosticMark,
+ installMonitor: installPerfDiagnosticsMonitor,
+ consumeReports: consumePersistedPerfDiagnosticReports,
+ peekReports: peekPersistedPerfDiagnosticReports,
+ isEnabled: isPerfDiagnosticsEnabled,
+ getSnapshot: getPerfDiagnosticsSnapshot,
+};
+
+export function getPerfDiagnosticsReporter(): PerfDiagnosticsReporter {
+ return reporter;
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/types.ts b/packages/app/src/runtime/perf-diagnostics/types.ts
new file mode 100644
index 000000000..ab8245244
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/types.ts
@@ -0,0 +1,44 @@
+export type PerfDiagnosticFields = Record;
+
+export interface PerfDiagnosticBreadcrumb {
+ atMs: number;
+ kind: "mark" | "span";
+ name: string;
+ durationMs?: number;
+ fields?: PerfDiagnosticFields;
+}
+
+export interface PerfDiagnosticsReport {
+ id: string;
+ scope: string;
+ atMs: number;
+ wallTimeMs: number;
+ wallTimeIso: string;
+ lagMs: number;
+ platform: string;
+ breadcrumbs: PerfDiagnosticBreadcrumb[];
+}
+
+export interface RecordPerfDiagnosticMarkOptions {
+ force?: boolean;
+}
+
+export interface PerfDiagnosticsSnapshot {
+ enabled: boolean;
+ monitorScope: string | null;
+ breadcrumbCount: number;
+ breadcrumbs: PerfDiagnosticBreadcrumb[];
+}
+
+export interface PerfDiagnosticsReporter {
+ mark: (
+ name: string,
+ fields?: PerfDiagnosticFields,
+ options?: RecordPerfDiagnosticMarkOptions
+ ) => void;
+ installMonitor: (scope: string) => () => void;
+ consumeReports: () => Promise;
+ peekReports: () => Promise;
+ isEnabled: () => boolean;
+ getSnapshot: (limit?: number) => PerfDiagnosticsSnapshot;
+}
diff --git a/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts b/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts
new file mode 100644
index 000000000..65db3e1c1
--- /dev/null
+++ b/packages/app/src/runtime/perf-diagnostics/use-perf-diagnostics.ts
@@ -0,0 +1,7 @@
+import { useContext } from "react";
+import { PerfDiagnosticsContext } from "./perf-diagnostics-context";
+import type { PerfDiagnosticsReporter } from "./types";
+
+export function usePerfDiagnostics(): PerfDiagnosticsReporter {
+ return useContext(PerfDiagnosticsContext);
+}
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index 990d89ef1..f02951ea0 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -24,6 +24,10 @@ import type {
ServerCapabilities,
} from "@server/shared/messages";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
+import {
+ createAgentLastActivityCoalescer,
+ type AgentLastActivityCommitter,
+} from "@/runtime/activity";
// Re-export types that were in session-context
export type MessageEntry =
@@ -257,6 +261,10 @@ interface SessionStoreActions {
// Agent activity timestamps
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
+ setAgentLastActivityBatch: (
+ updates: Map | ((prev: Map) => Map)
+ ) => void;
+ flushAgentLastActivity: () => void;
// Permissions
setPendingPermissions: (serverId: string, perms: Map | ((prev: Map) => Map)) => void;
@@ -278,6 +286,7 @@ type SessionStore = SessionStoreState & SessionStoreActions;
const SESSION_STORE_LOG_TAG = "[SessionStore]";
let sessionStoreUpdateCount = 0;
+const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
function logSessionStoreUpdate(
type: string,
@@ -332,9 +341,34 @@ function areServerCapabilitiesEqual(
}
export const useSessionStore = create()(
- subscribeWithSelector((set, get) => ({
- sessions: {},
- agentLastActivity: new Map(),
+ subscribeWithSelector((set, get) => {
+ const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
+ set((prev) => {
+ let nextActivity: Map | null = null;
+ for (const [agentId, timestamp] of updates.entries()) {
+ const current = prev.agentLastActivity.get(agentId);
+ if (current && current.getTime() >= timestamp.getTime()) {
+ continue;
+ }
+ if (!nextActivity) {
+ nextActivity = new Map(prev.agentLastActivity);
+ }
+ nextActivity.set(agentId, timestamp);
+ }
+ if (!nextActivity) {
+ return prev;
+ }
+ return {
+ ...prev,
+ agentLastActivity: nextActivity,
+ };
+ });
+ };
+ agentLastActivityCoalescer.setCommitter(commitActivityUpdates);
+
+ return {
+ sessions: {},
+ agentLastActivity: new Map(),
// Session management
initializeSession: (serverId, client, audioPlayer) => {
@@ -354,14 +388,34 @@ export const useSessionStore = create()(
},
clearSession: (serverId) => {
+ agentLastActivityCoalescer.flushNow();
set((prev) => {
- if (!(serverId in prev.sessions)) {
+ const session = prev.sessions[serverId];
+ if (!session) {
return prev;
}
logSessionStoreUpdate("clearSession", serverId);
const nextSessions = { ...prev.sessions };
delete nextSessions[serverId];
- return { ...prev, sessions: nextSessions };
+ let nextActivity = prev.agentLastActivity;
+ if (session.agents.size > 0) {
+ const candidate = new Map(prev.agentLastActivity);
+ let changed = false;
+ for (const agentId of session.agents.keys()) {
+ if (candidate.delete(agentId)) {
+ changed = true;
+ }
+ agentLastActivityCoalescer.deletePending(agentId);
+ }
+ if (changed) {
+ nextActivity = candidate;
+ }
+ }
+ return {
+ ...prev,
+ sessions: nextSessions,
+ agentLastActivity: nextActivity,
+ };
});
},
@@ -714,20 +768,49 @@ export const useSessionStore = create()(
// Agent activity timestamps (top-level, does NOT mutate session object)
setAgentLastActivity: (agentId, timestamp) => {
+ agentLastActivityCoalescer.enqueue(agentId, timestamp);
+ },
+
+ setAgentLastActivityBatch: (updates) => {
set((prev) => {
- const currentTimestamp = prev.agentLastActivity.get(agentId);
- if (currentTimestamp && currentTimestamp.getTime() === timestamp.getTime()) {
+ const nextActivity =
+ typeof updates === "function"
+ ? updates(prev.agentLastActivity)
+ : updates;
+ if (nextActivity === prev.agentLastActivity) {
+ return prev;
+ }
+ if (nextActivity.size === 0) {
+ if (prev.agentLastActivity.size === 0) {
+ return prev;
+ }
+ return {
+ ...prev,
+ agentLastActivity: new Map(),
+ };
+ }
+ let changed = false;
+ for (const [agentId, timestamp] of nextActivity.entries()) {
+ const currentTimestamp = prev.agentLastActivity.get(agentId);
+ if (!currentTimestamp || currentTimestamp.getTime() !== timestamp.getTime()) {
+ changed = true;
+ break;
+ }
+ }
+ if (!changed && nextActivity.size === prev.agentLastActivity.size) {
return prev;
}
- const nextActivity = new Map(prev.agentLastActivity);
- nextActivity.set(agentId, timestamp);
return {
...prev,
- agentLastActivity: nextActivity,
+ agentLastActivity: new Map(nextActivity),
};
});
},
+ flushAgentLastActivity: () => {
+ agentLastActivityCoalescer.flushNow();
+ },
+
// Permissions
setPendingPermissions: (serverId, perms) => {
set((prev) => {
@@ -840,5 +923,6 @@ export const useSessionStore = create()(
}
return entries;
},
- }))
+ };
+ })
);
diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts
index 414e653e5..c58dd8345 100644
--- a/packages/app/src/utils/agent-directory-sync.ts
+++ b/packages/app/src/utils/agent-directory-sync.ts
@@ -48,11 +48,17 @@ export function applyFetchedAgentDirectory(input: {
entries: FetchAgentsEntry[];
}): { agents: Map } {
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
+
const store = useSessionStore.getState();
+
store.setAgents(input.serverId, agents);
+
+ const lastActivityByAgentId = new Map();
for (const agent of agents.values()) {
- store.setAgentLastActivity(agent.id, agent.lastActivityAt);
+ lastActivityByAgentId.set(agent.id, agent.lastActivityAt);
}
+ store.setAgentLastActivityBatch(lastActivityByAgentId);
+
store.setPendingPermissions(input.serverId, pendingPermissions);
store.setInitializingAgents(input.serverId, new Map());
store.setHasHydratedAgents(input.serverId, true);
diff --git a/packages/app/src/utils/perf-diagnostics.ts b/packages/app/src/utils/perf-diagnostics.ts
new file mode 100644
index 000000000..adfcc02ff
--- /dev/null
+++ b/packages/app/src/utils/perf-diagnostics.ts
@@ -0,0 +1,20 @@
+// Compatibility shim while callers move to `@/runtime/perf-diagnostics`.
+// Keep this file side-effect free.
+export {
+ consumePersistedPerfDiagnosticReports,
+ getPerfDiagnosticBreadcrumbs,
+ getPerfDiagnosticsSnapshot,
+ installPerfDiagnosticsMonitor,
+ isPerfDiagnosticsEnabled,
+ peekPersistedPerfDiagnosticReports,
+ recordPerfDiagnosticMark,
+} from "@/runtime/perf-diagnostics";
+
+export type {
+ PerfDiagnosticBreadcrumb,
+ PerfDiagnosticFields,
+ PerfDiagnosticsReport,
+ PerfDiagnosticsSnapshot,
+ PerfDiagnosticsReporter,
+ RecordPerfDiagnosticMarkOptions,
+} from "@/runtime/perf-diagnostics";
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index ace6e0ecb..cb2782353 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
alias: {
"@": path.resolve(__dirname, "src"),
"@server": path.resolve(__dirname, "../server/src"),
+ "react-native": "react-native-web",
},
},
});
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index efe9b6fe5..b7a85e0f3 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -97,6 +97,13 @@ const consoleLogger: Logger = {
error: (obj, msg) => console.error(msg, obj),
}
+function getNowMs(): number {
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
+ return performance.now()
+ }
+ return Date.now()
+}
+
export type {
DaemonTransport,
DaemonTransportFactory,
@@ -163,8 +170,26 @@ export type DaemonClientConfig = {
baseDelayMs?: number
maxDelayMs?: number
}
+ onDiagnosticsEvent?: (event: DaemonClientDiagnosticsEvent) => void
}
+export type DaemonClientDiagnosticsEvent =
+ | {
+ type: 'transport_message_timing'
+ messageType: string
+ payloadBytes: number
+ parseMs: number
+ validateMs: number
+ totalMs: number
+ outcome: 'ok' | 'parse_error' | 'validation_error'
+ }
+ | {
+ type: 'transport_binary_frame'
+ channel: BinaryMuxChannel
+ messageType: number
+ payloadBytes: number
+ }
+
export type SendMessageOptions = {
messageId?: string
images?: Array<{ data: string; mimeType: string }>
@@ -432,6 +457,14 @@ export class DaemonClient {
})
}
+ private emitDiagnosticsEvent(event: DaemonClientDiagnosticsEvent): void {
+ try {
+ this.config.onDiagnosticsEvent?.(event)
+ } catch {
+ // Diagnostics hooks must never break daemon message handling.
+ }
+ }
+
// ============================================================================
// Connection
// ============================================================================
@@ -2638,12 +2671,19 @@ export class DaemonClient {
}
private handleTransportMessage(data: unknown): void {
+ const startedAtMs = getNowMs()
const rawData =
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
const rawBytes = asUint8Array(rawData)
if (rawBytes) {
const frame = decodeBinaryMuxFrame(rawBytes)
if (frame) {
+ this.emitDiagnosticsEvent({
+ type: 'transport_binary_frame',
+ channel: frame.channel,
+ messageType: frame.messageType,
+ payloadBytes: frame.payload?.byteLength ?? 0,
+ })
this.handleBinaryFrame(frame)
return
}
@@ -2654,19 +2694,52 @@ export class DaemonClient {
}
let parsedJson: unknown
+ let parseMs = 0
try {
+ const parseStartedAtMs = getNowMs()
parsedJson = JSON.parse(payload)
+ parseMs = getNowMs() - parseStartedAtMs
} catch {
+ this.emitDiagnosticsEvent({
+ type: 'transport_message_timing',
+ messageType: 'unknown',
+ payloadBytes: payload.length,
+ parseMs: 0,
+ validateMs: 0,
+ totalMs: getNowMs() - startedAtMs,
+ outcome: 'parse_error',
+ })
return
}
+ const validateStartedAtMs = getNowMs()
const parsed = WSOutboundMessageSchema.safeParse(parsedJson)
+ const validateMs = getNowMs() - validateStartedAtMs
if (!parsed.success) {
const msgType = (parsedJson as { type?: string })?.type ?? 'unknown'
+ this.emitDiagnosticsEvent({
+ type: 'transport_message_timing',
+ messageType: msgType,
+ payloadBytes: payload.length,
+ parseMs,
+ validateMs,
+ totalMs: getNowMs() - startedAtMs,
+ outcome: 'validation_error',
+ })
this.logger.warn({ msgType, error: parsed.error.message }, 'Message validation failed')
return
}
+ this.emitDiagnosticsEvent({
+ type: 'transport_message_timing',
+ messageType: parsed.data.type,
+ payloadBytes: payload.length,
+ parseMs,
+ validateMs,
+ totalMs: getNowMs() - startedAtMs,
+ outcome: 'ok',
+ })
+
if (parsed.data.type === 'pong') {
return
}