- 修复 emitEvent 无限递归(调用自身→调用 eventBus.publishSessionEvent) - 修复 prompt() 用户消息缺失 uuid/content 字段 - session.ready 事件映射为 session.updated(非重复 session.created) - 新增用户消息事件(message.updated role:user + message.part.updated) - 新增 session.status busy/idle、step-start/step-finish、session.idle/diff - 清理 heartbeat properties 为空对象 - 仅 assistant 类型消息作为 session.message 转发,过滤 system/其他杂消息 - 权限响应映射 once/always/reject → allow/deny - 会话创建支持 permission ruleset 推导 permission mode - prompt 路由支持 model 字段切换模型 - 初始化消息支持 hooks 配置 - 新增 HookCallback/ControlCancelRequest 处理 - 权限响应支持 updatedPermissions 和 interrupt - 新增 --resume-session-at、--permission-prompt-tool stdio 参数 - 追踪 lastMessageUuid 用于消息级回退 - EventBus dead client 清理
351 lines
9.4 KiB
TypeScript
351 lines
9.4 KiB
TypeScript
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
import { existsSync, statSync } from 'fs'
|
|
import { join, resolve, isAbsolute } from 'path'
|
|
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|
import { logError } from '../utils/log.js'
|
|
import type { EventBus } from './eventBus.js'
|
|
import { SessionHandle, type InitData } from './sessionHandle.js'
|
|
import type { SessionIndex, SessionIndexEntry, SessionState } from './types.js'
|
|
|
|
const INDEX_FILE = 'server-sessions.json'
|
|
|
|
export class SessionManager {
|
|
private sessions = new Map<string, SessionHandle>()
|
|
private loadedIndex = new Map<string, SessionIndexEntry>()
|
|
private eventBus: EventBus
|
|
private maxSessions: number
|
|
private idleTimeoutMs: number
|
|
private defaultWorkspace?: string
|
|
private idleCheckInterval: ReturnType<typeof setInterval> | null = null
|
|
private indexDirty = false
|
|
private _cachedInitData: InitData | null = null
|
|
private _initDataReady: Promise<void> | null = null
|
|
private _resolveInitDataReady: (() => void) | null = null
|
|
private _probeHandle: SessionHandle | null = null
|
|
|
|
constructor(opts: {
|
|
eventBus: EventBus
|
|
maxSessions?: number
|
|
idleTimeoutMs?: number
|
|
workspace?: string
|
|
}) {
|
|
this.eventBus = opts.eventBus
|
|
this.maxSessions = opts.maxSessions ?? 32
|
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? 1800000
|
|
this.defaultWorkspace = opts.workspace
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
await this.loadIndex()
|
|
this.startIdleCheck()
|
|
}
|
|
|
|
private getIndexPath(): string {
|
|
return join(getClaudeConfigHomeDir(), INDEX_FILE)
|
|
}
|
|
|
|
private async loadIndex(): Promise<void> {
|
|
const path = this.getIndexPath()
|
|
if (!existsSync(path)) return
|
|
try {
|
|
const raw = await readFile(path, 'utf-8')
|
|
const index: SessionIndex = JSON.parse(raw)
|
|
for (const [key, entry] of Object.entries(index)) {
|
|
this.loadedIndex.set(key, entry)
|
|
}
|
|
} catch (err) {
|
|
logError(err as Error)
|
|
}
|
|
}
|
|
|
|
private async saveIndex(): Promise<void> {
|
|
const index: SessionIndex = {}
|
|
for (const [key, handle] of this.sessions) {
|
|
const info = handle.getInfo()
|
|
index[key] = {
|
|
sessionId: handle.sessionId,
|
|
transcriptSessionId: handle.sessionId,
|
|
cwd: handle.cwd,
|
|
permissionMode: info.permission_mode,
|
|
createdAt: info.created_at,
|
|
lastActiveAt: info.last_active_at,
|
|
}
|
|
}
|
|
const dir = getClaudeConfigHomeDir()
|
|
if (!existsSync(dir)) {
|
|
await mkdir(dir, { recursive: true })
|
|
}
|
|
try {
|
|
await writeFile(this.getIndexPath(), JSON.stringify(index, null, 2))
|
|
} catch (err) {
|
|
logError(err as Error)
|
|
}
|
|
}
|
|
|
|
private scheduleIndexSave(): void {
|
|
if (this.indexDirty) return
|
|
this.indexDirty = true
|
|
setTimeout(async () => {
|
|
this.indexDirty = false
|
|
await this.saveIndex()
|
|
}, 1000)
|
|
}
|
|
|
|
private startIdleCheck(): void {
|
|
if (this.idleTimeoutMs <= 0) return
|
|
const checkInterval = Math.min(this.idleTimeoutMs / 2, 60000)
|
|
this.idleCheckInterval = setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [id, handle] of this.sessions) {
|
|
const info = handle.getInfo()
|
|
if (
|
|
info.status === 'running' &&
|
|
now - info.last_active_at > this.idleTimeoutMs!
|
|
) {
|
|
handle.kill()
|
|
this.eventBus.publishSessionEvent(id, 'deleted', {
|
|
status: 'stopped',
|
|
reason: 'idle_timeout',
|
|
})
|
|
this.sessions.delete(id)
|
|
this.scheduleIndexSave()
|
|
}
|
|
}
|
|
}, checkInterval)
|
|
}
|
|
|
|
getActiveCount(): number {
|
|
return this.sessions.size
|
|
}
|
|
|
|
getSession(id: string): SessionHandle | undefined {
|
|
return this.sessions.get(id)
|
|
}
|
|
|
|
getAllSessions(): SessionHandle[] {
|
|
return [...this.sessions.values()]
|
|
}
|
|
|
|
getSessionStatuses(): Record<
|
|
string,
|
|
{ status: SessionState; has_pending_permission: boolean }
|
|
> {
|
|
const result: Record<
|
|
string,
|
|
{ status: SessionState; has_pending_permission: boolean }
|
|
> = {}
|
|
for (const [id, handle] of this.sessions) {
|
|
result[id] = {
|
|
status: handle.status,
|
|
has_pending_permission: handle.getPendingPermissions().length > 0,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
async createSession(opts: {
|
|
cwd?: string
|
|
model?: string
|
|
permissionMode?: string
|
|
systemPrompt?: string
|
|
resumeSessionId?: string
|
|
resumeSessionAt?: string
|
|
hooks?: Record<string, unknown>
|
|
execPath: string
|
|
scriptArgs: string[]
|
|
silent?: boolean
|
|
}): Promise<SessionHandle> {
|
|
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
|
|
throw new Error(
|
|
`Maximum concurrent sessions reached (${this.maxSessions})`,
|
|
)
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID()
|
|
let cwd = opts.cwd || this.defaultWorkspace || process.cwd()
|
|
if (!isAbsolute(cwd)) {
|
|
cwd = resolve(process.cwd(), cwd)
|
|
}
|
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
throw new Error(`Working directory does not exist: ${cwd}`)
|
|
}
|
|
|
|
const handle = new SessionHandle({
|
|
sessionId,
|
|
cwd,
|
|
model: opts.model,
|
|
permissionMode: opts.permissionMode,
|
|
systemPrompt: opts.systemPrompt,
|
|
resumeSessionId: opts.resumeSessionId,
|
|
resumeSessionAt: opts.resumeSessionAt,
|
|
hooks: opts.hooks,
|
|
eventBus: this.eventBus,
|
|
execPath: opts.execPath,
|
|
scriptArgs: opts.scriptArgs,
|
|
silent: opts.silent,
|
|
onInit: (data) => {
|
|
this._cachedInitData = data
|
|
this._resolveInitDataReady?.()
|
|
},
|
|
})
|
|
|
|
this.sessions.set(sessionId, handle)
|
|
if (!opts.silent) {
|
|
this.eventBus.publishSessionEvent(sessionId, 'created', {
|
|
status: 'starting',
|
|
})
|
|
}
|
|
this.scheduleIndexSave()
|
|
|
|
try {
|
|
handle.spawn()
|
|
} catch (err) {
|
|
this.sessions.delete(sessionId)
|
|
this.scheduleIndexSave()
|
|
throw err
|
|
}
|
|
|
|
return handle
|
|
}
|
|
|
|
getCachedInitData(): InitData | null {
|
|
if (this._cachedInitData) return this._cachedInitData
|
|
for (const handle of this.sessions) {
|
|
if (handle[1].initData) return handle[1].initData
|
|
}
|
|
return null
|
|
}
|
|
|
|
waitForInitData(timeoutMs = 30000): Promise<void> {
|
|
if (this._cachedInitData) return Promise.resolve()
|
|
return this._initDataReady ?? Promise.resolve()
|
|
}
|
|
|
|
startProbeSession(opts: {
|
|
cwd?: string
|
|
execPath: string
|
|
scriptArgs: string[]
|
|
}): void {
|
|
this._initDataReady = new Promise(resolve => {
|
|
this._resolveInitDataReady = resolve
|
|
})
|
|
|
|
void (async () => {
|
|
try {
|
|
const probe = await this.createSession({
|
|
cwd: opts.cwd,
|
|
execPath: opts.execPath,
|
|
scriptArgs: opts.scriptArgs,
|
|
silent: true,
|
|
})
|
|
this._probeHandle = probe
|
|
await probe.start()
|
|
await this.deleteSession(probe.sessionId)
|
|
this._probeHandle = null
|
|
} catch (err) {
|
|
this._probeHandle?.forceKill()
|
|
this._probeHandle = null
|
|
}
|
|
this._resolveInitDataReady?.()
|
|
})()
|
|
}
|
|
|
|
killProbe(): void {
|
|
if (this._probeHandle) {
|
|
this._probeHandle.forceKill()
|
|
this._probeHandle = null
|
|
}
|
|
this._resolveInitDataReady?.()
|
|
}
|
|
|
|
async deleteSession(id: string): Promise<boolean> {
|
|
const handle = this.sessions.get(id)
|
|
if (!handle) return false
|
|
const silent = handle.silent
|
|
handle.forceKill()
|
|
this.sessions.delete(id)
|
|
if (!silent) {
|
|
this.eventBus.publishSessionEvent(id, 'deleted', { status: 'stopped' })
|
|
}
|
|
this.scheduleIndexSave()
|
|
return true
|
|
}
|
|
|
|
async destroyAll(): Promise<void> {
|
|
for (const [, handle] of this.sessions) {
|
|
handle.forceKill()
|
|
}
|
|
this.sessions.clear()
|
|
if (this.idleCheckInterval) {
|
|
clearInterval(this.idleCheckInterval)
|
|
}
|
|
await this.saveIndex()
|
|
}
|
|
|
|
getAllPendingPermissions(): Array<{
|
|
requestId: string
|
|
sessionId: string
|
|
toolName: string
|
|
toolUseId: string
|
|
input: Record<string, unknown>
|
|
title: string
|
|
description: string
|
|
}> {
|
|
const result: Array<{
|
|
requestId: string
|
|
sessionId: string
|
|
toolName: string
|
|
toolUseId: string
|
|
input: Record<string, unknown>
|
|
title: string
|
|
description: string
|
|
}> = []
|
|
for (const [, handle] of this.sessions) {
|
|
result.push(...handle.getPendingPermissions())
|
|
}
|
|
return result
|
|
}
|
|
|
|
getAllPendingQuestions(): Array<{
|
|
requestId: string
|
|
sessionId: string
|
|
mcpServerName: string
|
|
message: string
|
|
mode: string
|
|
requestedSchema: Record<string, unknown>
|
|
}> {
|
|
const result: Array<{
|
|
requestId: string
|
|
sessionId: string
|
|
mcpServerName: string
|
|
message: string
|
|
mode: string
|
|
requestedSchema: Record<string, unknown>
|
|
}> = []
|
|
for (const [, handle] of this.sessions) {
|
|
result.push(...handle.getPendingQuestions())
|
|
}
|
|
return result
|
|
}
|
|
|
|
findPermissionAcrossSessions(
|
|
requestId: string,
|
|
): { handle: SessionHandle; perm: import('./sessionHandle.js').PendingPermission } | null {
|
|
for (const [, handle] of this.sessions) {
|
|
const perm = handle.getPendingPermission(requestId)
|
|
if (perm) return { handle, perm }
|
|
}
|
|
return null
|
|
}
|
|
|
|
findQuestionAcrossSessions(
|
|
requestId: string,
|
|
): { handle: SessionHandle; question: import('./sessionHandle.js').PendingQuestion } | null {
|
|
for (const [, handle] of this.sessions) {
|
|
const question = handle.getPendingQuestion(requestId)
|
|
if (question) return { handle, question }
|
|
}
|
|
return null
|
|
}
|
|
}
|