Files
hapi/server/src/web/routes/sessions.ts
T
2026-01-03 14:43:59 +08:00

322 lines
11 KiB
TypeScript

import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'
import { Hono } from 'hono'
import { z } from 'zod'
import type { ModelMode } from '@hapi/protocol/types'
import type { SyncEngine, Session } from '../../sync/syncEngine'
import type { WebAppEnv } from '../middleware/auth'
import { requireSessionFromParam, requireSyncEngine } from './guards'
type SessionSummaryMetadata = {
name?: string
path: string
machineId?: string
summary?: { text: string }
flavor?: string | null
worktree?: {
basePath: string
branch: string
name: string
worktreePath?: string
createdAt?: number
}
}
type SessionSummary = {
id: string
active: boolean
activeAt: number
updatedAt: number
metadata: SessionSummaryMetadata | null
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
modelMode?: ModelMode
}
function toSessionSummary(session: Session): SessionSummary {
const pendingRequestsCount = session.agentState?.requests ? Object.keys(session.agentState.requests).length : 0
const metadata: SessionSummaryMetadata | null = session.metadata ? {
name: session.metadata.name,
path: session.metadata.path,
machineId: session.metadata.machineId ?? undefined,
summary: session.metadata.summary ? { text: session.metadata.summary.text } : undefined,
flavor: session.metadata.flavor ?? null,
worktree: session.metadata.worktree
} : null
const todoProgress = session.todos?.length ? {
completed: session.todos.filter(t => t.status === 'completed').length,
total: session.todos.length
} : null
return {
id: session.id,
active: session.active,
activeAt: session.activeAt,
updatedAt: session.updatedAt,
metadata,
todoProgress,
pendingRequestsCount,
modelMode: session.modelMode
}
}
const permissionModeSchema = z.object({
mode: PermissionModeSchema
})
const modelModeSchema = z.object({
model: ModelModeSchema
})
const renameSessionSchema = z.object({
name: z.string().min(1).max(255)
})
export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.get('/sessions', (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0
const namespace = c.get('namespace')
const sessions = engine.getSessionsByNamespace(namespace)
.sort((a, b) => {
// Active sessions first
if (a.active !== b.active) {
return a.active ? -1 : 1
}
// Within active sessions, sort by pending requests count
const aPending = getPendingCount(a)
const bPending = getPendingCount(b)
if (a.active && aPending !== bPending) {
return bPending - aPending
}
// Then by updatedAt
return b.updatedAt - a.updatedAt
})
.map(toSessionSummary)
return c.json({ sessions })
})
app.get('/sessions/:id', (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
return c.json({ session: sessionResult.session })
})
app.post('/sessions/:id/abort', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
if (sessionResult instanceof Response) {
return sessionResult
}
await engine.abortSession(sessionResult.sessionId)
return c.json({ ok: true })
})
app.post('/sessions/:id/archive', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
if (sessionResult instanceof Response) {
return sessionResult
}
await engine.archiveSession(sessionResult.sessionId)
return c.json({ ok: true })
})
app.post('/sessions/:id/switch', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
if (sessionResult instanceof Response) {
return sessionResult
}
await engine.switchSession(sessionResult.sessionId, 'remote')
return c.json({ ok: true })
})
app.post('/sessions/:id/permission-mode', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
if (sessionResult instanceof Response) {
return sessionResult
}
const body = await c.req.json().catch(() => null)
const parsed = permissionModeSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400)
}
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
const mode = parsed.data.mode
if (flavor === 'gemini') {
return c.json({ error: 'Permission mode not supported for Gemini sessions' }, 400)
}
if (!isPermissionModeAllowedForFlavor(mode, flavor)) {
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) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
if (sessionResult instanceof Response) {
return sessionResult
}
const body = await c.req.json().catch(() => null)
const parsed = modelModeSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400)
}
const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
if (!isModelModeAllowedForFlavor(parsed.data.model, flavor)) {
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.patch('/sessions/:id', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
const body = await c.req.json().catch(() => null)
const parsed = renameSessionSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid body: name is required' }, 400)
}
try {
await engine.renameSession(sessionResult.sessionId, parsed.data.name)
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to rename session'
// Map concurrency/version errors to 409 conflict
if (message.includes('concurrently') || message.includes('version')) {
return c.json({ error: message }, 409)
}
return c.json({ error: message }, 500)
}
})
app.delete('/sessions/:id', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
if (sessionResult.session.active) {
return c.json({ error: 'Cannot delete active session. Archive it first.' }, 409)
}
try {
await engine.deleteSession(sessionResult.sessionId)
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete session'
// Map "active session" error to 409 conflict (race condition: session became active)
if (message.includes('active')) {
return c.json({ error: message }, 409)
}
return c.json({ error: message }, 500)
}
})
app.get('/sessions/:id/slash-commands', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
// Session must exist but doesn't need to be active
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
// Get agent type from session metadata, default to 'claude'
const agent = sessionResult.session.metadata?.flavor ?? 'claude'
try {
const result = await engine.listSlashCommands(sessionResult.sessionId, agent)
return c.json(result)
} catch (error) {
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list slash commands'
})
}
})
return app
}