From 44190a1dcaa8ade7e9610827e16ed8105fe0edc6 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 08:32:07 +0100 Subject: [PATCH 1/3] fix(sync): restore session permission mode from last message --- sources/sync/storage.ts | 58 +++++++++++++++++++++++++++++++++--- sources/sync/storageTypes.ts | 1 + 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index f1fe413f6..21bd189ef 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -317,11 +317,14 @@ export const storage = create()((set, get) => { const savedDraft = savedDrafts[session.id]; const existingPermissionMode = state.sessions[session.id]?.permissionMode; const savedPermissionMode = savedPermissionModes[session.id]; + const existingPermissionModeUpdatedAt = state.sessions[session.id]?.permissionModeUpdatedAt; mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default' + permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', + // Preserve local coordination timestamp (not synced to server) + permissionModeUpdatedAt: existingPermissionModeUpdatedAt ?? null }; }); @@ -514,13 +517,38 @@ export const storage = create()((set, get) => { const messagesArray = Object.values(mergedMessagesMap) .sort((a, b) => b.createdAt - a.createdAt); + // Infer session permission mode from the most recent user message meta. + // This makes permission mode "follow" the session across devices/machines without adding server fields. + // Local user changes should win until the next user message is sent (tracked by permissionModeUpdatedAt). + let inferredPermissionMode: PermissionMode | null = null; + let inferredPermissionModeAt: number | null = null; + for (const message of messagesArray) { + if (message.kind !== 'user-text') continue; + const mode = message.meta?.permissionMode as PermissionMode | undefined; + if (!mode) continue; + inferredPermissionMode = mode; + inferredPermissionModeAt = message.createdAt; + break; + } + // Update session with todos and latestUsage // IMPORTANT: We extract latestUsage from the mutable reducerState and copy it to the Session object // This ensures latestUsage is available immediately on load, even before messages are fully loaded let updatedSessions = state.sessions; const needsUpdate = (reducerResult.todos !== undefined || existingSession.reducerState.latestUsage) && session; - if (needsUpdate) { + const canInferPermissionMode = Boolean( + session && + inferredPermissionMode && + inferredPermissionModeAt && + inferredPermissionModeAt > (session.permissionModeUpdatedAt ?? 0) + ); + + const shouldWritePermissionMode = + canInferPermissionMode && + (session!.permissionMode ?? 'default') !== inferredPermissionMode; + + if (needsUpdate || shouldWritePermissionMode) { updatedSessions = { ...state.sessions, [sessionId]: { @@ -529,9 +557,26 @@ export const storage = create()((set, get) => { // Copy latestUsage from reducerState to make it immediately available latestUsage: existingSession.reducerState.latestUsage ? { ...existingSession.reducerState.latestUsage - } : session.latestUsage + } : session.latestUsage, + ...(shouldWritePermissionMode && { + permissionMode: inferredPermissionMode, + permissionModeUpdatedAt: inferredPermissionModeAt + }) } }; + + // Persist permission modes (only non-default values to save space) + // Note: this includes modes inferred from session messages so they load instantly on app restart. + if (shouldWritePermissionMode) { + const allModes: Record = {}; + Object.entries(updatedSessions).forEach(([id, sess]) => { + if (sess.permissionMode && sess.permissionMode !== 'default') { + allModes[id] = sess.permissionMode; + } + }); + saveSessionPermissionModes(allModes); + sessionPermissionModes = allModes; + } } return { @@ -782,12 +827,17 @@ export const storage = create()((set, get) => { const session = state.sessions[sessionId]; if (!session) return state; + const now = Date.now(); + // Update the session with the new permission mode const updatedSessions = { ...state.sessions, [sessionId]: { ...session, - permissionMode: mode + permissionMode: mode, + // Mark as locally updated so older message-based inference cannot override this selection. + // Newer user messages (from any device) will still take over. + permissionModeUpdatedAt: now } }; diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bff187d04..bfd8bce22 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -70,6 +70,7 @@ export interface Session { }>; draft?: string | null; // Local draft message, not synced to server permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server + permissionModeUpdatedAt?: number | null; // Local timestamp to coordinate inferred (from last message) vs user-selected mode, not synced to server modelMode?: 'default' | null; // Local model mode, not synced to server (models configured in CLI) // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. // We store it directly on Session to ensure it's available immediately on load. From c5b21675eba94051fc092c31bfcee2ca90972cd9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 10:26:38 +0100 Subject: [PATCH 2/3] fix(sync): persist permission mode timestamp for restart-safe arbitration --- sources/sync/persistence.ts | 21 +++++++++++++++++++++ sources/sync/storage.ts | 20 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 2f9367523..440a2a125 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -188,6 +188,27 @@ export function saveSessionPermissionModes(modes: Record mmkv.set('session-permission-modes', JSON.stringify(modes)); } +export function loadSessionPermissionModeUpdatedAts(): Record { + const raw = mmkv.getString('session-permission-mode-updated-ats'); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + return parsed; + } catch (e) { + console.error('Failed to parse session permission mode updated timestamps', e); + return {}; + } + } + return {}; +} + +export function saveSessionPermissionModeUpdatedAts(updatedAts: Record) { + mmkv.set('session-permission-mode-updated-ats', JSON.stringify(updatedAts)); +} + export function loadProfile(): Profile { const profile = mmkv.getString('profile'); if (profile) { diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index 21bd189ef..b1053aa3c 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -11,7 +11,7 @@ import { Purchases, customerInfoToPurchases } from "./purchases"; import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; -import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence"; +import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionPermissionModeUpdatedAts, saveSessionPermissionModeUpdatedAts } from "./persistence"; import type { PermissionMode } from '@/components/PermissionModeSelector'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; @@ -250,6 +250,7 @@ export const storage = create()((set, get) => { let profile = loadProfile(); let sessionDrafts = loadSessionDrafts(); let sessionPermissionModes = loadSessionPermissionModes(); + let sessionPermissionModeUpdatedAts = loadSessionPermissionModeUpdatedAts(); return { settings, settingsVersion: version, @@ -303,6 +304,7 @@ export const storage = create()((set, get) => { // Load drafts and permission modes if sessions are empty (initial load) const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {}; const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {}; + const savedPermissionModeUpdatedAts = Object.keys(state.sessions).length === 0 ? sessionPermissionModeUpdatedAts : {}; // Merge new sessions with existing ones const mergedSessions: Record = { ...state.sessions }; @@ -318,13 +320,14 @@ export const storage = create()((set, get) => { const existingPermissionMode = state.sessions[session.id]?.permissionMode; const savedPermissionMode = savedPermissionModes[session.id]; const existingPermissionModeUpdatedAt = state.sessions[session.id]?.permissionModeUpdatedAt; + const savedPermissionModeUpdatedAt = savedPermissionModeUpdatedAts[session.id]; mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', // Preserve local coordination timestamp (not synced to server) - permissionModeUpdatedAt: existingPermissionModeUpdatedAt ?? null + permissionModeUpdatedAt: existingPermissionModeUpdatedAt ?? savedPermissionModeUpdatedAt ?? null }; }); @@ -569,13 +572,19 @@ export const storage = create()((set, get) => { // Note: this includes modes inferred from session messages so they load instantly on app restart. if (shouldWritePermissionMode) { const allModes: Record = {}; + const allUpdatedAts: Record = {}; Object.entries(updatedSessions).forEach(([id, sess]) => { if (sess.permissionMode && sess.permissionMode !== 'default') { allModes[id] = sess.permissionMode; } + if (typeof sess.permissionModeUpdatedAt === 'number') { + allUpdatedAts[id] = sess.permissionModeUpdatedAt; + } }); saveSessionPermissionModes(allModes); sessionPermissionModes = allModes; + saveSessionPermissionModeUpdatedAts(allUpdatedAts); + sessionPermissionModeUpdatedAts = allUpdatedAts; } } @@ -843,14 +852,21 @@ export const storage = create()((set, get) => { // Collect all permission modes for persistence const allModes: Record = {}; + const allUpdatedAts: Record = {}; Object.entries(updatedSessions).forEach(([id, sess]) => { if (sess.permissionMode && sess.permissionMode !== 'default') { allModes[id] = sess.permissionMode; } + if (typeof sess.permissionModeUpdatedAt === 'number') { + allUpdatedAts[id] = sess.permissionModeUpdatedAt; + } }); // Persist permission modes (only non-default values to save space) saveSessionPermissionModes(allModes); + saveSessionPermissionModeUpdatedAts(allUpdatedAts); + sessionPermissionModes = allModes; + sessionPermissionModeUpdatedAts = allUpdatedAts; // No need to rebuild sessionListViewData since permission mode doesn't affect the list display return { From 6409cbe3e8b9170323a911d11996e9d7cc5a70f1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Tue, 13 Jan 2026 11:32:48 +0100 Subject: [PATCH 3/3] fix(sync): persist permission mode reliably across devices --- sources/-session/SessionView.tsx | 3 +- sources/components/PermissionModeSelector.tsx | 3 +- sources/constants/PermissionModes.ts | 12 ++ sources/sync/apiSocket.ts | 19 ++- sources/sync/persistence.ts | 13 ++- sources/sync/settings.ts | 3 +- sources/sync/storage.ts | 109 +++++++++++------- sources/sync/storageTypes.ts | 9 +- sources/sync/sync.ts | 5 +- sources/sync/time.ts | 17 +++ sources/sync/typesMessageMeta.ts | 3 +- sources/sync/typesRaw.ts | 3 +- 12 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 sources/constants/PermissionModes.ts create mode 100644 sources/sync/time.ts diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx index e93fdd4eb..3427364e4 100644 --- a/sources/-session/SessionView.tsx +++ b/sources/-session/SessionView.tsx @@ -22,6 +22,7 @@ import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; +import type { PermissionMode } from '@/constants/PermissionModes'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import * as React from 'react'; @@ -189,7 +190,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: }, [machineId, cliVersion, acknowledgedCliVersions]); // Function to update permission mode - const updatePermissionMode = React.useCallback((mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => { + const updatePermissionMode = React.useCallback((mode: PermissionMode) => { storage.getState().updateSessionPermissionMode(sessionId, mode); }, [sessionId]); diff --git a/sources/components/PermissionModeSelector.tsx b/sources/components/PermissionModeSelector.tsx index da96dd234..0e724220f 100644 --- a/sources/components/PermissionModeSelector.tsx +++ b/sources/components/PermissionModeSelector.tsx @@ -2,9 +2,10 @@ import React from 'react'; import { Text, Pressable, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Typography } from '@/constants/Typography'; +import type { PermissionMode } from '@/constants/PermissionModes'; import { hapticsLight } from './haptics'; -export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; +export type { PermissionMode } from '@/constants/PermissionModes'; export type ModelMode = 'default' | 'adaptiveUsage' | 'sonnet' | 'opus' | 'gpt-5-codex-high' | 'gpt-5-codex-medium' | 'gpt-5-codex-low' | 'gpt-5-minimal' | 'gpt-5-low' | 'gpt-5-medium' | 'gpt-5-high'; diff --git a/sources/constants/PermissionModes.ts b/sources/constants/PermissionModes.ts new file mode 100644 index 000000000..b45e0b326 --- /dev/null +++ b/sources/constants/PermissionModes.ts @@ -0,0 +1,12 @@ +export const PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan', + 'read-only', + 'safe-yolo', + 'yolo', +] as const; + +export type PermissionMode = (typeof PERMISSION_MODES)[number]; + diff --git a/sources/sync/apiSocket.ts b/sources/sync/apiSocket.ts index 7e64ae583..d2c77f3aa 100644 --- a/sources/sync/apiSocket.ts +++ b/sources/sync/apiSocket.ts @@ -1,6 +1,7 @@ import { io, Socket } from 'socket.io-client'; import { TokenStorage } from '@/auth/tokenStorage'; import { Encryption } from './encryption/encryption'; +import { observeServerTimestamp } from './time'; // // Types @@ -180,10 +181,26 @@ class ApiSocket { ...options?.headers }; - return fetch(url, { + const response = await fetch(url, { ...options, headers }); + + // Best-effort server time calibration using the HTTP Date header ("server now"). + // This avoids deriving "now" from potentially stale resource timestamps (e.g. session.updatedAt). + try { + const dateHeader = response.headers.get('date'); + if (dateHeader) { + const serverNow = Date.parse(dateHeader); + if (!Number.isNaN(serverNow)) { + observeServerTimestamp(serverNow); + } + } + } catch { + // Best-effort only + } + + return response; } // diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts index 440a2a125..6520bca21 100644 --- a/sources/sync/persistence.ts +++ b/sources/sync/persistence.ts @@ -3,7 +3,7 @@ import { Settings, settingsDefaults, settingsParse, SettingsSchema } from './set import { LocalSettings, localSettingsDefaults, localSettingsParse } from './localSettings'; import { Purchases, purchasesDefaults, purchasesParse } from './purchases'; import { Profile, profileDefaults, profileParse } from './profile'; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import type { PermissionMode } from '@/constants/PermissionModes'; const mmkv = new MMKV(); const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1'; @@ -193,10 +193,17 @@ export function loadSessionPermissionModeUpdatedAts(): Record { if (raw) { try { const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return {}; } - return parsed; + + const result: Record = {}; + for (const [sessionId, value] of Object.entries(parsed as Record)) { + if (typeof value === 'number' && Number.isFinite(value)) { + result[sessionId] = value; + } + } + return result; } catch (e) { console.error('Failed to parse session permission mode updated timestamps', e); return {}; diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts index 5746c863d..bcd85017c 100644 --- a/sources/sync/settings.ts +++ b/sources/sync/settings.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; // // Configuration Profile Schema (for environment variable profiles) @@ -116,7 +117,7 @@ export const AIBackendProfileSchema = z.object({ defaultSessionType: z.enum(['simple', 'worktree']).optional(), // Default permission mode for this profile - defaultPermissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), + defaultPermissionMode: z.enum(PERMISSION_MODES).optional(), // Default model mode for this profile defaultModelMode: z.string().optional(), diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts index b1053aa3c..115b0d083 100644 --- a/sources/sync/storage.ts +++ b/sources/sync/storage.ts @@ -12,7 +12,7 @@ import { TodoState } from "../-zen/model/ops"; import { Profile } from "./profile"; import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes"; import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionPermissionModeUpdatedAts, saveSessionPermissionModeUpdatedAts } from "./persistence"; -import type { PermissionMode } from '@/components/PermissionModeSelector'; +import { PERMISSION_MODES, type PermissionMode } from '@/constants/PermissionModes'; import type { CustomerInfo } from './revenueCat/types'; import React from "react"; import { sync } from "./sync"; @@ -21,6 +21,7 @@ import { isMutableTool } from "@/components/tools/knownTools"; import { projectManager } from "./projectManager"; import { DecryptedArtifact } from "./artifactTypes"; import { FeedItem } from "./feedTypes"; +import { nowServerMs } from "./time"; // Debounce timer for realtimeMode changes let realtimeModeDebounceTimer: ReturnType | null = null; @@ -116,7 +117,7 @@ interface StorageState { setSocketStatus: (status: 'disconnected' | 'connecting' | 'connected' | 'error') => void; getActiveSessions: () => Session[]; updateSessionDraft: (sessionId: string, draft: string | null) => void; - updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => void; + updateSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void; updateSessionModelMode: (sessionId: string, mode: 'default') => void; // Artifact methods applyArtifacts: (artifacts: DecryptedArtifact[]) => void; @@ -251,6 +252,30 @@ export const storage = create()((set, get) => { let sessionDrafts = loadSessionDrafts(); let sessionPermissionModes = loadSessionPermissionModes(); let sessionPermissionModeUpdatedAts = loadSessionPermissionModeUpdatedAts(); + + const persistSessionPermissionData = (sessions: Record) => { + const allModes: Record = {}; + const allUpdatedAts: Record = {}; + + Object.entries(sessions).forEach(([id, sess]) => { + if (sess.permissionMode && sess.permissionMode !== 'default') { + allModes[id] = sess.permissionMode; + } + if (typeof sess.permissionModeUpdatedAt === 'number') { + allUpdatedAts[id] = sess.permissionModeUpdatedAt; + } + }); + + try { + saveSessionPermissionModes(allModes); + saveSessionPermissionModeUpdatedAts(allUpdatedAts); + sessionPermissionModes = allModes; + sessionPermissionModeUpdatedAts = allUpdatedAts; + } catch (e) { + console.error('Failed to persist session permission data:', e); + } + }; + return { settings, settingsVersion: version, @@ -321,13 +346,38 @@ export const storage = create()((set, get) => { const savedPermissionMode = savedPermissionModes[session.id]; const existingPermissionModeUpdatedAt = state.sessions[session.id]?.permissionModeUpdatedAt; const savedPermissionModeUpdatedAt = savedPermissionModeUpdatedAts[session.id]; + + // CLI may publish a session permission mode in encrypted metadata for local-only starts. + // This is a fallback signal for when there are no app-sent user messages carrying meta.permissionMode yet. + const metadataPermissionMode = session.metadata?.permissionMode ?? null; + const metadataPermissionModeUpdatedAt = session.metadata?.permissionModeUpdatedAt ?? null; + + let mergedPermissionMode = + existingPermissionMode || + savedPermissionMode || + session.permissionMode || + 'default'; + + let mergedPermissionModeUpdatedAt = + existingPermissionModeUpdatedAt ?? + savedPermissionModeUpdatedAt ?? + null; + + if (metadataPermissionMode && typeof metadataPermissionModeUpdatedAt === 'number') { + const localUpdatedAt = mergedPermissionModeUpdatedAt ?? 0; + if (metadataPermissionModeUpdatedAt > localUpdatedAt) { + mergedPermissionMode = metadataPermissionMode; + mergedPermissionModeUpdatedAt = metadataPermissionModeUpdatedAt; + } + } + mergedSessions[session.id] = { ...session, presence, draft: existingDraft || savedDraft || session.draft || null, - permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default', + permissionMode: mergedPermissionMode, // Preserve local coordination timestamp (not synced to server) - permissionModeUpdatedAt: existingPermissionModeUpdatedAt ?? savedPermissionModeUpdatedAt ?? null + permissionModeUpdatedAt: mergedPermissionModeUpdatedAt }; }); @@ -527,8 +577,9 @@ export const storage = create()((set, get) => { let inferredPermissionModeAt: number | null = null; for (const message of messagesArray) { if (message.kind !== 'user-text') continue; - const mode = message.meta?.permissionMode as PermissionMode | undefined; - if (!mode) continue; + const rawMode = message.meta?.permissionMode; + if (!rawMode || !PERMISSION_MODES.includes(rawMode as any)) continue; + const mode = rawMode as PermissionMode; inferredPermissionMode = mode; inferredPermissionModeAt = message.createdAt; break; @@ -544,6 +595,9 @@ export const storage = create()((set, get) => { session && inferredPermissionMode && inferredPermissionModeAt && + // NOTE: inferredPermissionModeAt comes from message.createdAt (server timestamp for remote messages, + // and best-effort server-aligned timestamp for locally-created optimistic messages). + // permissionModeUpdatedAt is stamped using nowServerMs() for clock-safe ordering across devices. inferredPermissionModeAt > (session.permissionModeUpdatedAt ?? 0) ); @@ -571,20 +625,7 @@ export const storage = create()((set, get) => { // Persist permission modes (only non-default values to save space) // Note: this includes modes inferred from session messages so they load instantly on app restart. if (shouldWritePermissionMode) { - const allModes: Record = {}; - const allUpdatedAts: Record = {}; - Object.entries(updatedSessions).forEach(([id, sess]) => { - if (sess.permissionMode && sess.permissionMode !== 'default') { - allModes[id] = sess.permissionMode; - } - if (typeof sess.permissionModeUpdatedAt === 'number') { - allUpdatedAts[id] = sess.permissionModeUpdatedAt; - } - }); - saveSessionPermissionModes(allModes); - sessionPermissionModes = allModes; - saveSessionPermissionModeUpdatedAts(allUpdatedAts); - sessionPermissionModeUpdatedAts = allUpdatedAts; + persistSessionPermissionData(updatedSessions); } } @@ -832,11 +873,11 @@ export const storage = create()((set, get) => { sessionListViewData }; }), - updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => set((state) => { + updateSessionPermissionMode: (sessionId: string, mode: PermissionMode) => set((state) => { const session = state.sessions[sessionId]; if (!session) return state; - const now = Date.now(); + const now = nowServerMs(); // Update the session with the new permission mode const updatedSessions = { @@ -850,23 +891,7 @@ export const storage = create()((set, get) => { } }; - // Collect all permission modes for persistence - const allModes: Record = {}; - const allUpdatedAts: Record = {}; - Object.entries(updatedSessions).forEach(([id, sess]) => { - if (sess.permissionMode && sess.permissionMode !== 'default') { - allModes[id] = sess.permissionMode; - } - if (typeof sess.permissionModeUpdatedAt === 'number') { - allUpdatedAts[id] = sess.permissionModeUpdatedAt; - } - }); - - // Persist permission modes (only non-default values to save space) - saveSessionPermissionModes(allModes); - saveSessionPermissionModeUpdatedAts(allUpdatedAts); - sessionPermissionModes = allModes; - sessionPermissionModeUpdatedAts = allUpdatedAts; + persistSessionPermissionData(updatedSessions); // No need to rebuild sessionListViewData since permission mode doesn't affect the list display return { @@ -997,6 +1022,12 @@ export const storage = create()((set, get) => { const modes = loadSessionPermissionModes(); delete modes[sessionId]; saveSessionPermissionModes(modes); + sessionPermissionModes = modes; + + const updatedAts = loadSessionPermissionModeUpdatedAts(); + delete updatedAts[sessionId]; + saveSessionPermissionModeUpdatedAts(updatedAts); + sessionPermissionModeUpdatedAts = updatedAts; // Rebuild sessionListViewData without the deleted session const sessionListViewData = buildSessionListViewData(remainingSessions); diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts index bfd8bce22..853f7bd61 100644 --- a/sources/sync/storageTypes.ts +++ b/sources/sync/storageTypes.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { PERMISSION_MODES } from "@/constants/PermissionModes"; +import type { PermissionMode } from "@/constants/PermissionModes"; // // Agent states @@ -21,7 +23,10 @@ export const MetadataSchema = z.object({ homeDir: z.string().optional(), // User's home directory on the machine happyHomeDir: z.string().optional(), // Happy configuration directory hostPid: z.number().optional(), // Process ID of the session - flavor: z.string().nullish() // Session flavor/variant identifier + flavor: z.string().nullish(), // Session flavor/variant identifier + // Published by happy-cli so the app can seed permission state even before there are messages. + permissionMode: z.enum(PERMISSION_MODES).optional(), + permissionModeUpdatedAt: z.number().optional(), }); export type Metadata = z.infer; @@ -69,7 +74,7 @@ export interface Session { id: string; }>; draft?: string | null; // Local draft message, not synced to server - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server + permissionMode?: PermissionMode | null; // Local permission mode, not synced to server permissionModeUpdatedAt?: number | null; // Local timestamp to coordinate inferred (from last message) vs user-selected mode, not synced to server modelMode?: 'default' | null; // Local model mode, not synced to server (models configured in CLI) // IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing. diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts index fde7d5b02..3b4873a8a 100644 --- a/sources/sync/sync.ts +++ b/sources/sync/sync.ts @@ -31,6 +31,7 @@ import { voiceHooks } from '@/realtime/hooks/voiceHooks'; import { Message } from './typesMessage'; import { EncryptionCache } from './encryption/encryptionCache'; import { systemPrompt } from './prompt/systemPrompt'; +import { nowServerMs } from './time'; import { fetchArtifact, fetchArtifacts, createArtifact, updateArtifact } from './apiArtifacts'; import { DecryptedArtifact, Artifact, ArtifactCreateRequest, ArtifactUpdateRequest } from './artifactTypes'; import { ArtifactEncryption } from './encryption/artifactEncryption'; @@ -270,7 +271,7 @@ class Sync { const encryptedRawRecord = await encryption.encryptRawRecord(content); // Add to messages - normalize the raw record - const createdAt = Date.now(); + const createdAt = nowServerMs(); const normalizedMessage = normalizeRawMessage(localId, localId, createdAt, content); if (normalizedMessage) { this.applyMessages(sessionId, [normalizedMessage]); @@ -1392,7 +1393,7 @@ class Sync { throw new Error(`Session encryption not ready for ${sessionId}`); } - // Request + // Request (apiSocket.request calibrates server time best-effort from the HTTP Date header) const response = await apiSocket.request(`/v1/sessions/${sessionId}/messages`); const data = await response.json(); diff --git a/sources/sync/time.ts b/sources/sync/time.ts new file mode 100644 index 000000000..e9a146bb0 --- /dev/null +++ b/sources/sync/time.ts @@ -0,0 +1,17 @@ +let serverTimeOffsetMs = 0; + +export function observeServerTimestamp(serverTimestampMs: number | null | undefined) { + if (typeof serverTimestampMs !== 'number' || !Number.isFinite(serverTimestampMs)) { + return; + } + serverTimeOffsetMs = serverTimestampMs - Date.now(); +} + +/** + * Best-effort server-aligned "now" for clock-safe ordering across devices. + * Falls back to Date.now() until we observe at least one server timestamp. + */ +export function nowServerMs(): number { + return Date.now() + serverTimeOffsetMs; +} + diff --git a/sources/sync/typesMessageMeta.ts b/sources/sync/typesMessageMeta.ts index cbfd4f29a..f8d697993 100644 --- a/sources/sync/typesMessageMeta.ts +++ b/sources/sync/typesMessageMeta.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; // Shared message metadata schema export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), // Source identifier - permissionMode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), // Permission mode for this message + permissionMode: z.enum(PERMISSION_MODES).optional(), // Permission mode for this message model: z.string().nullable().optional(), // Model name for this message (null = reset) fallbackModel: z.string().nullable().optional(), // Fallback model for this message (null = reset) customSystemPrompt: z.string().nullable().optional(), // Custom system prompt for this message (null = reset) diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts index 4dde5e855..c0e7212f5 100644 --- a/sources/sync/typesRaw.ts +++ b/sources/sync/typesRaw.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; import { MessageMetaSchema, MessageMeta } from './typesMessageMeta'; +import { PERMISSION_MODES } from '@/constants/PermissionModes'; // // Raw types @@ -52,7 +53,7 @@ const rawToolResultContentSchema = z.object({ permissions: z.object({ date: z.number(), result: z.enum(['approved', 'denied']), - mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']).optional(), + mode: z.enum(PERMISSION_MODES).optional(), allowedTools: z.array(z.string()).optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), }).optional(),