From ef151d84baf823dee5e0c34643e5f711c39f40ff Mon Sep 17 00:00:00 2001 From: DoSun Date: Tue, 12 May 2026 20:37:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=80=9F=E9=89=B4=20vibe-kanba?= =?UTF-8?q?n=20=E4=BC=98=E5=8C=96=E5=AD=90=E8=BF=9B=E7=A8=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=B6=88=E6=81=AF=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加内存消息缓冲区,解决 tab 切换后用户消息丢失问题 - prompt() 去除重复 waitReady(),减少不必要的等待 - 新增 kill_on_drop 工具(FinalizationRegistry),待后续启用 - 新增权限模式 hooks 自动配置(仅在显式指定 permission_mode 时生效) - 清除 serve 模式下的调试 timing 日志 --- src/server/eventBus.ts | 8 - src/server/routes/message.ts | 14 +- src/server/routes/session.ts | 172 ++++++++++--------- src/server/sessionHandle.ts | 45 +++-- src/server/sessionMessageRouter.ts | 15 +- src/utils/killOnDrop.ts | 94 ++++++++++ src/utils/permissions/permissionModeHooks.ts | 163 ++++++++++++++++++ 7 files changed, 405 insertions(+), 106 deletions(-) create mode 100644 src/utils/killOnDrop.ts create mode 100644 src/utils/permissions/permissionModeHooks.ts diff --git a/src/server/eventBus.ts b/src/server/eventBus.ts index e93907c12..9f40aade4 100644 --- a/src/server/eventBus.ts +++ b/src/server/eventBus.ts @@ -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 - 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( diff --git a/src/server/routes/message.ts b/src/server/routes/message.ts index 9b319ef0f..7edaecd3e 100644 --- a/src/server/routes/message.ts +++ b/src/server/routes/message.ts @@ -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) diff --git a/src/server/routes/session.ts b/src/server/routes/session.ts index 28bebc0a8..baf51a361 100644 --- a/src/server/routes/session.ts +++ b/src/server/routes/session.ts @@ -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 { @@ -124,6 +126,26 @@ export function createSessionRoutes( sessionManager: SessionManager, eventBus: EventBus, ): Hono { + async function getOrResumeSession(id: string): Promise { + 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 | 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 }) }) } diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index f3801d6ca..8e6ba3ad9 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -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 { + 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>; 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()] } diff --git a/src/server/sessionMessageRouter.ts b/src/server/sessionMessageRouter.ts index a32ec1f21..1fa2d3abc 100644 --- a/src/server/sessionMessageRouter.ts +++ b/src/server/sessionMessageRouter.ts @@ -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 }) } diff --git a/src/utils/killOnDrop.ts b/src/utils/killOnDrop.ts new file mode 100644 index 000000000..b34e8714f --- /dev/null +++ b/src/utils/killOnDrop.ts @@ -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() + +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 +} diff --git a/src/utils/permissions/permissionModeHooks.ts b/src/utils/permissions/permissionModeHooks.ts new file mode 100644 index 000000000..f4ae8c83b --- /dev/null +++ b/src/utils/permissions/permissionModeHooks.ts @@ -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 | undefined, +): Record | undefined { + if (!autoHooks && !userHooks) return undefined + if (!autoHooks) return userHooks + if (!userHooks) return autoHooks as unknown as Record + + const merged: Record = { ...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 +}