Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
736 changes: 295 additions & 441 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"react-native-worklets": "0.5.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
"zod": "^3.23.8 || ^4.0.0",
"zustand": "^5.0.9"
},
"devDependencies": {
Expand Down
36 changes: 36 additions & 0 deletions packages/app/src/components/use-bottom-anchor-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,53 @@ const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
const USER_SCROLL_AWAY_DELTA_PX = 24;

// Active rAF handles are tracked so that when the document is hidden (e.g. macOS
// display sleep) we can cancel every pending recursive chain. Otherwise Chromium
// throttles rAF while occluded and the chains pile up, draining the JS thread on
// resume.
const activeRafHandles = new Set<ScheduledFrameHandle>();
let visibilityListenerAttached = false;

function ensureRafVisibilityListener(): void {
if (visibilityListenerAttached) {
return;
}
if (typeof document === "undefined") {
return;
}
visibilityListenerAttached = true;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") {
return;
}
for (const handle of activeRafHandles) {
handle.cancelled = true;
if (handle.rafId !== null) {
cancelAnimationFrame(handle.rafId);
handle.rafId = null;
}
}
activeRafHandles.clear();
});
}

function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
delayFrames?: number;
}): ScheduledFrameHandle {
ensureRafVisibilityListener();

const handle: ScheduledFrameHandle = {
cancelled: false,
rafId: null,
remainingFrames: Math.max(0, input.delayFrames ?? 0),
callback: input.callback,
};
activeRafHandles.add(handle);

const tick = () => {
if (handle.cancelled) {
activeRafHandles.delete(handle);
return;
}
if (handle.remainingFrames > 0) {
Expand All @@ -131,6 +165,7 @@ function scheduleAnimationFrameWithDelay(input: {
return;
}
handle.rafId = null;
activeRafHandles.delete(handle);
input.callback();
};

Expand All @@ -148,6 +183,7 @@ function cancelScheduledAnimationFrame(handle: unknown): void {
cancelAnimationFrame(scheduled.rafId);
scheduled.rafId = null;
}
activeRafHandles.delete(scheduled);
}

function deriveVerificationBlockedReason(input: {
Expand Down
26 changes: 25 additions & 1 deletion packages/app/src/contexts/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type {

const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
const APP_RESUMED_COALESCE_WINDOW_MS = 250;

function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
Expand Down Expand Up @@ -498,6 +499,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const revalidationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
const revalidationQueuedRef = useRef(false);
const appResumedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const appResumedPendingAwayMsRef = useRef<number>(0);
const wasConnectedRef = useRef(isConnected);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
Expand Down Expand Up @@ -744,7 +747,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS);
}, [client, flushAuthoritativeRevalidation, isConnected]);

const handleAppResumed = useCallback(
const runAppResumedEffects = useCallback(
(awayMs: number) => {
scheduleAuthoritativeRevalidation();

Expand Down Expand Up @@ -772,6 +775,24 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
);

// Coalesce burst of resume signals: AppState change + visibilitychange + WS
// online transition can fire within milliseconds of each other on macOS unlock.
const handleAppResumed = useCallback(
(awayMs: number) => {
appResumedPendingAwayMsRef.current = Math.max(appResumedPendingAwayMsRef.current, awayMs);
if (appResumedTimerRef.current) {
return;
}
appResumedTimerRef.current = setTimeout(() => {
appResumedTimerRef.current = null;
const coalescedAwayMs = appResumedPendingAwayMsRef.current;
appResumedPendingAwayMsRef.current = 0;
runAppResumedEffects(coalescedAwayMs);
}, APP_RESUMED_COALESCE_WINDOW_MS);
},
[runAppResumedEffects],
);

// Client activity tracking (heartbeat, push token registration)
useClientActivity({ client, focusedAgentId, onAppResumed: handleAppResumed });
usePushTokenRegistration({ client, serverId });
Expand Down Expand Up @@ -1159,6 +1180,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (revalidationTimerRef.current) {
clearTimeout(revalidationTimerRef.current);
}
if (appResumedTimerRef.current) {
clearTimeout(appResumedTimerRef.current);
}
};
}, []);

Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/panels/agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,13 @@ function ChatAgentContent({
if (!isConnected || !hasSession) {
return;
}
// Defer the bump-driven catch-up to user-visible panels so a single
// resume event doesn't fan out to every mounted panel at once.
// Background panels pick up the catch-up via the focus effect when
// they later become user-visible.
if (!isPaneFocused) {
return;
}
const shouldSyncOnEntry = needsAuthoritativeSync || isNative;
if (!shouldSyncOnEntry) {
return;
Expand All @@ -857,6 +864,7 @@ function ChatAgentContent({
ensureInitializedWithSyncErrorHandling,
hasSession,
isConnected,
isPaneFocused,
needsAuthoritativeSync,
]);

Expand Down
23 changes: 22 additions & 1 deletion packages/app/src/runtime/host-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,10 @@ function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void
map.set(newKey, value);
}

// Mirrors HISTORY_STALE_AFTER_MS in session-context. Reconnects faster than this
// threshold do not invalidate per-panel timeline caches.
const HISTORY_STALE_AFTER_MS = 60_000;

export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private serverListeners = new Map<string, Set<() => void>>();
Expand All @@ -1206,6 +1210,7 @@ export class HostRuntimeStore {
private hosts: HostProfile[] = [];
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private offlineSinceByServer = new Map<string, number>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private bootStarted = false;

Expand Down Expand Up @@ -1365,6 +1370,7 @@ export class HostRuntimeStore {
controller.adoptReconciledServerId(newServerId);

rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.offlineSinceByServer, oldServerId, newServerId);
rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId);

const listeners = this.serverListeners.get(oldServerId);
Expand Down Expand Up @@ -1597,6 +1603,7 @@ export class HostRuntimeStore {
}
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.offlineSinceByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
void controller.stop();
this.emit(serverId);
Expand Down Expand Up @@ -1648,6 +1655,7 @@ export class HostRuntimeStore {
const controller = this.controllers.get(serverId);
if (!controller) {
this.lastConnectionStatusByServer.delete(serverId);
this.offlineSinceByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
return;
}
Expand All @@ -1656,8 +1664,21 @@ export class HostRuntimeStore {
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
const didTransitionOnline =
snapshot.connectionStatus === "online" && previousStatus !== "online";
const wasPreviouslyOnline = previousStatus === "online";
if (snapshot.connectionStatus !== "online" && wasPreviouslyOnline) {
this.offlineSinceByServer.set(serverId, Date.now());
}
if (didTransitionOnline) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
const offlineSince = this.offlineSinceByServer.get(serverId);
this.offlineSinceByServer.delete(serverId);
// Skip the bump on quick blips — only invalidate panel timeline caches
// when the offline window crossed the staleness threshold. The first
// online transition (no prior offline timestamp) still bumps so a fresh
// boot syncs once.
const offlineDurationMs = offlineSince === undefined ? Infinity : Date.now() - offlineSince;
if (offlineDurationMs >= HISTORY_STALE_AFTER_MS) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
}
}

// Runtime owns directory bootstrap policy, including reconnect and delayed
Expand Down
19 changes: 16 additions & 3 deletions packages/app/src/utils/web-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export function focusWithRetries({
}: FocusWithRetriesOptions): () => void {
let cancelled = false;
const deadlineMs = Date.now() + timeoutMs;
const doc = typeof document === "undefined" ? null : document;

const handleVisibilityChange = () => {
if (doc?.visibilityState === "hidden") {
cancelled = true;
}
};
doc?.addEventListener("visibilitychange", handleVisibilityChange);

const cancel = () => {
cancelled = true;
doc?.removeEventListener("visibilitychange", handleVisibilityChange);
};

const tick = () => {
if (cancelled) return;
Expand All @@ -27,11 +40,13 @@ export function focusWithRetries({

if (isFocused()) {
onSuccess?.();
cancel();
return;
}

if (Date.now() >= deadlineMs) {
onTimeout?.();
cancel();
return;
}

Expand All @@ -42,7 +57,5 @@ export function focusWithRetries({

tick();

return () => {
cancelled = true;
};
return cancel;
}
2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2",
"zod": "^3.23.8"
"zod": "^3.23.8 || ^4.0.0"
},
"devDependencies": {
"@types/node": "24.6.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
"@mariozechner/pi-coding-agent": "^0.70.2",
"@modelcontextprotocol/sdk": "^1.20.1",
"@modelcontextprotocol/sdk": "1.27.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
"@xterm/headless": "^6.0.0",
Expand All @@ -90,7 +90,7 @@
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod": "^3.23.8 || ^4.0.0",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/server/agent/create-agent-mode.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js";

const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
const CLAUDE_MODES = ["default", "acceptEdits", "auto", "plan", "bypassPermissions"];
const OPENCODE_MODES = ["build", "full-access", "plan"];
const CODEX_MODES = ["auto", "full-access"];

Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/server/agent/provider-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "auto",
label: "Auto Mode",
description:
"Classifier-driven approvals. Falls back to prompting after repeated denials. Requires Max/Team/Enterprise plan.",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan Mode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,7 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal: {
turnState: "idle" | "foreground" | "autonomous";
turnState: "idle" | "foreground" | "background";
nextTurnOrdinal: number;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
autonomousTurn: { id: string } | null;
Expand Down
Loading