claude-code-best/src/server/routes/session.ts
DoSun 1a440af30b feat: 对齐 OpenCode 即时推送架构,session status 事件绕过队列直接推送
- sessionState.ts: 单 listener → Set 多订阅者,新增 registerSdkEventConsumer 绕过 SDK 事件队列
- sdkEventQueue.ts: SessionStateChangedEvent 从队列系统移除
- print.ts: runHeadlessStreaming 注册直接消费者,事件即时 output.enqueue()
- query.ts: queryLoop 每轮迭代发送 running 心跳
- server/routes/session.ts: prompt_async 立即 emit busy(不等子进程 init),历史会话先解析 body 再恢复
- server/sessionHandle.ts: prompt() 检测已 busy 时跳过重复 emit
- server/eventBus.ts: 添加 SSE publish 耗时日志
- server/server.ts: 传递 eventBus 给 sessionRoutes
- 新增 16 个 sessionState 多订阅者 + SDK consumer 测试
2026-05-12 16:49:26 +08:00

590 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string | undefined> {
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<Record<string, unknown>>,
messageID?: string,
) {
return streamSSE(c, async stream => {
const startTime = Date.now()
let resultWritten = false
let pendingWrite: Promise<void> = 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<string, unknown>
}>()
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 的顶层 sessioncsc 无 parent 概念,全部视为 root
// const rootsOnly = url.searchParams.get('roots') === 'true'
// 从磁盘读取历史 session 列表
let historySessions: Awaited<ReturnType<typeof listSessionsImpl>> = []
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<Record<string, unknown>>
files?: string[]
images?: unknown[]
model?: { providerID?: string; modelID?: string }
agent?: string
messageID?: string
}>()
const textContent = body.content ?? body.parts
?.filter((p): p is Record<string, unknown> & { 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<string, unknown> | 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 (330s).
const body = await c.req.json<{
content?: string
parts?: Array<Record<string, unknown>>
files?: string[]
images?: unknown[]
model?: { providerID?: string; modelID?: string }
agent?: string
messageID?: string
}>()
const textContent = body.content ?? body.parts
?.filter((p): p is Record<string, unknown> & { 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<string, unknown> | 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 })
})
}