mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
322 lines
11 KiB
TypeScript
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
|
|
}
|