import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import type { SessionManager } from '../sessionManager.js' import { getScriptArgsForChild } from '../sessionHandle.js' import { badRequest, notFound, tooManySessions, sessionError, conflict, } from '../errors.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js' function ssePrompt( handle: import('../sessionHandle.js').SessionHandle, id: string, content: string, c: import('hono').Context, ) { return streamSSE(c, async stream => { const startTime = Date.now() let resultWritten = false let pendingWrite: Promise = Promise.resolve() const unsub = handle.onMessage(msg => { if (resultWritten) return if (msg.type === 'result') { resultWritten = true const info = handle.getInfo() pendingWrite = pendingWrite.then(() => stream.writeSSE({ event: 'result', data: JSON.stringify({ ...msg, session_id: id, cost_usd: info.cost_usd, duration_ms: Date.now() - startTime, }), }), ) unsub() } else if ( msg.type === 'assistant' || msg.type === 'tool_progress' ) { pendingWrite = pendingWrite.then(() => stream.writeSSE({ event: 'message', data: JSON.stringify({ ...msg, session_id: id }), }), ) } else if (msg.type === 'control_request') { pendingWrite = pendingWrite.then(() => stream.writeSSE({ event: 'control_request', data: JSON.stringify({ ...msg, session_id: id }), }), ) } else if (msg.type === 'system') { pendingWrite = pendingWrite.then(() => stream.writeSSE({ event: 'system', data: JSON.stringify({ ...msg, session_id: id }), }), ) } }) stream.onAbort(() => { unsub() }) try { await handle.prompt(content) } catch { if (!resultWritten) { pendingWrite = pendingWrite.then(() => stream.writeSSE({ event: 'result', data: JSON.stringify({ type: 'result', subtype: 'error_during_execution', session_id: id, duration_ms: Date.now() - startTime, }), }), ) } } await pendingWrite if (!resultWritten) { await stream.writeSSE({ event: 'result', data: JSON.stringify({ type: 'result', subtype: 'success', session_id: id, duration_ms: Date.now() - startTime, }), }) } await stream.sleep(50) }) } export function createSessionRoutes( sessionManager: SessionManager, ): Hono { return new Hono() .post('/session', async c => { const body = await c.req.json<{ cwd?: string permission_mode?: string model?: string system_prompt?: string resume_session_id?: string }>() try { const handle = await sessionManager.createSession({ cwd: body.cwd, model: body.model, permissionMode: body.permission_mode, systemPrompt: body.system_prompt, resumeSessionId: body.resume_session_id, execPath: process.execPath, scriptArgs: getScriptArgsForChild(), }) const initData = handle.initData return c.json( { session_id: handle.sessionId, status: handle.status, cwd: handle.cwd, created_at: handle.getInfo().created_at, commands: initData?.commands ?? [], agents: initData?.agents ?? [], models: initData?.models ?? [], account: initData?.account ?? {}, }, 201, ) } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to create session' if (msg.includes('Maximum concurrent')) { throw tooManySessions(msg) } throw sessionError(msg) } }) .get('/session', async c => { const url = new URL(c.req.url) const limit = parseInt(url.searchParams.get('limit') ?? '50', 10) const offset = parseInt(url.searchParams.get('offset') ?? '0', 10) const dir = url.searchParams.get('dir') ?? undefined // roots=true 时只返回没有 parentID 的顶层 session(csc 无 parent 概念,全部视为 root) // const rootsOnly = url.searchParams.get('roots') === 'true' // 从磁盘读取历史 session 列表 let historySessions: Awaited> = [] try { historySessions = await listSessionsImpl({ dir, limit: limit + offset }) process.stderr.write(`[server:session] listSessionsImpl dir=${dir ?? 'all'} found=${historySessions.length}\n`) } catch (err) { process.stderr.write(`[server:session] listSessionsImpl error: ${err}\n`) } // 内存中活跃的 handle,用于覆盖运行时状态 const handleMap = new Map( sessionManager.getAllSessions().map(h => [h.sessionId, h]) ) // 把磁盘历史会话转成统一格式,如果内存中有对应 handle 则合并运行时字段 const merged = historySessions.map(s => { const handle = handleMap.get(s.sessionId) const info = handle?.getInfo() return { session_id: s.sessionId, status: info?.status ?? 'stopped', cwd: info?.cwd ?? s.cwd ?? '', title: (info?.title ?? s.customTitle ?? s.firstPrompt ?? s.summary) ?? '', model: info?.model, permission_mode: info?.permission_mode, created_at: s.createdAt ?? info?.created_at ?? 0, last_active_at: s.lastModified ?? info?.last_active_at ?? 0, cost_usd: info?.cost_usd ?? 0, input_tokens: info?.input_tokens ?? 0, output_tokens: info?.output_tokens ?? 0, } }) // 补充内存中有但磁盘还没落盘的活跃 session(刚创建还没写过消息的) const historyIds = new Set(historySessions.map(s => s.sessionId)) for (const handle of sessionManager.getAllSessions()) { if (!historyIds.has(handle.sessionId)) { const info = handle.getInfo() merged.push({ ...info, title: info.title ?? '' }) } } // 按最后活跃时间倒序 merged.sort((a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0)) const sessions = merged.slice(offset, offset + limit) process.stderr.write(`[server:session] GET /session -> history=${historySessions.length} active=${handleMap.size} merged=${merged.length} returned=${sessions.length}\n`) return c.json({ sessions }) }) .get('/session/status', async c => { // 内存中活跃 session 的状态 const activeStatuses = sessionManager.getSessionStatuses() // 补充磁盘历史 session(全部视为 idle) let historySessions: Awaited> = [] try { historySessions = await listSessionsImpl({ limit: 200 }) } catch {} const sessions: Record = {} // 先把历史 session 全部标为 idle/stopped for (const s of historySessions) { sessions[s.sessionId] = { status: 'stopped', state: 'stopped', has_pending_permission: false, type: 'idle', } } // 用内存中活跃的 handle 状态覆盖 for (const [id, st] of Object.entries(activeStatuses)) { sessions[id] = { status: st.status, state: st.status, has_pending_permission: st.has_pending_permission, type: st.status === 'running' ? 'busy' : 'idle', } } return c.json({ sessions }) }) .get('/session/:sessionID', c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const info = handle.getInfo() return c.json({ ...info, message_count: handle.messageCount, usage: handle.usage, }) }) .patch('/session/:sessionID', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ title?: string model?: string permission_mode?: string }>() if (body.title) handle.setTitle(body.title) if (body.model) await handle.setModel(body.model) if (body.permission_mode) await handle.setPermissionMode(body.permission_mode) return c.json({ session_id: id, title: handle.title ?? body.title, model: handle.model, permission_mode: handle.permissionMode, }) }) .delete('/session/:sessionID', async c => { const id = c.req.param('sessionID') const deleted = await sessionManager.deleteSession(id) if (!deleted) throw notFound('session not found') return c.json({ deleted: true }) }) .post('/session/:sessionID/prompt', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ content?: string parts?: Array<{ type: string; text?: string }> files?: string[] images?: unknown[] }>() const content = body.content ?? body.parts ?.filter((p) => p.type === 'text' && p.text) .map((p) => p.text) .join('\n') ?? '' if (!content) throw badRequest('content is required') if (handle.prompting) throw conflict('session is already processing a prompt') return ssePrompt(handle, id, content, c) }) .post('/session/:sessionID/prompt_async', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ content?: string parts?: Array<{ type: string; text?: string }> files?: string[] images?: unknown[] }>() const content = body.content ?? body.parts ?.filter((p) => p.type === 'text' && p.text) .map((p) => p.text) .join('\n') ?? '' if (!content) throw badRequest('content is required') if (handle.prompting) throw conflict('session is already processing a prompt') handle.prompt(content).catch(() => {}) return new Response(null, { status: 204 }) }) .post('/session/:sessionID/abort', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') await handle.abort() return c.json({ aborted: true }) }) .post('/session/:sessionID/shell', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') return ssePrompt(handle, id, body.command, c) }) .post('/session/:sessionID/command', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') return ssePrompt(handle, id, body.command, c) }) .post('/session/:sessionID/command_async', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') if (handle.prompting) throw conflict('session is already processing a prompt') handle.prompt(body.command).catch(() => {}) return new Response(null, { status: 204 }) }) .post('/session/:sessionID/revert', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') return c.json(handle.getInfo()) }) .post('/session/:sessionID/summarize', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (!handle) throw notFound('session not found') return c.json({ ok: true }) }) }