diff --git a/hub/src/sync/permissionModePersistence.test.ts b/hub/src/sync/permissionModePersistence.test.ts new file mode 100644 index 000000000..821c02ef2 --- /dev/null +++ b/hub/src/sync/permissionModePersistence.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { SyncEngine } from './syncEngine' + +function createEngine(store?: Store): SyncEngine { + const engine = new SyncEngine( + store ?? new Store(':memory:'), + { + of: () => ({ + to: () => ({ + emit: () => {} + }) + }) + } as never, + {} as never, + { + broadcast: () => {} + } as never + ) + engine.stop() + return engine +} + +describe('permission mode persistence', () => { + it('restores saved permission mode after reloading sessions from the store', () => { + const store = new Store(':memory:') + const engine = createEngine(store) + + const session = engine.getOrCreateSession( + 'permission-mode-persist', + { path: '/tmp/project', host: 'localhost' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + engine.handleSessionAlive({ + sid: session.id, + time: Date.now(), + thinking: false, + permissionMode: 'yolo' + }) + + const reloadedEngine = createEngine(store) + const reloadedSession = reloadedEngine.getSession(session.id) + + expect(reloadedSession?.metadata?.preferredPermissionMode).toBe('yolo') + expect(reloadedSession?.permissionMode).toBe('yolo') + }) + + it('reapplies the previous permission mode when resuming an archived session', async () => { + const engine = createEngine() + + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const session = engine.getOrCreateSession( + 'resume-permission-mode', + { + path: '/tmp/project', + host: 'localhost', + machineId: machine.id, + flavor: 'codex', + codexSessionId: 'resume-token', + preferredPermissionMode: 'yolo' + }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + const calls: Array<{ type: 'spawn' } | { type: 'config'; sessionId: string; permissionMode?: string }> = [] + const spawnSession = async () => { + calls.push({ type: 'spawn' }) + return { type: 'success' as const, sessionId: session.id } + } + const requestSessionConfig = async (sessionId: string, config: { permissionMode?: string }) => { + calls.push({ type: 'config', sessionId, permissionMode: config.permissionMode }) + return { applied: { permissionMode: config.permissionMode } } + } + + ;(engine as any).rpcGateway = { + spawnSession, + requestSessionConfig + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(calls).toContainEqual({ type: 'spawn' }) + expect(calls).toContainEqual({ type: 'config', sessionId: session.id, permissionMode: 'yolo' }) + }) +}) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index c9a0c5490..3c298445b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -126,7 +126,7 @@ export class SessionCache { thinkingAt: existing?.thinkingAt ?? 0, todos, teamState, - permissionMode: existing?.permissionMode, + permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode, modelMode: existing?.modelMode } @@ -167,6 +167,7 @@ export class SessionCache { session.thinkingAt = t if (payload.permissionMode !== undefined) { session.permissionMode = payload.permissionMode + this.persistPreferredPermissionMode(session, payload.permissionMode) } if (payload.modelMode !== undefined) { session.modelMode = payload.modelMode @@ -233,6 +234,7 @@ export class SessionCache { if (config.permissionMode !== undefined) { session.permissionMode = config.permissionMode + this.persistPreferredPermissionMode(session, config.permissionMode) } if (config.modelMode !== undefined) { session.modelMode = config.modelMode @@ -398,7 +400,39 @@ export class SessionCache { merged.host = oldObj.host changed = true } + if (typeof oldObj.preferredPermissionMode === 'string' && typeof newObj.preferredPermissionMode !== 'string') { + merged.preferredPermissionMode = oldObj.preferredPermissionMode + changed = true + } return changed ? merged : newMetadata } + + private persistPreferredPermissionMode(session: Session, permissionMode: PermissionMode): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.preferredPermissionMode === permissionMode) { + return + } + + const nextMetadata = { ...currentMetadata, preferredPermissionMode: permissionMode } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index da497556d..607a5a1cf 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -329,6 +329,7 @@ export class SyncEngine { if (!metadata || typeof metadata.path !== 'string') { return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } } + const preferredPermissionMode = session.permissionMode ?? metadata.preferredPermissionMode const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' || metadata.flavor === 'cursor' ? metadata.flavor @@ -388,6 +389,15 @@ export class SyncEngine { return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } } + if (preferredPermissionMode !== undefined) { + try { + await this.applySessionConfig(spawnResult.sessionId, { permissionMode: preferredPermissionMode }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to restore permission mode' + return { type: 'error', message, code: 'resume_failed' } + } + } + if (spawnResult.sessionId !== access.sessionId) { try { await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 5fc5c1bb7..d470aac12 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -45,6 +45,7 @@ export const MetadataSchema = z.object({ lifecycleStateSince: z.number().optional(), archivedBy: z.string().optional(), archiveReason: z.string().optional(), + preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), worktree: WorktreeMetadataSchema.optional() })