feat(server): 借鉴 vibe-kanban 优化子进程管理与消息可靠性
- 添加内存消息缓冲区,解决 tab 切换后用户消息丢失问题 - prompt() 去除重复 waitReady(),减少不必要的等待 - 新增 kill_on_drop 工具(FinalizationRegistry),待后续启用 - 新增权限模式 hooks 自动配置(仅在显式指定 permission_mode 时生效) - 清除 serve 模式下的调试 timing 日志
This commit is contained in:
parent
daa2dce7b9
commit
ef151d84ba
|
|
@ -53,7 +53,6 @@ export class EventBus {
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(event: string, data: unknown): void {
|
publish(event: string, data: unknown): void {
|
||||||
const tPublish = event === 'session.status' ? Date.now() : 0
|
|
||||||
const entry: BusEvent = { event, data }
|
const entry: BusEvent = { event, data }
|
||||||
this.buffer.push(entry)
|
this.buffer.push(entry)
|
||||||
if (this.buffer.length > this.bufferSize) {
|
if (this.buffer.length > this.bufferSize) {
|
||||||
|
|
@ -91,13 +90,6 @@ export class EventBus {
|
||||||
this.clients.delete(id)
|
this.clients.delete(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event === 'session.status' && tPublish > 0) {
|
|
||||||
const dataObj = data as Record<string, unknown>
|
|
||||||
const sessionId = dataObj?.sessionID ?? dataObj?.session_id
|
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${sessionId}] eventBus.publish('session.status') → ${clientCount} SSE clients, +${Date.now() - tPublish}ms in publish loop\n`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publishSessionEvent(
|
publishSessionEvent(
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,16 @@ import {
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
readSessionTodos,
|
readSessionTodos,
|
||||||
readSessionDiff,
|
readSessionDiff,
|
||||||
|
type SessionMessage,
|
||||||
} from '../transcriptReader.js'
|
} from '../transcriptReader.js'
|
||||||
|
|
||||||
|
function dedupeMessages(disk: SessionMessage[], memory: SessionMessage[]): SessionMessage[] {
|
||||||
|
if (disk.length === 0) return memory
|
||||||
|
const diskUuids = new Set(disk.map(m => m.uuid))
|
||||||
|
const tail = memory.filter(m => !diskUuids.has(m.uuid))
|
||||||
|
return [...disk, ...tail]
|
||||||
|
}
|
||||||
|
|
||||||
export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
||||||
return new Hono()
|
return new Hono()
|
||||||
.get('/session/:sessionID/message', async c => {
|
.get('/session/:sessionID/message', async c => {
|
||||||
|
|
@ -18,7 +26,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
||||||
const before = url.searchParams.get('before') ?? undefined
|
const before = url.searchParams.get('before') ?? undefined
|
||||||
const includeSystem = url.searchParams.get('include_system') === 'true'
|
const includeSystem = url.searchParams.get('include_system') === 'true'
|
||||||
|
|
||||||
const { messages, nextCursor } = await readSessionMessages({
|
const { messages: diskMessages, nextCursor } = await readSessionMessages({
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
cwd: handle?.spawnCwd ?? handle?.cwd,
|
cwd: handle?.spawnCwd ?? handle?.cwd,
|
||||||
limit,
|
limit,
|
||||||
|
|
@ -26,6 +34,10 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
||||||
includeSystem,
|
includeSystem,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const messages = handle && handle.messageBuffer.length > 0
|
||||||
|
? dedupeMessages(diskMessages, [...handle.messageBuffer])
|
||||||
|
: diskMessages
|
||||||
|
|
||||||
if (nextCursor) {
|
if (nextCursor) {
|
||||||
c.header('Link', `<${url.pathname}?limit=${limit}&before=${nextCursor}>; rel="prev"`)
|
c.header('Link', `<${url.pathname}?limit=${limit}&before=${nextCursor}>; rel="prev"`)
|
||||||
c.header('X-Next-Cursor', nextCursor)
|
c.header('X-Next-Cursor', nextCursor)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
} from '../errors.js'
|
} from '../errors.js'
|
||||||
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
|
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
|
||||||
import { canonicalizePath } from '../../utils/sessionStoragePortable.js'
|
import { canonicalizePath } from '../../utils/sessionStoragePortable.js'
|
||||||
|
import { buildHooksForPermissionMode, mergeHooks } from '../../utils/permissions/permissionModeHooks.js'
|
||||||
|
import { permissionModeFromString } from '../../utils/permissions/PermissionMode.js'
|
||||||
|
|
||||||
/** 从磁盘历史记录查找 session 的原始 cwd,用于 resume 时恢复正确的工作目录 */
|
/** 从磁盘历史记录查找 session 的原始 cwd,用于 resume 时恢复正确的工作目录 */
|
||||||
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
|
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
|
||||||
|
|
@ -124,6 +126,26 @@ export function createSessionRoutes(
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
eventBus: EventBus,
|
eventBus: EventBus,
|
||||||
): Hono {
|
): Hono {
|
||||||
|
async function getOrResumeSession(id: string): Promise<import('../sessionHandle.js').SessionHandle> {
|
||||||
|
const handle = sessionManager.getSession(id)
|
||||||
|
if (handle) return handle
|
||||||
|
|
||||||
|
const cwd = await getHistoryCwd(id)
|
||||||
|
try {
|
||||||
|
const resumed = await sessionManager.createSession({
|
||||||
|
cwd,
|
||||||
|
sessionId: id,
|
||||||
|
resumeSessionId: id,
|
||||||
|
execPath: process.execPath,
|
||||||
|
scriptArgs: getScriptArgsForChild(),
|
||||||
|
})
|
||||||
|
await resumed.waitReady(30000)
|
||||||
|
return resumed
|
||||||
|
} catch (err) {
|
||||||
|
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Hono()
|
return new Hono()
|
||||||
.post('/session', async c => {
|
.post('/session', async c => {
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
|
|
@ -155,6 +177,13 @@ export function createSessionRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let hooks = body.hooks
|
||||||
|
if (permissionMode) {
|
||||||
|
const resolvedMode = permissionModeFromString(permissionMode)
|
||||||
|
const autoHooks = buildHooksForPermissionMode(resolvedMode)
|
||||||
|
hooks = mergeHooks(autoHooks, body.hooks)
|
||||||
|
}
|
||||||
|
|
||||||
const handle = await sessionManager.createSession({
|
const handle = await sessionManager.createSession({
|
||||||
cwd,
|
cwd,
|
||||||
sessionId: body.session_id,
|
sessionId: body.session_id,
|
||||||
|
|
@ -163,7 +192,7 @@ export function createSessionRoutes(
|
||||||
systemPrompt: body.system_prompt,
|
systemPrompt: body.system_prompt,
|
||||||
resumeSessionId: body.resume_session_id,
|
resumeSessionId: body.resume_session_id,
|
||||||
resumeSessionAt: body.resume_session_at,
|
resumeSessionAt: body.resume_session_at,
|
||||||
hooks: body.hooks,
|
hooks,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
scriptArgs: getScriptArgsForChild(),
|
scriptArgs: getScriptArgsForChild(),
|
||||||
})
|
})
|
||||||
|
|
@ -360,22 +389,7 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/prompt', async c => {
|
.post('/session/:sessionID/prompt', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
let handle = sessionManager.getSession(id)
|
const handle = await getOrResumeSession(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<{
|
const body = await c.req.json<{
|
||||||
content?: string
|
content?: string
|
||||||
|
|
@ -450,82 +464,35 @@ export function createSessionRoutes(
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
status: { type: 'busy' },
|
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)
|
let handle = sessionManager.getSession(id)
|
||||||
|
|
||||||
// 历史 session 不在内存中,自动恢复
|
|
||||||
if (!handle) {
|
if (!handle) {
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${id}] historical session, starting recovery...\n`,
|
|
||||||
)
|
|
||||||
const cwd = await getHistoryCwd(id)
|
|
||||||
try {
|
try {
|
||||||
handle = await sessionManager.createSession({
|
handle = await getOrResumeSession(id)
|
||||||
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) {
|
} catch (err) {
|
||||||
// Recovery failed — clear the busy status we emitted earlier
|
|
||||||
eventBus.publish('session.status', {
|
eventBus.publish('session.status', {
|
||||||
sessionID: id,
|
sessionID: id,
|
||||||
status: { type: 'idle' },
|
status: { type: 'idle' },
|
||||||
})
|
})
|
||||||
throw sessionError(err instanceof Error ? err.message : 'Failed to resume session')
|
throw err
|
||||||
}
|
}
|
||||||
} else if (handle.prompting) {
|
} else if (handle.prompting) {
|
||||||
throw conflict('session is already processing a prompt')
|
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 () => {
|
void (async () => {
|
||||||
try {
|
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') {
|
if (handle.status !== 'running') {
|
||||||
await handle.waitReady()
|
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) {
|
if (body.model?.modelID) {
|
||||||
const tModelStart = Date.now()
|
|
||||||
try { await handle.setModel(body.model.modelID) } catch {}
|
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`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const effectiveAgent = body.agent ?? (agentParts[0] as Record<string, unknown> | undefined)?.name as string | undefined
|
const effectiveAgent = body.agent ?? (agentParts[0] as Record<string, unknown> | undefined)?.name as string | undefined
|
||||||
if (effectiveAgent && effectiveAgent !== handle.agent) {
|
if (effectiveAgent && effectiveAgent !== handle.agent) {
|
||||||
try { await handle.setAgent(effectiveAgent) } catch {}
|
try { await handle.setAgent(effectiveAgent) } catch {}
|
||||||
}
|
}
|
||||||
const tPromptCall = Date.now()
|
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${id}] calling prompt() at +${tPromptCall - tEntry}ms\n`,
|
|
||||||
)
|
|
||||||
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
|
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
|
||||||
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -536,51 +503,96 @@ export function createSessionRoutes(
|
||||||
.post('/session/:sessionID/abort', async c => {
|
.post('/session/:sessionID/abort', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = sessionManager.getSession(id)
|
||||||
if (!handle) throw notFound('session not found')
|
if (!handle) return c.json({ aborted: true })
|
||||||
await handle.abort()
|
await handle.abort()
|
||||||
return c.json({ aborted: true })
|
return c.json({ aborted: true })
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/shell', async c => {
|
.post('/session/:sessionID/shell', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrResumeSession(id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{ command: string }>()
|
const body = await c.req.json<{ command: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
return ssePrompt(handle, id, body.command, c)
|
return ssePrompt(handle, id, body.command, c)
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/command', async c => {
|
.post('/session/:sessionID/command', async c => {
|
||||||
|
const t0 = Date.now()
|
||||||
const id = c.req.param('sessionID')
|
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 }>()
|
const body = await c.req.json<{ command: string; arguments?: string; agent?: string; model?: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
return ssePrompt(handle, id, body.command, c)
|
|
||||||
|
const cmdParts = '/' + [body.command, body.arguments].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
eventBus.publish('session.status', {
|
||||||
|
sessionID: id,
|
||||||
|
status: { type: 'busy' },
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle = sessionManager.getSession(id)
|
||||||
|
|
||||||
|
if (!handle) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
handle = await getOrResumeSession(id)
|
||||||
|
|
||||||
|
if (body.agent && body.agent !== handle.agent) {
|
||||||
|
try { await handle.setAgent(body.agent) } catch {}
|
||||||
|
}
|
||||||
|
if (body.model) {
|
||||||
|
const modelID = body.model.includes('/') ? body.model.split('/').slice(1).join('/') : body.model
|
||||||
|
try { await handle.setModel(modelID) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.prompt(cmdParts).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
eventBus.publish('session.status', {
|
||||||
|
sessionID: id,
|
||||||
|
status: { type: 'idle' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.agent && body.agent !== handle.agent) {
|
||||||
|
try { await handle.setAgent(body.agent) } catch {}
|
||||||
|
}
|
||||||
|
if (body.model) {
|
||||||
|
const modelID = body.model.includes('/') ? body.model.split('/').slice(1).join('/') : body.model
|
||||||
|
try { await handle.setModel(modelID) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.prompt(cmdParts).catch(() => {})
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200)
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/command_async', async c => {
|
.post('/session/:sessionID/command_async', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrResumeSession(id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{ command: string }>()
|
const body = await c.req.json<{ command: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
if (handle.prompting) throw conflict('session is already processing a prompt')
|
if (handle.prompting) throw conflict('session is already processing a prompt')
|
||||||
|
|
||||||
|
eventBus.publish('session.status', {
|
||||||
|
sessionID: id,
|
||||||
|
status: { type: 'busy' },
|
||||||
|
})
|
||||||
|
|
||||||
handle.prompt(body.command).catch(() => {})
|
handle.prompt(body.command).catch(() => {})
|
||||||
|
|
||||||
return new Response(null, { status: 204 })
|
return c.json({ ok: true }, 200)
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/revert', async c => {
|
.post('/session/:sessionID/revert', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrResumeSession(id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
return c.json(handle.getInfo())
|
return c.json(handle.getInfo())
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/summarize', async c => {
|
.post('/session/:sessionID/summarize', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrResumeSession(id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { INIT_TIMEOUT_MS, getScriptArgsForChild, loadChildSpawnPrefix } from './
|
||||||
import { ControlChannel } from './sessionControlChannel.js'
|
import { ControlChannel } from './sessionControlChannel.js'
|
||||||
import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.js'
|
import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.js'
|
||||||
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
||||||
|
import type { DisposableChildProcess } from '../utils/killOnDrop.js'
|
||||||
|
import type { SessionMessage } from './transcriptReader.js'
|
||||||
|
|
||||||
export type { InitData, PendingPermission, PendingQuestion }
|
export type { InitData, PendingPermission, PendingQuestion }
|
||||||
export { getScriptArgsForChild }
|
export { getScriptArgsForChild }
|
||||||
|
|
@ -32,7 +34,7 @@ export type SessionHandleOptions = {
|
||||||
onInit?: (data: InitData) => void
|
onInit?: (data: InitData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionHandle {
|
export class SessionHandle implements DisposableChildProcess {
|
||||||
readonly sessionId: string
|
readonly sessionId: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
private _spawnCwd: string | undefined
|
private _spawnCwd: string | undefined
|
||||||
|
|
@ -68,6 +70,7 @@ export class SessionHandle {
|
||||||
private _controlChannel = new ControlChannel()
|
private _controlChannel = new ControlChannel()
|
||||||
private _titleGenerationAttempted = false
|
private _titleGenerationAttempted = false
|
||||||
private _firstPromptContent?: string
|
private _firstPromptContent?: string
|
||||||
|
private _messageBuffer: SessionMessage[] = []
|
||||||
|
|
||||||
get status(): SessionState {
|
get status(): SessionState {
|
||||||
return this._status
|
return this._status
|
||||||
|
|
@ -108,6 +111,17 @@ export class SessionHandle {
|
||||||
get prompting(): boolean {
|
get prompting(): boolean {
|
||||||
return this._prompting
|
return this._prompting
|
||||||
}
|
}
|
||||||
|
get messageBuffer(): ReadonlyArray<SessionMessage> {
|
||||||
|
return this._messageBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
pushMessage(msg: SessionMessage): void {
|
||||||
|
if (this._messageBuffer.length > 0) {
|
||||||
|
const last = this._messageBuffer[this._messageBuffer.length - 1]
|
||||||
|
if (last.uuid === msg.uuid) return
|
||||||
|
}
|
||||||
|
this._messageBuffer.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
get lastMessageUuid(): string | null {
|
get lastMessageUuid(): string | null {
|
||||||
return this._lastMessageUuid
|
return this._lastMessageUuid
|
||||||
|
|
@ -210,10 +224,12 @@ export class SessionHandle {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// registerKillOnDrop(this, this.child)
|
||||||
this.setupStdout()
|
this.setupStdout()
|
||||||
this.setupStderr()
|
this.setupStderr()
|
||||||
|
|
||||||
this.child!.on('close', (code, signal) => {
|
this.child!.on('close', (code, signal) => {
|
||||||
|
// unregisterKillOnDrop(this)
|
||||||
if (this._status !== 'stopped') {
|
if (this._status !== 'stopped') {
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this._busyStatus = { type: 'idle' }
|
this._busyStatus = { type: 'idle' }
|
||||||
|
|
@ -441,6 +457,7 @@ export class SessionHandle {
|
||||||
emitBusyStatus: () => this.emitBusyStatus(),
|
emitBusyStatus: () => this.emitBusyStatus(),
|
||||||
emitMessage: (msg) => this.emitMessage(msg),
|
emitMessage: (msg) => this.emitMessage(msg),
|
||||||
writeStdin: (data) => this.writeStdin(data),
|
writeStdin: (data) => this.writeStdin(data),
|
||||||
|
pushBufferMessage: (msg) => this.pushMessage(msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,19 +502,11 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(content: string, opts?: { parts?: Array<Record<string, unknown>>; messageID?: string }): Promise<{ done: boolean; error?: string }> {
|
async prompt(content: string, opts?: { parts?: Array<Record<string, unknown>>; messageID?: string }): Promise<{ done: boolean; error?: string }> {
|
||||||
const tEnter = Date.now()
|
|
||||||
if (this._status === 'stopped') {
|
if (this._status === 'stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is stopped`,
|
`Session ${this.sessionId} is stopped`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (this._status !== 'running') {
|
|
||||||
const tWaitStart = Date.now()
|
|
||||||
await this.waitReady()
|
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${this.sessionId}] prompt() waitReady took ${Date.now() - tWaitStart}ms\n`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (this._prompting) {
|
if (this._prompting) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is already processing a prompt`,
|
`Session ${this.sessionId} is already processing a prompt`,
|
||||||
|
|
@ -505,12 +514,7 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
this._prompting = true
|
this._prompting = true
|
||||||
|
|
||||||
// Busy status was already emitted from the route handler (immediate).
|
|
||||||
// Only emit here if this prompt() was called directly (not via prompt_async).
|
|
||||||
const alreadyBusy = this._busyStatus?.type === 'busy'
|
const alreadyBusy = this._busyStatus?.type === 'busy'
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${this.sessionId}] prompt() entry, alreadyBusy=${alreadyBusy}, +0ms from prompt() call\n`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!alreadyBusy) {
|
if (!alreadyBusy) {
|
||||||
this._busyStatus = { type: 'busy' }
|
this._busyStatus = { type: 'busy' }
|
||||||
|
|
@ -538,6 +542,15 @@ export class SessionHandle {
|
||||||
provider_id: this._providerId ?? '',
|
provider_id: this._providerId ?? '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.pushMessage({
|
||||||
|
uuid,
|
||||||
|
type: 'user',
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
parent_uuid: null,
|
||||||
|
})
|
||||||
|
|
||||||
this.writeStdin(userMsg)
|
this.writeStdin(userMsg)
|
||||||
|
|
||||||
if (!this._title && !this._titleGenerationAttempted) {
|
if (!this._title && !this._titleGenerationAttempted) {
|
||||||
|
|
@ -695,6 +708,10 @@ export class SessionHandle {
|
||||||
this._controlChannel.rejectAll(new Error('Session force killed'))
|
this._controlChannel.rejectAll(new Error('Session force killed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Symbol.dispose](): void {
|
||||||
|
this.forceKill()
|
||||||
|
}
|
||||||
|
|
||||||
getPendingPermissions(): PendingPermission[] {
|
getPendingPermissions(): PendingPermission[] {
|
||||||
return [...this.pendingPermissions.values()]
|
return [...this.pendingPermissions.values()]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { jsonStringify } from '../utils/slowOperations.js'
|
import { jsonStringify } from '../utils/slowOperations.js'
|
||||||
import type { ControlChannel } from './sessionControlChannel.js'
|
import type { ControlChannel } from './sessionControlChannel.js'
|
||||||
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
||||||
|
import type { SessionMessage } from './transcriptReader.js'
|
||||||
|
|
||||||
export type StdoutMessage = {
|
export type StdoutMessage = {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -42,6 +43,7 @@ export type MessageRouterCtx = {
|
||||||
emitBusyStatus(): void
|
emitBusyStatus(): void
|
||||||
emitMessage(msg: StdoutMessage): void
|
emitMessage(msg: StdoutMessage): void
|
||||||
writeStdin(data: string): void
|
writeStdin(data: string): void
|
||||||
|
pushBufferMessage(msg: SessionMessage): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
|
@ -132,6 +134,16 @@ function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void
|
||||||
enriched = { ...enriched, provider_id: ctx.getProviderId() }
|
enriched = { ...enriched, provider_id: ctx.getProviderId() }
|
||||||
}
|
}
|
||||||
ctx.emitEvent('message', enriched)
|
ctx.emitEvent('message', enriched)
|
||||||
|
if (msg.uuid) {
|
||||||
|
ctx.pushBufferMessage({
|
||||||
|
uuid: msg.uuid as string,
|
||||||
|
type: 'assistant',
|
||||||
|
role: 'assistant',
|
||||||
|
content: msg.message ?? msg.content ?? '',
|
||||||
|
timestamp: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now(),
|
||||||
|
parent_uuid: (msg.parentUuid as string) ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
|
@ -153,9 +165,6 @@ function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
||||||
ctx.setBusyStatus({ type: 'idle' })
|
ctx.setBusyStatus({ type: 'idle' })
|
||||||
ctx.emitBusyStatus()
|
ctx.emitBusyStatus()
|
||||||
process.stderr.write(
|
|
||||||
`[serve:timing:${ctx.sessionId}] result → idle emitted\n`,
|
|
||||||
)
|
|
||||||
ctx.emitEvent('result', msg)
|
ctx.emitEvent('result', msg)
|
||||||
ctx.resolvePrompt({ done: true })
|
ctx.resolvePrompt({ done: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
src/utils/killOnDrop.ts
Normal file
94
src/utils/killOnDrop.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* kill_on_drop: Ensures child processes are killed when their handle is GC'd.
|
||||||
|
*
|
||||||
|
* Inspired by Rust's tokio `kill_on_drop(true)` pattern. Uses FinalizationRegistry
|
||||||
|
* to guarantee cleanup even when explicit kill() is missed (exception, bug, etc.).
|
||||||
|
*
|
||||||
|
* Two usage modes:
|
||||||
|
*
|
||||||
|
* 1. Class-based: implement `DisposableChildProcess` on your handle class, then
|
||||||
|
* call `registerKillOnDrop(this, this.child)`. When `this` is GC'd without
|
||||||
|
* `[Symbol.dispose]()` being called, the child is killed.
|
||||||
|
*
|
||||||
|
* 2. Scoped: use `killOnDrop(child)` with `using` keyword for block-scoped cleanup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChildProcess } from 'child_process'
|
||||||
|
import treeKill from 'tree-kill'
|
||||||
|
|
||||||
|
export interface DisposableChildProcess {
|
||||||
|
[Symbol.dispose](): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveEntries = new WeakMap<DisposableChildProcess, { pid: number | undefined; killed: boolean }>()
|
||||||
|
|
||||||
|
const registry = new FinalizationRegistry<{ pid: number | undefined; killed: boolean }>((entry) => {
|
||||||
|
if (entry.pid && !entry.killed) {
|
||||||
|
try {
|
||||||
|
treeKill(entry.pid, 'SIGKILL')
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a child process for automatic cleanup when the owner handle is GC'd.
|
||||||
|
*
|
||||||
|
* Call this after spawning. The child's 'exit' event auto-unregisters to
|
||||||
|
* avoid unnecessary finalization work.
|
||||||
|
*/
|
||||||
|
export function registerKillOnDrop(
|
||||||
|
owner: DisposableChildProcess,
|
||||||
|
child: ChildProcess,
|
||||||
|
): void {
|
||||||
|
const entry = { pid: child.pid, killed: child.killed }
|
||||||
|
registry.register(owner, entry, owner)
|
||||||
|
liveEntries.set(owner, entry)
|
||||||
|
|
||||||
|
child.once('exit', () => {
|
||||||
|
entry.killed = true
|
||||||
|
unregisterKillOnDrop(owner)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a handle from kill_on_drop. Called automatically when the child
|
||||||
|
* exits, or manually when you've explicitly killed the process.
|
||||||
|
*/
|
||||||
|
export function unregisterKillOnDrop(owner: DisposableChildProcess): void {
|
||||||
|
registry.unregister(owner)
|
||||||
|
liveEntries.delete(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped child process wrapper using `using` keyword.
|
||||||
|
* Guarantees kill on scope exit (normal or exception).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* using guard = killOnDrop(spawn(...))
|
||||||
|
* // ... use process ...
|
||||||
|
* // automatically killed when scope exits
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function killOnDrop(child: ChildProcess): DisposableChildProcess {
|
||||||
|
let disposed = false
|
||||||
|
const wrapper: DisposableChildProcess = {
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
if (disposed) return
|
||||||
|
disposed = true
|
||||||
|
if (child.pid && !child.killed) {
|
||||||
|
try {
|
||||||
|
treeKill(child.pid, 'SIGKILL')
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
child.once('exit', () => {
|
||||||
|
disposed = true
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
163
src/utils/permissions/permissionModeHooks.ts
Normal file
163
src/utils/permissions/permissionModeHooks.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* Permission mode → hooks auto-configuration.
|
||||||
|
*
|
||||||
|
* Inspired by vibe-kanban's structured PermissionMode + Hooks mapping.
|
||||||
|
* Generates the `hooks` configuration for the CLI `initialize` control request
|
||||||
|
* based on the selected permission mode.
|
||||||
|
*
|
||||||
|
* Mode → Hook mapping:
|
||||||
|
*
|
||||||
|
* | Mode | Hook behavior |
|
||||||
|
* |-------------------|-------------------------------------------------------------------|
|
||||||
|
* | plan | ExitPlanMode/AskUserQuestion → ask; everything else → auto-allow |
|
||||||
|
* | default (approvals) | Read-only tools → auto-allow; rest → ask |
|
||||||
|
* | acceptEdits | All file ops → auto-allow; dangerous tools → ask |
|
||||||
|
* | bypassPermissions | No hooks needed (all permissions skipped) |
|
||||||
|
* | auto | No hooks (classifier handles decisions) |
|
||||||
|
* | dontAsk | No hooks (ask→deny conversion happens in permission pipeline) |
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PermissionMode } from '../../types/permissions.js'
|
||||||
|
|
||||||
|
type HookMatcher = {
|
||||||
|
tool_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookAction = {
|
||||||
|
type: 'allow' | 'deny' | 'ask'
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookConfig = {
|
||||||
|
matcher: HookMatcher
|
||||||
|
action: HookAction
|
||||||
|
}
|
||||||
|
|
||||||
|
type HooksConfig = {
|
||||||
|
PreToolUse?: HookConfig[]
|
||||||
|
PostToolUse?: HookConfig[]
|
||||||
|
Stop?: HookConfig[]
|
||||||
|
Notification?: Array<{ type: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_ONLY_TOOLS = [
|
||||||
|
'Read',
|
||||||
|
'Glob',
|
||||||
|
'Grep',
|
||||||
|
'ToolSearch',
|
||||||
|
'LSP',
|
||||||
|
'TaskGet',
|
||||||
|
'TaskList',
|
||||||
|
'WebFetch',
|
||||||
|
'WebSearch',
|
||||||
|
'McpGet',
|
||||||
|
'McpList',
|
||||||
|
]
|
||||||
|
|
||||||
|
const FILE_OPERATION_TOOLS = [
|
||||||
|
'Edit',
|
||||||
|
'Write',
|
||||||
|
'NotebookEdit',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DANGEROUS_TOOLS = [
|
||||||
|
'Bash',
|
||||||
|
'PowerShell',
|
||||||
|
]
|
||||||
|
|
||||||
|
const PLAN_APPROVAL_TOOLS = [
|
||||||
|
'ExitPlanMode',
|
||||||
|
'AskUserQuestion',
|
||||||
|
]
|
||||||
|
|
||||||
|
function allowHook(toolName: string): HookConfig {
|
||||||
|
return { matcher: { tool_name: toolName }, action: { type: 'allow' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function askHook(toolName: string): HookConfig {
|
||||||
|
return { matcher: { tool_name: toolName }, action: { type: 'ask' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build hooks configuration for a given permission mode.
|
||||||
|
* Returns the hooks object to pass in the CLI `initialize` control request.
|
||||||
|
* Returns `undefined` when no hooks are needed (bypass, auto, dontAsk).
|
||||||
|
*/
|
||||||
|
export function buildHooksForPermissionMode(mode: PermissionMode): HooksConfig | undefined {
|
||||||
|
switch (mode) {
|
||||||
|
case 'plan': {
|
||||||
|
const preToolUse: HookConfig[] = []
|
||||||
|
for (const tool of READ_ONLY_TOOLS) {
|
||||||
|
preToolUse.push(allowHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of FILE_OPERATION_TOOLS) {
|
||||||
|
preToolUse.push(allowHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of DANGEROUS_TOOLS) {
|
||||||
|
preToolUse.push(askHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of PLAN_APPROVAL_TOOLS) {
|
||||||
|
preToolUse.push(askHook(tool))
|
||||||
|
}
|
||||||
|
return { PreToolUse: preToolUse }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'default': {
|
||||||
|
const preToolUse: HookConfig[] = []
|
||||||
|
for (const tool of READ_ONLY_TOOLS) {
|
||||||
|
preToolUse.push(allowHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of FILE_OPERATION_TOOLS) {
|
||||||
|
preToolUse.push(askHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of DANGEROUS_TOOLS) {
|
||||||
|
preToolUse.push(askHook(tool))
|
||||||
|
}
|
||||||
|
return { PreToolUse: preToolUse }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'acceptEdits': {
|
||||||
|
const preToolUse: HookConfig[] = []
|
||||||
|
for (const tool of READ_ONLY_TOOLS) {
|
||||||
|
preToolUse.push(allowHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of FILE_OPERATION_TOOLS) {
|
||||||
|
preToolUse.push(allowHook(tool))
|
||||||
|
}
|
||||||
|
for (const tool of DANGEROUS_TOOLS) {
|
||||||
|
preToolUse.push(askHook(tool))
|
||||||
|
}
|
||||||
|
return { PreToolUse: preToolUse }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bypassPermissions':
|
||||||
|
case 'auto':
|
||||||
|
case 'dontAsk':
|
||||||
|
case 'bubble':
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge auto-generated hooks with user-supplied hooks.
|
||||||
|
* User hooks take precedence (appended after auto-generated, CLI processes
|
||||||
|
* them in order — first match wins in most implementations).
|
||||||
|
*/
|
||||||
|
export function mergeHooks(
|
||||||
|
autoHooks: HooksConfig | undefined,
|
||||||
|
userHooks: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!autoHooks && !userHooks) return undefined
|
||||||
|
if (!autoHooks) return userHooks
|
||||||
|
if (!userHooks) return autoHooks as unknown as Record<string, unknown>
|
||||||
|
|
||||||
|
const merged: Record<string, unknown> = { ...userHooks }
|
||||||
|
for (const [key, value] of Object.entries(autoHooks)) {
|
||||||
|
const existing = merged[key]
|
||||||
|
if (Array.isArray(existing) && Array.isArray(value)) {
|
||||||
|
merged[key] = [...value, ...existing]
|
||||||
|
} else if (!(key in merged)) {
|
||||||
|
merged[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user