mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user