feat: add real-time permission and model mode synchronization close #21

Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
This commit is contained in:
weishu
2025-12-28 20:51:18 +08:00
parent 2dc09a80db
commit 334cebc2da
21 changed files with 361 additions and 120 deletions
+2
View File
@@ -12,6 +12,8 @@ type SessionAlivePayload = {
time: number
thinking?: boolean
mode?: 'local' | 'remote'
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
modelMode?: 'default' | 'sonnet' | 'opus'
}
type SessionEndPayload = {
+63 -11
View File
@@ -84,8 +84,8 @@ export interface Session {
thinking: boolean
thinkingAt: number
todos?: TodoItem[]
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null
modelMode?: 'default' | 'sonnet' | 'opus' | null
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
modelMode?: 'default' | 'sonnet' | 'opus'
}
export interface Machine {
@@ -312,7 +312,14 @@ export class SyncEngine {
this.emit(event)
}
handleSessionAlive(payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }): void {
handleSessionAlive(payload: {
sid: string
time: number
thinking?: boolean
mode?: 'local' | 'remote'
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
modelMode?: 'default' | 'sonnet' | 'opus'
}): void {
const t = clampAliveTime(payload.time)
if (!t) return
@@ -321,21 +328,40 @@ export class SyncEngine {
const wasActive = session.active
const wasThinking = session.thinking
const previousPermissionMode = session.permissionMode
const previousModelMode = session.modelMode
session.active = true
session.activeAt = Math.max(session.activeAt, t)
session.thinking = Boolean(payload.thinking)
session.thinkingAt = t
if (payload.permissionMode !== undefined) {
session.permissionMode = payload.permissionMode
}
if (payload.modelMode !== undefined) {
session.modelMode = payload.modelMode
}
const now = Date.now()
const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0
const modeChanged = previousPermissionMode !== session.permissionMode || previousModelMode !== session.modelMode
const shouldBroadcast = (!wasActive && session.active)
|| (wasThinking !== session.thinking)
|| modeChanged
|| (now - lastBroadcastAt > 10_000)
if (shouldBroadcast) {
this.lastBroadcastAtBySessionId.set(session.id, now)
this.emit({ type: 'session-updated', sessionId: session.id, data: { activeAt: session.activeAt, thinking: session.thinking } })
this.emit({
type: 'session-updated',
sessionId: session.id,
data: {
activeAt: session.activeAt,
thinking: session.thinking,
permissionMode: session.permissionMode,
modelMode: session.modelMode
}
})
}
}
@@ -455,8 +481,8 @@ export class SyncEngine {
thinking: existing?.thinking ?? false,
thinkingAt: existing?.thinkingAt ?? 0,
todos,
permissionMode: existing?.permissionMode ?? null,
modelMode: existing?.modelMode ?? null
permissionMode: existing?.permissionMode,
modelMode: existing?.modelMode
}
this.sessions.set(sessionId, session)
@@ -549,7 +575,6 @@ export class SyncEngine {
}
async sendMessage(sessionId: string, payload: { text: string; localId?: string | null; sentFrom?: 'telegram-bot' | 'webapp' }): Promise<void> {
const session = this.sessions.get(sessionId)
const sentFrom = payload.sentFrom ?? 'webapp'
const content = {
@@ -559,9 +584,7 @@ export class SyncEngine {
text: payload.text
},
meta: {
sentFrom,
permissionMode: session?.permissionMode || 'default',
model: session?.modelMode === 'default' ? null : session?.modelMode ?? undefined
sentFrom
}
}
@@ -643,7 +666,7 @@ export class SyncEngine {
async setPermissionMode(
sessionId: string,
mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
): Promise<void> {
const session = this.sessions.get(sessionId)
if (session) {
@@ -660,6 +683,35 @@ export class SyncEngine {
}
}
async applySessionConfig(
sessionId: string,
config: {
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
modelMode?: 'default' | 'sonnet' | 'opus'
}
): Promise<void> {
const result = await this.sessionRpc(sessionId, 'set-session-config', config)
if (!result || typeof result !== 'object') {
throw new Error('Invalid response from session config RPC')
}
const obj = result as { applied?: { permissionMode?: Session['permissionMode']; modelMode?: Session['modelMode'] } }
const applied = obj.applied
if (!applied || typeof applied !== 'object') {
throw new Error('Missing applied session config')
}
const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
if (session) {
if (applied.permissionMode !== undefined) {
session.permissionMode = applied.permissionMode
}
if (applied.modelMode !== undefined) {
session.modelMode = applied.modelMode
}
this.emit({ type: 'session-updated', sessionId, data: session })
}
}
async spawnSession(
machineId: string,
directory: string,
+35 -7
View File
@@ -27,7 +27,7 @@ type SessionSummary = {
metadata: SessionSummaryMetadata | null
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
modelMode?: 'default' | 'sonnet' | 'opus' | null
modelMode?: 'default' | 'sonnet' | 'opus'
}
function toSessionSummary(session: Session): SessionSummary {
@@ -55,12 +55,12 @@ function toSessionSummary(session: Session): SessionSummary {
metadata,
todoProgress,
pendingRequestsCount,
modelMode: session.modelMode ?? null
modelMode: session.modelMode
}
}
const permissionModeSchema = z.object({
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo'])
})
const modelModeSchema = z.object({
@@ -159,8 +159,26 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ error: 'Invalid body' }, 400)
}
await engine.setPermissionMode(sessionResult.sessionId, parsed.data.mode)
return c.json({ ok: true })
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
const mode = parsed.data.mode
const claudeModes = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
const codexModes = new Set(['default', 'read-only', 'safe-yolo', 'yolo'])
if (flavor === 'gemini') {
return c.json({ error: 'Permission mode not supported for Gemini sessions' }, 400)
}
if (flavor === 'codex' ? !codexModes.has(mode) : !claudeModes.has(mode)) {
return c.json({ error: 'Invalid permission mode for session flavor' }, 400)
}
try {
await engine.applySessionConfig(sessionResult.sessionId, { permissionMode: mode })
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to apply permission mode'
return c.json({ error: message }, 409)
}
})
app.post('/sessions/:id/model', async (c) => {
@@ -180,8 +198,18 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ error: 'Invalid body' }, 400)
}
await engine.setModelMode(sessionResult.sessionId, parsed.data.model)
return c.json({ ok: true })
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
if (flavor !== 'claude') {
return c.json({ error: 'Model mode is only supported for Claude sessions' }, 400)
}
try {
await engine.applySessionConfig(sessionResult.sessionId, { modelMode: parsed.data.model })
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to apply model mode'
return c.json({ error: message }, 409)
}
})
app.get('/sessions/:id/slash-commands', async (c) => {