From 1638bbb4f64ac70541b8ad4793b62dcd88b8ed4d Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Tue, 9 Jun 2026 16:20:59 +0800 Subject: [PATCH] feat(hooks): support terminal sequence notifications --- .../acp-integration/session/Session.test.ts | 72 ++++++++ .../src/acp-integration/session/Session.ts | 34 +++- packages/cli/src/serve/httpAcpBridge.test.ts | 45 +++++ packages/cli/src/serve/httpAcpBridge.ts | 43 +++-- .../src/services/notificationService.test.ts | 1 + .../hooks/useAttentionNotifications.test.ts | 1 + .../src/ui/hooks/useAttentionNotifications.ts | 12 +- .../ui/hooks/useTerminalNotification.test.ts | 17 ++ .../src/ui/hooks/useTerminalNotification.ts | 6 + .../cli/src/utils/terminalSequence.test.ts | 170 ++++++++++++++++++ packages/cli/src/utils/terminalSequence.ts | 156 ++++++++++++++++ .../core/src/core/toolHookTriggers.test.ts | 16 ++ packages/core/src/core/toolHookTriggers.ts | 13 +- .../core/src/hooks/hookAggregator.test.ts | 132 ++++++++++++++ packages/core/src/hooks/hookAggregator.ts | 30 +++- packages/core/src/hooks/types.test.ts | 41 +++++ packages/core/src/hooks/types.ts | 3 + 17 files changed, 769 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/utils/terminalSequence.test.ts create mode 100644 packages/cli/src/utils/terminalSequence.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 62f0fc1dc69..c5013558d28 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -2744,6 +2744,78 @@ describe('Session', () => { ); }); + it('emits terminalSequence returned by permission notification hooks over ACP', async () => { + const notificationHookSpy = vi + .spyOn(core, 'fireNotificationHook') + .mockResolvedValue({ terminalSequence: '\x07' }); + const executeSpy = vi.fn().mockResolvedValue({ + llmContent: 'ok', + returnDisplay: 'ok', + }); + const onConfirmSpy = vi.fn().mockResolvedValue(undefined); + const invocation = { + params: { path: '/tmp/file.txt' }, + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + getConfirmationDetails: vi.fn().mockResolvedValue({ + type: 'info', + title: 'Need permission', + prompt: 'Allow?', + onConfirm: onConfirmSpy, + }), + getDescription: vi.fn().mockReturnValue('Inspect file'), + toolLocations: vi.fn().mockReturnValue([]), + execute: executeSpy, + }; + const tool = { + name: 'read_file', + kind: core.Kind.Read, + build: vi.fn().mockReturnValue(invocation), + }; + + mockToolRegistry.getTool.mockReturnValue(tool); + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.DEFAULT); + mockConfig.getPermissionManager = vi.fn().mockReturnValue(null); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false); + mockConfig.getMessageBus = vi.fn().mockReturnValue({}); + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { + id: 'call-terminal-sequence', + name: 'read_file', + args: { path: '/tmp/file.txt' }, + }, + ], + }, + }, + ]), + ); + + try { + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'run tool' }], + }); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + notificationHookSpy.mockRestore(); + } + + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/terminal-sequence', + { + v: 1, + sessionId: 'test-session-id', + terminalSequence: '\x07', + }, + ); + }); + it('allows info confirmation tools in plan mode', async () => { const executeSpy = vi.fn().mockResolvedValue({ llmContent: 'ok', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index effc0ac9a73..b39bd3cdb3a 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2587,7 +2587,7 @@ export class Session implements SessionContext { ); if (hooksEnabled && messageBus) { - void fireNotificationHook( + this.fireNotificationHookWithTerminalSequence( messageBus, `Qwen Code needs your permission to use ${fc.name}`, NotificationType.PermissionPrompt, @@ -3167,4 +3167,36 @@ export class Session implements SessionContext { debugLogger.warn(msg); } } + + /** + * Fire a notification hook and forward any terminalSequence to the ACP + * client as an extNotification. Fire-and-forget — errors are logged at + * debug level. + */ + private fireNotificationHookWithTerminalSequence( + messageBus: MessageBus, + message: string, + notificationType: NotificationType, + title?: string, + ): void { + void fireNotificationHook(messageBus, message, notificationType, title) + .then((hookResult) => { + if (!hookResult.terminalSequence) return; + return this.client.extNotification( + 'qwen/notify/session/terminal-sequence', + { + v: 1, + sessionId: this.sessionId, + terminalSequence: hookResult.terminalSequence, + }, + ); + }) + .catch((err: unknown) => { + debugLogger.debug( + `ACP terminalSequence notification dropped ` + + `(session=${this.sessionId}): ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + }); + } } diff --git a/packages/cli/src/serve/httpAcpBridge.test.ts b/packages/cli/src/serve/httpAcpBridge.test.ts index 4541c670984..90c5f81ce9e 100644 --- a/packages/cli/src/serve/httpAcpBridge.test.ts +++ b/packages/cli/src/serve/httpAcpBridge.test.ts @@ -5077,6 +5077,51 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('publishes terminal_sequence when the child fires terminalSequence notification', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification( + 'qwen/notify/session/terminal-sequence', + { + v: 1, + sessionId: session.sessionId, + terminalSequence: '\x07', + }, + ); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('terminal_sequence'); + expect(collected[0]?.data).toEqual({ terminalSequence: '\x07' }); + + abort.abort(); + await bridge.shutdown(); + }); + it('drops unknown extNotification methods, kinds, and missing sessionIds silently', async () => { let capturedConn: AgentSideConnection | undefined; const factory: ChannelFactory = async () => { diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index 3c54cc05f74..7e8705e854a 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -749,27 +749,32 @@ class BridgeClient implements Client { private readonly inFlightRestoreIds = new Set(); /** - * PR 14b: handle child→bridge ACP `extNotification` calls. Only one - * method is recognized today — `qwen/notify/session/mcp-budget-event` - * — translating the McpClientManager's budget-event payload into a - * session-scoped SSE frame. Unknown methods, unknown event kinds, - * and missing sessionIds are dropped silently for forward-compat - * (a future child can add new notification methods without breaking - * this handler; an older daemon can ignore them cleanly). - * - * Codex review fix #1: when the sessionId IS present but the - * `byId`-resolvable entry is not yet registered (the child fired - * the event during its own `newSession` handler, before - * `connection.newSession` returned to `doSpawn`), buffer the frame - * and replay it on `drainEarlyEvents`. + * Handle child→bridge ACP `extNotification` calls and translate them into + * session-scoped SSE frames. */ async extNotification( method: string, params: Record, ): Promise { - if (method !== 'qwen/notify/session/mcp-budget-event') return; const sessionId = params['sessionId']; if (typeof sessionId !== 'string') return; + + if (method === 'qwen/notify/session/terminal-sequence') { + const { v: _v2, sessionId: _sid2, ...tsRest } = params; + void _v2; + void _sid2; + const terminalSequence = tsRest['terminalSequence']; + if ( + typeof terminalSequence !== 'string' || + terminalSequence.length === 0 + ) { + return; + } + this.publishExtNotification(sessionId, 'terminal_sequence', tsRest); + return; + } + + if (method !== 'qwen/notify/session/mcp-budget-event') return; const kind = params['kind']; const type = kind === 'budget_warning' @@ -787,10 +792,18 @@ class BridgeClient implements Client { void _v; void _sid; void _kind; + this.publishExtNotification(sessionId, type, rest); + } + + private publishExtNotification( + sessionId: string, + type: string, + data: Record, + ): void { const entry = this.resolveEntry(sessionId); const frame: Omit = { type, - data: rest, + data, ...(entry?.activePromptOriginatorClientId ? { originatorClientId: entry.activePromptOriginatorClientId } : {}), diff --git a/packages/cli/src/services/notificationService.test.ts b/packages/cli/src/services/notificationService.test.ts index dfbce64e98c..8fe0aae8017 100644 --- a/packages/cli/src/services/notificationService.test.ts +++ b/packages/cli/src/services/notificationService.test.ts @@ -27,6 +27,7 @@ function createMockTerminal(): TerminalNotification { notifyKitty: vi.fn(), notifyGhostty: vi.fn(), notifyBell: vi.fn(), + writeTerminalSequence: vi.fn(() => true), }; } diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts index 57ad80ac09b..548277f70c2 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -28,6 +28,7 @@ const mockTerminal: TerminalNotification = { notifyKitty: vi.fn(), notifyGhostty: vi.fn(), notifyBell: vi.fn(), + writeTerminalSequence: vi.fn(() => true), }; const mockSettings: LoadedSettings = { diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index 4ab5ace5a18..3ee7775841c 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -118,9 +118,15 @@ export const useAttentionNotifications = ({ 'Qwen Code is waiting for your input', NotificationType.IdlePrompt, 'Waiting for input', - ).catch(() => { - // Silently ignore errors - fireNotificationHook has internal error handling - }); + ) + .then((hookResult) => { + if (hookResult.terminalSequence) { + terminal.writeTerminalSequence(hookResult.terminalSequence); + } + }) + .catch(() => { + // Silently ignore errors - fireNotificationHook has internal error handling + }); } idleNotificationSentRef.current = true; } diff --git a/packages/cli/src/ui/hooks/useTerminalNotification.test.ts b/packages/cli/src/ui/hooks/useTerminalNotification.test.ts index 2cde12971ca..2d8876231b3 100644 --- a/packages/cli/src/ui/hooks/useTerminalNotification.test.ts +++ b/packages/cli/src/ui/hooks/useTerminalNotification.test.ts @@ -57,4 +57,21 @@ describe('buildTerminalNotification', () => { // BEL should NOT be wrapped in DCS passthrough expect(writeRaw.mock.calls[0]![0]).not.toContain('\x1bPtmux'); }); + + it('writeTerminalSequence emits valid OSC sequence', () => { + delete process.env['TMUX']; + delete process.env['STY']; + const terminal = buildTerminalNotification(writeRaw); + const result = terminal.writeTerminalSequence('\x1b]9;hello\x07'); + expect(result).toBe(true); + expect(writeRaw).toHaveBeenCalledTimes(1); + expect(writeRaw.mock.calls[0]![0]).toContain('\x1b]9;hello\x07'); + }); + + it('writeTerminalSequence rejects invalid sequence', () => { + const terminal = buildTerminalNotification(writeRaw); + const result = terminal.writeTerminalSequence('plain text'); + expect(result).toBe(false); + expect(writeRaw).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/hooks/useTerminalNotification.ts b/packages/cli/src/ui/hooks/useTerminalNotification.ts index 7750318970b..61c69b16ba1 100644 --- a/packages/cli/src/ui/hooks/useTerminalNotification.ts +++ b/packages/cli/src/ui/hooks/useTerminalNotification.ts @@ -19,6 +19,7 @@ import { oscKittyNotify, oscGhosttyNotify, } from '../../utils/osc.js'; +import { emitTerminalSequence } from '../../utils/terminalSequence.js'; // ── Types ────────────────────────────────────────────────────────── @@ -29,6 +30,8 @@ export interface TerminalNotification { notifyKitty: (opts: { message: string; title: string; id: number }) => void; notifyGhostty: (opts: { message: string; title: string }) => void; notifyBell: () => void; + /** Validate and emit a hook-provided terminal escape sequence. */ + writeTerminalSequence: (sequence: string) => boolean; } // ── Factory (no React context needed) ────────────────────────────── @@ -59,5 +62,8 @@ export function buildTerminalNotification( // Wrapping would make it opaque DCS payload and lose that fallback. writeRaw(BEL); }, + writeTerminalSequence(sequence: string) { + return emitTerminalSequence(sequence, writeRaw); + }, }; } diff --git a/packages/cli/src/utils/terminalSequence.test.ts b/packages/cli/src/utils/terminalSequence.test.ts new file mode 100644 index 00000000000..edabef23a72 --- /dev/null +++ b/packages/cli/src/utils/terminalSequence.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + parseAllowedTerminalSequences, + emitTerminalSequence, +} from './terminalSequence.js'; + +describe('parseAllowedTerminalSequences', () => { + describe('accepted sequences', () => { + it('accepts bare BEL', () => { + expect(parseAllowedTerminalSequences('\x07')).toEqual(['\x07']); + }); + + it('accepts OSC 0 with BEL terminator', () => { + const seq = '\x1b]0;window title\x07'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 1 with ST terminator', () => { + const seq = '\x1b]1;icon name\x1b\\'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 2 title', () => { + const seq = '\x1b]2;tab title\x07'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 9 notification', () => { + const seq = '\x1b]9;hello world\x07'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 9 with subcommand (progress)', () => { + const seq = '\x1b]9;4;1;50\x07'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 99 Kitty notification', () => { + const seq = '\x1b]99;i=1:d=0:p=title;VGl0bGU=\x1b\\'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts OSC 777 Ghostty notification', () => { + const seq = '\x1b]777;notify;Title;Body\x07'; + expect(parseAllowedTerminalSequences(seq)).toEqual([seq]); + }); + + it('accepts multiple valid sequences concatenated', () => { + const bel = '\x07'; + const osc9 = '\x1b]9;hello\x07'; + const osc0 = '\x1b]0;title\x1b\\'; + const input = bel + osc9 + osc0; + expect(parseAllowedTerminalSequences(input)).toEqual([bel, osc9, osc0]); + }); + }); + + describe('rejected sequences', () => { + it('rejects empty string', () => { + expect(parseAllowedTerminalSequences('')).toBeNull(); + }); + + it('rejects plain text', () => { + expect(parseAllowedTerminalSequences('hello world')).toBeNull(); + }); + + it('rejects CSI color sequence', () => { + expect(parseAllowedTerminalSequences('\x1b[31m')).toBeNull(); + }); + + it('rejects OSC 8 hyperlink', () => { + expect( + parseAllowedTerminalSequences('\x1b]8;;https://example.com\x07'), + ).toBeNull(); + }); + + it('rejects OSC 52 clipboard', () => { + expect( + parseAllowedTerminalSequences('\x1b]52;c;dGVzdA==\x07'), + ).toBeNull(); + }); + + it('rejects OSC 1337', () => { + expect(parseAllowedTerminalSequences('\x1b]1337;SetMark\x07')).toBeNull(); + }); + + it('rejects OSC 4 palette change', () => { + expect( + parseAllowedTerminalSequences('\x1b]4;1;rgb:ff/00/00\x07'), + ).toBeNull(); + }); + + it('rejects unterminated OSC', () => { + expect(parseAllowedTerminalSequences('\x1b]9;hello')).toBeNull(); + }); + + it('rejects OSC with nested ESC that is not ST', () => { + expect( + parseAllowedTerminalSequences('\x1b]9;he\x1b[31mllo\x07'), + ).toBeNull(); + }); + + it('rejects mixed valid and invalid content', () => { + const valid = '\x07'; + const invalid = 'plain text'; + expect(parseAllowedTerminalSequences(valid + invalid)).toBeNull(); + }); + + it('rejects OSC with no numeric code', () => { + expect(parseAllowedTerminalSequences('\x1b];hello\x07')).toBeNull(); + }); + }); +}); + +describe('emitTerminalSequence', () => { + const originalEnv = { ...process.env }; + const writeRaw = vi.fn(); + + afterEach(() => { + writeRaw.mockReset(); + process.env = { ...originalEnv }; + }); + + it('emits bare BEL without multiplexer wrapping', () => { + process.env['TMUX'] = '/tmp/tmux'; + expect(emitTerminalSequence('\x07', writeRaw)).toBe(true); + expect(writeRaw).toHaveBeenCalledTimes(1); + expect(writeRaw).toHaveBeenCalledWith('\x07'); + // BEL must NOT be wrapped in DCS passthrough + expect(writeRaw.mock.calls[0]![0]).not.toContain('\x1bPtmux'); + }); + + it('emits OSC through wrapForMultiplexer under tmux', () => { + process.env['TMUX'] = '/tmp/tmux'; + delete process.env['STY']; + const osc = '\x1b]9;hello\x07'; + expect(emitTerminalSequence(osc, writeRaw)).toBe(true); + expect(writeRaw).toHaveBeenCalledTimes(1); + const written = writeRaw.mock.calls[0]![0] as string; + expect(written).toContain('\x1bPtmux;'); + }); + + it('emits OSC without wrapping outside multiplexer', () => { + delete process.env['TMUX']; + delete process.env['STY']; + const osc = '\x1b]9;hello\x07'; + expect(emitTerminalSequence(osc, writeRaw)).toBe(true); + expect(writeRaw).toHaveBeenCalledWith(osc); + }); + + it('returns false and writes nothing for invalid input', () => { + expect(emitTerminalSequence('plain text', writeRaw)).toBe(false); + expect(writeRaw).not.toHaveBeenCalled(); + }); + + it('emits multiple tokens separately', () => { + delete process.env['TMUX']; + delete process.env['STY']; + const input = '\x07\x1b]0;title\x07'; + expect(emitTerminalSequence(input, writeRaw)).toBe(true); + expect(writeRaw).toHaveBeenCalledTimes(2); + expect(writeRaw.mock.calls[0]![0]).toBe('\x07'); + expect(writeRaw.mock.calls[1]![0]).toBe('\x1b]0;title\x07'); + }); +}); diff --git a/packages/cli/src/utils/terminalSequence.ts b/packages/cli/src/utils/terminalSequence.ts new file mode 100644 index 00000000000..356f1b48a90 --- /dev/null +++ b/packages/cli/src/utils/terminalSequence.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Validates and emits hook-provided terminal escape sequences. + * + * Only an allowlisted subset of OSC codes and bare BEL are accepted. + * Invalid input is rejected entirely — no partial stripping — to + * prevent transforming a malicious sequence into a different valid one. + */ + +import { BEL, wrapForMultiplexer } from './osc.js'; + +const ESC = '\x1b'; +const ST_CHAR = '\\'; + +/** OSC codes that hooks are allowed to emit. */ +const ALLOWED_OSC_CODES = new Set([0, 1, 2, 9, 99, 777]); + +/** + * Parse a `terminalSequence` string into individual validated tokens. + * + * Returns the array of raw sequence strings when the entire input is + * valid, or `null` when any part is invalid. + */ +export function parseAllowedTerminalSequences(input: string): string[] | null { + if (!input) return null; + + const tokens: string[] = []; + let position = 0; + + while (position < input.length) { + if (input[position] === '\x07') { + // Bare BEL + tokens.push('\x07'); + position++; + continue; + } + + if ( + input[position] === ESC && + position + 1 < input.length && + input[position + 1] === ']' + ) { + // OSC sequence: ESC ] ; + const oscResult = parseOscSequence(input, position); + if (!oscResult) return null; + tokens.push(oscResult.raw); + position = oscResult.end; + continue; + } + + // Any other byte at the top level is invalid + return null; + } + + return tokens.length > 0 ? tokens : null; +} + +interface OscParseResult { + raw: string; + end: number; +} + +function parseOscSequence(input: string, start: number): OscParseResult | null { + // start points at ESC, start+1 is ']' + let position = start + 2; + + // Read the numeric OSC code + let codeStr = ''; + while ( + position < input.length && + input[position] >= '0' && + input[position] <= '9' + ) { + codeStr += input[position]; + position++; + } + + if (codeStr.length === 0) return null; + + const oscCode = Number(codeStr); + if (!ALLOWED_OSC_CODES.has(oscCode)) return null; + + // After the code, expect ';' or a terminator + // (OSC 0/1/2 can have just a title with ';') + if (position >= input.length) return null; + + // Read until terminator: BEL or ST (ESC \) + // The ';' after code is part of payload + while (position < input.length) { + const char = input[position]; + + if (char === '\x07') { + // BEL terminator + return { + raw: input.slice(start, position + 1), + end: position + 1, + }; + } + + if ( + char === ESC && + position + 1 < input.length && + input[position + 1] === ST_CHAR + ) { + // ST terminator (ESC \) + return { + raw: input.slice(start, position + 2), + end: position + 2, + }; + } + + // Nested ESC that isn't ST is invalid (except within payload of allowed sequences) + if ( + char === ESC && + (position + 1 >= input.length || input[position + 1] !== ST_CHAR) + ) { + return null; + } + + position++; + } + + // Unterminated — no terminator found + return null; +} + +/** + * Validate and emit a `terminalSequence` string through a raw writer. + * + * BEL is written raw (so tmux bell-action works). + * OSC sequences are wrapped for tmux/screen passthrough. + * + * @returns `true` when the sequence was emitted, `false` when rejected. + */ +export function emitTerminalSequence( + sequence: string, + writeRaw: (data: string) => void, +): boolean { + const tokens = parseAllowedTerminalSequences(sequence); + if (!tokens) return false; + + for (const token of tokens) { + if (token === '\x07') { + writeRaw(BEL); + } else { + writeRaw(wrapForMultiplexer(token)); + } + } + + return true; +} diff --git a/packages/core/src/core/toolHookTriggers.test.ts b/packages/core/src/core/toolHookTriggers.test.ts index cf08e3ba9fe..ea69c38968a 100644 --- a/packages/core/src/core/toolHookTriggers.test.ts +++ b/packages/core/src/core/toolHookTriggers.test.ts @@ -795,6 +795,22 @@ describe('toolHookTriggers', () => { }); }); + it('should return terminal sequence when available', async () => { + const mockMessageBus = createMockMessageBus(); + (mockMessageBus.request as ReturnType).mockResolvedValue({ + success: true, + output: { terminalSequence: '\x07' }, + }); + + const result = await fireNotificationHook( + mockMessageBus, + 'Test notification', + NotificationType.PermissionPrompt, + ); + + expect(result).toEqual({ terminalSequence: '\x07' }); + }); + it('should send correct parameters to MessageBus for permission_prompt', async () => { const mockMessageBus = createMockMessageBus(); (mockMessageBus.request as ReturnType).mockResolvedValue({ diff --git a/packages/core/src/core/toolHookTriggers.ts b/packages/core/src/core/toolHookTriggers.ts index 7745c620c4e..9686160675c 100644 --- a/packages/core/src/core/toolHookTriggers.ts +++ b/packages/core/src/core/toolHookTriggers.ts @@ -442,6 +442,8 @@ export async function firePostToolBatchHook( export interface NotificationHookResult { /** Additional context from the hook */ additionalContext?: string; + /** Terminal escape sequence requested by the hook */ + terminalSequence?: string; } /** @@ -485,11 +487,16 @@ export async function fireNotificationHook( 'Notification', response.output, ); + const result: NotificationHookResult = {}; const additionalContext = notificationOutput.getAdditionalContext(); + if (additionalContext !== undefined) { + result.additionalContext = additionalContext; + } + if (notificationOutput.terminalSequence !== undefined) { + result.terminalSequence = notificationOutput.terminalSequence; + } - return { - additionalContext, - }; + return result; } catch (error) { // Notification hook errors should not affect the notification flow debugLogger.warn( diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts index 427f9a00453..3b5cb70906a 100644 --- a/packages/core/src/hooks/hookAggregator.test.ts +++ b/packages/core/src/hooks/hookAggregator.test.ts @@ -1072,4 +1072,136 @@ describe('HookAggregator', () => { ).toBe('single context'); }); }); + + describe('terminalSequence merging', () => { + it('preserves single terminalSequence in OR-logic events', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Notification, + success: true, + output: { terminalSequence: '\x07' }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.Notification, + ); + expect(result.finalOutput?.terminalSequence).toBe('\x07'); + }); + + it('concatenates terminalSequence from multiple outputs', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'hook1' }, + eventName: HookEventName.PreToolUse, + success: true, + output: { terminalSequence: '\x07' }, + duration: 10, + }, + { + hookConfig: { type: HookType.Command, command: 'hook2' }, + eventName: HookEventName.PreToolUse, + success: true, + output: { terminalSequence: '\x1b]9;hello\x07' }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.terminalSequence).toBe('\x07\x1b]9;hello\x07'); + }); + + it('omits terminalSequence when no outputs have it', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.Stop, + success: true, + output: { continue: true }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults(results, HookEventName.Stop); + expect(result.finalOutput?.terminalSequence).toBeUndefined(); + }); + + it('preserves terminalSequence in simple merge events', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'hook1' }, + eventName: HookEventName.SessionStart, + success: true, + output: { terminalSequence: '\x1b]0;title\x07' }, + duration: 10, + }, + { + hookConfig: { type: HookType.Command, command: 'hook2' }, + eventName: HookEventName.SessionStart, + success: true, + output: { terminalSequence: '\x07' }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.SessionStart, + ); + expect(result.finalOutput?.terminalSequence).toBe('\x1b]0;title\x07\x07'); + }); + + it('preserves terminalSequence in PermissionRequest merge', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'hook1' }, + eventName: HookEventName.PermissionRequest, + success: true, + output: { + terminalSequence: '\x07', + hookSpecificOutput: { + decision: { behavior: 'allow' }, + }, + }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PermissionRequest, + ); + expect(result.finalOutput?.terminalSequence).toBe('\x07'); + }); + + it('does not affect decision fields when terminalSequence is present', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { type: HookType.Command, command: 'hook1' }, + eventName: HookEventName.PreToolUse, + success: true, + output: { + decision: 'block', + reason: 'blocked', + terminalSequence: '\x07', + }, + duration: 10, + }, + ]; + + const result = aggregator.aggregateResults( + results, + HookEventName.PreToolUse, + ); + expect(result.finalOutput?.decision).toBe('block'); + expect(result.finalOutput?.reason).toBe('blocked'); + expect(result.finalOutput?.terminalSequence).toBe('\x07'); + }); + }); }); diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index 5d13e5b25c2..b9ec1430d7a 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -187,6 +187,9 @@ export class HookAggregator { } } + // Concatenate terminal sequences from all outputs + this.mergeTerminalSequences(outputs, merged); + // Set merged decision if (hasBlock) { merged.decision = 'block'; @@ -328,6 +331,8 @@ export class HookAggregator { decision: mergedDecision, }; + this.mergeTerminalSequences(outputs, merged); + return merged; } @@ -341,7 +346,10 @@ export class HookAggregator { for (const output of outputs) { // Collect additionalContext for concatenation this.extractAdditionalContext(output, additionalContexts); - merged = { ...merged, ...output }; + // Exclude terminalSequence from spread — it is concatenated below + const { terminalSequence: _ts, ...rest } = output; + void _ts; + merged = { ...merged, ...rest }; } // Merge additionalContext with concatenation @@ -352,6 +360,9 @@ export class HookAggregator { }; } + // Concatenate all terminalSequence values + this.mergeTerminalSequences(outputs, merged); + return merged; } @@ -383,6 +394,23 @@ export class HookAggregator { } } + /** + * Concatenate terminalSequence values from all outputs into merged. + */ + private mergeTerminalSequences( + outputs: HookOutput[], + merged: HookOutput, + ): void { + const sequences = outputs + .map((o) => o.terminalSequence) + .filter((s): s is string => typeof s === 'string' && s.length > 0); + if (sequences.length > 0) { + merged.terminalSequence = sequences.join(''); + } else { + delete merged.terminalSequence; + } + } + /** * Extract additional context from hook-specific outputs */ diff --git a/packages/core/src/hooks/types.test.ts b/packages/core/src/hooks/types.test.ts index c64d064ae45..9fbf70c58c5 100644 --- a/packages/core/src/hooks/types.test.ts +++ b/packages/core/src/hooks/types.test.ts @@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'; import { MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH, createHookOutput, + DefaultHookOutput, UserPromptExpansionHookOutput, + HookEventName, } from './types.js'; describe('UserPromptSubmit getAdditionalContext', () => { @@ -102,3 +104,42 @@ describe('UserPromptExpansionHookOutput.getAdditionalContext', () => { ); }); }); + +describe('terminalSequence on HookOutput', () => { + it('DefaultHookOutput preserves terminalSequence', () => { + const output = new DefaultHookOutput({ + terminalSequence: '\x07', + }); + expect(output.terminalSequence).toBe('\x07'); + }); + + it('terminalSequence does not affect blocking decision', () => { + const output = new DefaultHookOutput({ + terminalSequence: '\x1b]9;hello\x07', + decision: 'allow', + }); + expect(output.isBlockingDecision()).toBe(false); + expect(output.shouldStopExecution()).toBe(false); + }); + + it('createHookOutput preserves terminalSequence for all event types', () => { + const events = [ + HookEventName.PreToolUse, + HookEventName.PostToolUse, + HookEventName.Notification, + HookEventName.Stop, + HookEventName.PermissionRequest, + ]; + for (const eventName of events) { + const output = createHookOutput(eventName, { + terminalSequence: '\x07', + }); + expect(output.terminalSequence).toBe('\x07'); + } + }); + + it('terminalSequence defaults to undefined', () => { + const output = new DefaultHookOutput({}); + expect(output.terminalSequence).toBeUndefined(); + }); +}); diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 80fdf791764..7d0415ecf89 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -282,6 +282,7 @@ export interface HookOutput { stopReason?: string; suppressOutput?: boolean; systemMessage?: string; + terminalSequence?: string; decision?: HookDecision; reason?: string; hookSpecificOutput?: Record; @@ -337,6 +338,7 @@ export class DefaultHookOutput implements HookOutput { stopReason?: string; suppressOutput?: boolean; systemMessage?: string; + terminalSequence?: string; decision?: HookDecision; reason?: string; hookSpecificOutput?: Record; @@ -346,6 +348,7 @@ export class DefaultHookOutput implements HookOutput { this.stopReason = data.stopReason; this.suppressOutput = data.suppressOutput; this.systemMessage = data.systemMessage; + this.terminalSequence = data.terminalSequence; this.decision = data.decision; this.reason = data.reason; this.hookSpecificOutput = data.hookSpecificOutput;