import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import type { SessionManager } from '../sessionManager.js' import type { EventBus } from '../eventBus.js' import { getScriptArgsForChild } from '../childSpawn.js' import { badRequest, notFound, tooManySessions, sessionError, conflict, } from '../errors.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js' import { canonicalizePath } from '../../utils/sessionStoragePortable.js' /** 从磁盘历史记录查找 session 的原始 cwd,用于 resume 时恢复正确的工作目录 */ async function getHistoryCwd(sessionId: string): Promise { try { const list = await listSessionsImpl({ limit: 1000 }) return list.find(s => s.sessionId === sessionId)?.cwd } catch { return undefined } } function ssePrompt( handle: import('../sessionHandle.js').SessionHandle, id: string, content: string, c: import('hono').Context, parts?: Array>, messageID?: string, ) { 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, { parts, messageID }) } 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, eventBus: EventBus, ): Hono { return new Hono() .post('/session', async c => { const body = await c.req.json<{ cwd?: string session_id?: string permission_mode?: string permission?: Array<{ permission: string; pattern: string; action: string }> model?: string system_prompt?: string resume_session_id?: string resume_session_at?: string hooks?: Record }>() const headerDir = c.req.header('x-csc-directory') || c.req.header('x-opencode-directory') const cwd = body.cwd || (headerDir ? decodeURIComponent(headerDir) : undefined) let permissionMode = body.permission_mode if (!permissionMode && body.permission) { const hasDeny = body.permission.some(r => r.action === 'deny') const hasAsk = body.permission.some(r => r.action === 'ask') if (!hasDeny && !hasAsk) { permissionMode = 'bypassPermissions' } else if (hasAsk) { permissionMode = 'default' } } try { const handle = await sessionManager.createSession({ cwd, sessionId: body.session_id, model: body.model, permissionMode, systemPrompt: body.system_prompt, resumeSessionId: body.resume_session_id, resumeSessionAt: body.resume_session_at, hooks: body.hooks, execPath: process.execPath, scriptArgs: getScriptArgsForChild(), }) // 不阻塞等待 ready:子进程冷启动可能 >30s 撞上反代超时;prompt() 内部已有 waitReady 兜底 const info = handle.getInfo() return c.json( { session_id: handle.sessionId, status: handle.status, cwd: handle.cwd, created_at: info.created_at, }, 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) // 优先从请求头 x-csc-directory 取工作目录(cs-cloud 通过此头传递), // fallback 到 query string 的 dir 参数 const headerDir = c.req.header('x-csc-directory') const dir = (headerDir ? decodeURIComponent(headerDir) : undefined) ?? 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 }) } catch {} // 内存中活跃的 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 { id: s.sessionId, session_id: s.sessionId, slug: info?.slug ?? s.sessionId, projectID: info?.projectID ?? '', status: info?.status ?? 'stopped', directory: info?.directory ?? s.cwd ?? '', cwd: info?.cwd ?? s.cwd ?? '', title: (info?.title ?? s.customTitle ?? s.firstPrompt ?? s.summary) ?? '', version: info?.version ?? '', time: info?.time ?? { created: s.createdAt ?? 0, updated: s.lastModified ?? 0, }, 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)) const canonicalDir = dir ? await canonicalizePath(dir).catch(() => dir) : undefined for (const handle of sessionManager.getAllSessions()) { if (!historyIds.has(handle.sessionId)) { if (canonicalDir) { const handleCwd = await canonicalizePath(handle.cwd).catch(() => handle.cwd) if (handleCwd !== canonicalDir) continue } const info = handle.getInfo() merged.push({ ...info, title: info.title ?? '' }) } } // 过滤掉已知的无意义管理命令会话(用户直接输入后立即退出,没有实际对话内容) const BORING_COMMANDS = new Set([ '/exit', '/quit', '/bye', '/clear', '/reset', '/model', '/models', '/login', '/logout', '/help', '/version', '/compact', ]) const filtered = merged.filter(s => { const t = s.title.trim() if (!t) return false if (BORING_COMMANDS.has(t)) return false return true }) // 按最后活跃时间倒序 filtered.sort((a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0)) const sessions = filtered.slice(offset, offset + limit) return c.json(sessions) }) .get('/session/status', async c => { const headerDir = c.req.header('x-csc-directory') const dir = headerDir ? decodeURIComponent(headerDir) : undefined const statuses = sessionManager.getSessionStatuses(dir) return c.json(statuses) }) .get('/session/:sessionID', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) if (handle) { const info = handle.getInfo() return c.json({ ...info, message_count: handle.messageCount, usage: handle.usage, }) } // 内存中没有,从磁盘历史记录查找 try { const history = await listSessionsImpl({ limit: 1000 }) const s = history.find(h => h.sessionId === id) if (s) { return c.json({ id: s.sessionId, session_id: s.sessionId, slug: s.sessionId, projectID: '', status: 'stopped', directory: s.cwd ?? '', cwd: s.cwd ?? '', title: (s.customTitle ?? s.firstPrompt ?? s.summary) ?? '', version: '', time: { created: s.createdAt ?? 0, updated: s.lastModified ?? 0, }, model: undefined, permission_mode: undefined, created_at: s.createdAt ?? 0, last_active_at: s.lastModified ?? 0, cost_usd: 0, input_tokens: 0, output_tokens: 0, message_count: 0, usage: { input_tokens: 0, output_tokens: 0 }, }) } } catch {} throw notFound('session not found') }) .patch('/session/:sessionID', async c => { const id = c.req.param('sessionID') const handle = sessionManager.getSession(id) const body = await c.req.json<{ title?: string model?: string permission_mode?: string time?: { archived?: number } }>() if (handle) { 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') <<<<<<< HEAD await sessionManager.deleteSession(id) ======= const deleted = sessionManager.deleteSession(id) if (!deleted) throw notFound('session not found') >>>>>>> dab72c6b (feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送) return c.json({ deleted: true }) }) .post('/session/:sessionID/prompt', async c => { const id = c.req.param('sessionID') let handle = sessionManager.getSession(id) if (!handle) { const cwd = await getHistoryCwd(id) try { handle = await sessionManager.createSession({ cwd, sessionId: id, resumeSessionId: id, execPath: process.execPath, scriptArgs: getScriptArgsForChild(), }) await handle.waitReady(30000) } catch (err) { throw sessionError(err instanceof Error ? err.message : 'Failed to resume session') } } const body = await c.req.json<{ content?: string parts?: Array> files?: string[] images?: unknown[] model?: { providerID?: string; modelID?: string } agent?: string messageID?: string }>() const textContent = body.content ?? body.parts ?.filter((p): p is Record & { type: string; text?: string } => p.type === 'text' && !!(p as { text?: string }).text) .map((p) => (p as { text: string }).text) .join('\n') ?? '' const agentParts = body.parts?.filter((p) => p.type === 'agent' && p.name) ?? [] const agentMentions = agentParts.map((p) => `@agent-${p.name}`).join(' ') const content = [textContent, agentMentions].filter(Boolean).join('\n') if (!content) throw badRequest('content is required') if (handle.prompting) throw conflict('session is already processing a prompt') if (body.model?.modelID) { try { await handle.setModel(body.model.modelID) } catch {} } const effectiveAgent = body.agent ?? (agentParts[0] as Record | undefined)?.name as string | undefined if (effectiveAgent && effectiveAgent !== handle.agent) { try { await handle.setAgent(effectiveAgent) } catch {} } return ssePrompt(handle, id, content, c, body.parts, body.messageID) }) .post('/session/:sessionID/prompt_async', async c => { const tEntry = Date.now() const id = c.req.param('sessionID') // Parse body FIRST — independent of session lookup, so we can // emit busy status immediately even for historical sessions that // need to create+waitReady (3–30s). const body = await c.req.json<{ content?: string parts?: Array> files?: string[] images?: unknown[] model?: { providerID?: string; modelID?: string } agent?: string messageID?: string }>() const textContent = body.content ?? body.parts ?.filter((p): p is Record & { type: string; text?: string } => p.type === 'text' && !!(p as { text?: string }).text) .map((p) => (p as { text: string }).text) .join('\n') ?? '' const agentParts = body.parts?.filter((p) => p.type === 'agent' && p.name) ?? [] const agentMentions = agentParts.map((p) => `@agent-${p.name}`).join(' ') const content = [textContent, agentMentions].filter(Boolean).join('\n') if (!content) { throw badRequest('content is required') } // Emit busy IMMEDIATELY — before session recovery or child-process // init. Aligned with OpenCode: status.set fires in the request // handler synchronously, not after waiting for the child process. eventBus.publish('session.status', { sessionID: id, status: { type: 'busy' }, }) const tBusyEmitted = Date.now() process.stderr.write( `[serve:timing:${id}] busy emitted at +${tBusyEmitted - tEntry}ms (immediate)\n`, ) let handle = sessionManager.getSession(id) // 历史 session 不在内存中,自动恢复 if (!handle) { process.stderr.write( `[serve:timing:${id}] historical session, starting recovery...\n`, ) const cwd = await getHistoryCwd(id) try { handle = await sessionManager.createSession({ cwd, sessionId: id, resumeSessionId: id, execPath: process.execPath, scriptArgs: getScriptArgsForChild(), }) const tCreated = Date.now() process.stderr.write( `[serve:timing:${id}] session created at +${tCreated - tEntry}ms, waiting for ready...\n`, ) await handle.waitReady(30000) const tReady = Date.now() process.stderr.write( `[serve:timing:${id}] session ready at +${tReady - tEntry}ms\n`, ) } catch (err) { // Recovery failed — clear the busy status we emitted earlier eventBus.publish('session.status', { sessionID: id, status: { type: 'idle' }, }) throw sessionError(err instanceof Error ? err.message : 'Failed to resume session') } } else if (handle.prompting) { throw conflict('session is already processing a prompt') } const tQueued = Date.now() process.stderr.write( `[serve:timing:${id}] session ready, queuing prompt at +${tQueued - tEntry}ms\n`, ) void (async () => { try { const tFire = Date.now() process.stderr.write( `[serve:timing:${id}] IIFE starts, status=${handle.status} at +${tFire - tEntry}ms\n`, ) if (handle.status !== 'running') { await handle.waitReady() const tReady = Date.now() process.stderr.write( `[serve:timing:${id}] IIFE waitReady done at +${tReady - tEntry}ms\n`, ) } if (body.model?.modelID) { const tModelStart = Date.now() try { await handle.setModel(body.model.modelID) } catch {} const tModelDone = Date.now() process.stderr.write( `[serve:timing:${id}] setModel done at +${tModelDone - tEntry}ms (took ${tModelDone - tModelStart}ms)\n`, ) } <<<<<<< HEAD const effectiveAgent = body.agent ?? (agentParts[0] as Record | undefined)?.name as string | undefined if (effectiveAgent && effectiveAgent !== handle.agent) { try { await handle.setAgent(effectiveAgent) } catch {} } ======= const tPromptCall = Date.now() process.stderr.write( `[serve:timing:${id}] calling prompt() at +${tPromptCall - tEntry}ms\n`, ) >>>>>>> dab72c6b (feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送) handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {}) } catch {} })() return c.json({ ok: true }, 200) }) .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 }) }) }