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 {
|
||||
const tPublish = event === 'session.status' ? Date.now() : 0
|
||||
const entry: BusEvent = { event, data }
|
||||
this.buffer.push(entry)
|
||||
if (this.buffer.length > this.bufferSize) {
|
||||
|
|
@ -91,13 +90,6 @@ export class EventBus {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,16 @@ import {
|
|||
readSessionMessages,
|
||||
readSessionTodos,
|
||||
readSessionDiff,
|
||||
type SessionMessage,
|
||||
} 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 {
|
||||
return new Hono()
|
||||
.get('/session/:sessionID/message', async c => {
|
||||
|
|
@ -18,7 +26,7 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
|||
const before = url.searchParams.get('before') ?? undefined
|
||||
const includeSystem = url.searchParams.get('include_system') === 'true'
|
||||
|
||||
const { messages, nextCursor } = await readSessionMessages({
|
||||
const { messages: diskMessages, nextCursor } = await readSessionMessages({
|
||||
sessionId: id,
|
||||
cwd: handle?.spawnCwd ?? handle?.cwd,
|
||||
limit,
|
||||
|
|
@ -26,6 +34,10 @@ export function createMessageRoutes(sessionManager: SessionManager): Hono {
|
|||
includeSystem,
|
||||
})
|
||||
|
||||
const messages = handle && handle.messageBuffer.length > 0
|
||||
? dedupeMessages(diskMessages, [...handle.messageBuffer])
|
||||
: diskMessages
|
||||
|
||||
if (nextCursor) {
|
||||
c.header('Link', `<${url.pathname}?limit=${limit}&before=${nextCursor}>; rel="prev"`)
|
||||
c.header('X-Next-Cursor', nextCursor)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from '../errors.js'
|
||||
import { listSessionsImpl } from '../../utils/listSessionsImpl.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 时恢复正确的工作目录 */
|
||||
async function getHistoryCwd(sessionId: string): Promise<string | undefined> {
|
||||
|
|
@ -124,6 +126,26 @@ export function createSessionRoutes(
|
|||
sessionManager: SessionManager,
|
||||
eventBus: EventBus,
|
||||
): 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()
|
||||
.post('/session', async c => {
|
||||
const body = await c.req.json<{
|
||||
|
|
@ -155,6 +177,13 @@ export function createSessionRoutes(
|
|||
}
|
||||
|
||||
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({
|
||||
cwd,
|
||||
sessionId: body.session_id,
|
||||
|
|
@ -163,7 +192,7 @@ export function createSessionRoutes(
|
|||
systemPrompt: body.system_prompt,
|
||||
resumeSessionId: body.resume_session_id,
|
||||
resumeSessionAt: body.resume_session_at,
|
||||
hooks: body.hooks,
|
||||
hooks,
|
||||
execPath: process.execPath,
|
||||
scriptArgs: getScriptArgsForChild(),
|
||||
})
|
||||
|
|
@ -360,22 +389,7 @@ export function createSessionRoutes(
|
|||
})
|
||||
.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 handle = await getOrResumeSession(id)
|
||||
|
||||
const body = await c.req.json<{
|
||||
content?: string
|
||||
|
|
@ -450,82 +464,35 @@ export function createSessionRoutes(
|
|||
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`,
|
||||
)
|
||||
handle = await getOrResumeSession(id)
|
||||
} 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')
|
||||
throw err
|
||||
}
|
||||
} 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`,
|
||||
)
|
||||
}
|
||||
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`,
|
||||
)
|
||||
handle.prompt(content, { parts: body.parts, messageID: body.messageID }).catch(() => {})
|
||||
|
||||
} catch {}
|
||||
|
|
@ -536,51 +503,96 @@ export function createSessionRoutes(
|
|||
.post('/session/:sessionID/abort', async c => {
|
||||
const id = c.req.param('sessionID')
|
||||
const handle = sessionManager.getSession(id)
|
||||
if (!handle) throw notFound('session not found')
|
||||
if (!handle) return c.json({ aborted: true })
|
||||
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 handle = await getOrResumeSession(id)
|
||||
|
||||
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 t0 = Date.now()
|
||||
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')
|
||||
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 => {
|
||||
const id = c.req.param('sessionID')
|
||||
const handle = sessionManager.getSession(id)
|
||||
if (!handle) throw notFound('session not found')
|
||||
const handle = await getOrResumeSession(id)
|
||||
|
||||
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')
|
||||
|
||||
eventBus.publish('session.status', {
|
||||
sessionID: id,
|
||||
status: { type: 'busy' },
|
||||
})
|
||||
|
||||
handle.prompt(body.command).catch(() => {})
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
return c.json({ ok: true }, 200)
|
||||
})
|
||||
.post('/session/:sessionID/revert', async c => {
|
||||
const id = c.req.param('sessionID')
|
||||
const handle = sessionManager.getSession(id)
|
||||
if (!handle) throw notFound('session not found')
|
||||
const handle = await getOrResumeSession(id)
|
||||
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')
|
||||
const handle = await getOrResumeSession(id)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { INIT_TIMEOUT_MS, getScriptArgsForChild, loadChildSpawnPrefix } from './
|
|||
import { ControlChannel } from './sessionControlChannel.js'
|
||||
import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.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 { getScriptArgsForChild }
|
||||
|
|
@ -32,7 +34,7 @@ export type SessionHandleOptions = {
|
|||
onInit?: (data: InitData) => void
|
||||
}
|
||||
|
||||
export class SessionHandle {
|
||||
export class SessionHandle implements DisposableChildProcess {
|
||||
readonly sessionId: string
|
||||
readonly cwd: string
|
||||
private _spawnCwd: string | undefined
|
||||
|
|
@ -68,6 +70,7 @@ export class SessionHandle {
|
|||
private _controlChannel = new ControlChannel()
|
||||
private _titleGenerationAttempted = false
|
||||
private _firstPromptContent?: string
|
||||
private _messageBuffer: SessionMessage[] = []
|
||||
|
||||
get status(): SessionState {
|
||||
return this._status
|
||||
|
|
@ -108,6 +111,17 @@ export class SessionHandle {
|
|||
get prompting(): boolean {
|
||||
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 {
|
||||
return this._lastMessageUuid
|
||||
|
|
@ -210,10 +224,12 @@ export class SessionHandle {
|
|||
windowsHide: true,
|
||||
})
|
||||
|
||||
// registerKillOnDrop(this, this.child)
|
||||
this.setupStdout()
|
||||
this.setupStderr()
|
||||
|
||||
this.child!.on('close', (code, signal) => {
|
||||
// unregisterKillOnDrop(this)
|
||||
if (this._status !== 'stopped') {
|
||||
this._status = 'stopped'
|
||||
this._busyStatus = { type: 'idle' }
|
||||
|
|
@ -441,6 +457,7 @@ export class SessionHandle {
|
|||
emitBusyStatus: () => this.emitBusyStatus(),
|
||||
emitMessage: (msg) => this.emitMessage(msg),
|
||||
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 }> {
|
||||
const tEnter = Date.now()
|
||||
if (this._status === 'stopped') {
|
||||
throw new Error(
|
||||
`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) {
|
||||
throw new Error(
|
||||
`Session ${this.sessionId} is already processing a prompt`,
|
||||
|
|
@ -505,12 +514,7 @@ export class SessionHandle {
|
|||
}
|
||||
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'
|
||||
process.stderr.write(
|
||||
`[serve:timing:${this.sessionId}] prompt() entry, alreadyBusy=${alreadyBusy}, +0ms from prompt() call\n`,
|
||||
)
|
||||
|
||||
if (!alreadyBusy) {
|
||||
this._busyStatus = { type: 'busy' }
|
||||
|
|
@ -538,6 +542,15 @@ export class SessionHandle {
|
|||
provider_id: this._providerId ?? '',
|
||||
})
|
||||
|
||||
this.pushMessage({
|
||||
uuid,
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
parent_uuid: null,
|
||||
})
|
||||
|
||||
this.writeStdin(userMsg)
|
||||
|
||||
if (!this._title && !this._titleGenerationAttempted) {
|
||||
|
|
@ -695,6 +708,10 @@ export class SessionHandle {
|
|||
this._controlChannel.rejectAll(new Error('Session force killed'))
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.forceKill()
|
||||
}
|
||||
|
||||
getPendingPermissions(): PendingPermission[] {
|
||||
return [...this.pendingPermissions.values()]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import type { ControlChannel } from './sessionControlChannel.js'
|
||||
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
||||
import type { SessionMessage } from './transcriptReader.js'
|
||||
|
||||
export type StdoutMessage = {
|
||||
type: string
|
||||
|
|
@ -42,6 +43,7 @@ export type MessageRouterCtx = {
|
|||
emitBusyStatus(): void
|
||||
emitMessage(msg: StdoutMessage): void
|
||||
writeStdin(data: string): void
|
||||
pushBufferMessage(msg: SessionMessage): 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() }
|
||||
}
|
||||
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 {
|
||||
|
|
@ -153,9 +165,6 @@ function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
|||
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
||||
ctx.setBusyStatus({ type: 'idle' })
|
||||
ctx.emitBusyStatus()
|
||||
process.stderr.write(
|
||||
`[serve:timing:${ctx.sessionId}] result → idle emitted\n`,
|
||||
)
|
||||
ctx.emitEvent('result', msg)
|
||||
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