feat: serve 模式支持 resume 历史会话继续对话

- prompt/prompt_async 接口找不到内存 session 时自动从磁盘历史恢复
- 从 transcript 读取历史 session 的原始 cwd,保证子进程在正确目录启动
- resume 时不传 --session-id 避免与 --resume 冲突(csc 限制)
- sessionManager.createSession 支持传入指定 sessionId,resume 时复用历史 id

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Askhz 2026-05-09 17:54:25 +08:00
parent f34325f60a
commit 550dbb5532
3 changed files with 47 additions and 7 deletions

View File

@ -11,6 +11,16 @@ import {
} from '../errors.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.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,
@ -354,8 +364,22 @@ export function createSessionRoutes(
})
.post('/session/:sessionID/prompt', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
if (!handle) throw notFound('session not found')
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
@ -381,9 +405,23 @@ export function createSessionRoutes(
.post('/session/:sessionID/prompt_async', async c => {
const id = c.req.param('sessionID')
const handle = sessionManager.getSession(id)
let handle = sessionManager.getSession(id)
// 历史 session 不在内存中,自动恢复
if (!handle) {
throw notFound('session not found')
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<{

View File

@ -245,8 +245,8 @@ export class SessionHandle {
'stream-json',
'--output-format',
'stream-json',
'--session-id',
this.sessionId,
// resume 时不传 --session-idcsc 会继承历史 session 的 id
...(this.opts.resumeSessionId ? [] : ['--session-id', this.sessionId]),
...(this.opts.model ? ['--model', this.opts.model] : []),
'--permission-mode',
this._permissionMode,
@ -299,6 +299,7 @@ export class SessionHandle {
const reject = this.initReject
this.initResolve = null
this.initReject = null
process.stderr.write(`[serve:${this.sessionId}] exit_code=${code} cwd=${this.cwd} stderr:\n${this.lastStderr.slice(-5).join('\n')}\n`)
reject(new Error(`Process exited with code ${code}`))
}
if (this.promptReject) {

View File

@ -155,6 +155,7 @@ export class SessionManager {
execPath: string
scriptArgs: string[]
silent?: boolean
sessionId?: string
}): Promise<SessionHandle> {
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
throw new Error(
@ -162,7 +163,7 @@ export class SessionManager {
)
}
const sessionId = crypto.randomUUID()
const sessionId = opts.sessionId ?? crypto.randomUUID()
let cwd = opts.cwd || this.defaultWorkspace || process.cwd()
if (!isAbsolute(cwd)) {
cwd = resolve(process.cwd(), cwd)