33 * Licensed under the MIT License. See License.txt in the project root for license information.
44 *--------------------------------------------------------------------------------------------*/
55
6- import type { SDKAssistantMessage , SDKCompactBoundaryMessage , SDKHookResponseMessage , SDKHookStartedMessage , SDKMessage , SDKResultMessage , SDKUserMessage , SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk' ;
6+ import type { SDKAssistantMessage , SDKCompactBoundaryMessage , SDKHookProgressMessage , SDKHookResponseMessage , SDKHookStartedMessage , SDKMessage , SDKResultMessage , SDKUserMessage , SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk' ;
77import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools' ;
88import type Anthropic from '@anthropic-ai/sdk' ;
99import * as l10n from '@vscode/l10n' ;
1010import type * as vscode from 'vscode' ;
11+ import { vBoolean , vLiteral , vObj , vString , type ValidatorType } from '../../../../platform/configuration/common/validator' ;
1112import { ILogService } from '../../../../platform/log/common/logService' ;
12- import { CopilotChatAttr , GenAiAttr , GenAiOperationName , IOTelService , type ISpanHandle , SpanKind , SpanStatusCode , truncateForOTel } from '../../../../platform/otel/common/index' ;
13+ import { CopilotChatAttr , GenAiAttr , GenAiOperationName , IOTelService , SpanKind , SpanStatusCode , truncateForOTel , type ISpanHandle } from '../../../../platform/otel/common/index' ;
1314import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation' ;
14- import { ChatResponseThinkingProgressPart } from '../../../../vscodeTypes' ;
15+ import { ChatResponseThinkingProgressPart , type ChatHookType } from '../../../../vscodeTypes' ;
1516import { ToolName } from '../../../tools/common/toolNames' ;
1617import { IToolsService } from '../../../tools/common/toolsService' ;
1718import { ClaudeToolNames } from './claudeTools' ;
@@ -89,9 +90,7 @@ export const ALL_KNOWN_MESSAGE_KEYS = new Set([
8990 // TODO: Show `system:local_command_output` — has `content` text from local slash commands
9091 'system:local_command_output' ,
9192 'system:hook_started' ,
92- // TODO: Show `system:hook_progress` — has `stdout`, `stderr`, `output` for streaming hook output
9393 'system:hook_progress' ,
94- // TODO: Show `system:hook_response` — has `output`, `stderr`, `outcome` — surface errors to user
9594 'system:hook_response' ,
9695 // TODO: Show `system:task_notification` — has `summary` and `status` for subagent completion
9796 'system:task_notification' ,
@@ -311,13 +310,127 @@ export function handleHookStarted(
311310 state . otelHookSpans . set ( message . hook_id , span ) ;
312311}
313312
313+ // #region Hook JSON output validator
314+
315+ /**
316+ * Validator for structured JSON output from hooks (exit code 0 only).
317+ *
318+ * Hooks can return JSON with these fields:
319+ * - `continue`: if false, stops processing entirely
320+ * - `stopReason`: message shown to user when `continue` is false
321+ * - `systemMessage`: warning shown to user
322+ * - `decision`: "block" to prevent the operation
323+ * - `reason`: explanation when `decision` is "block"
324+ *
325+ * @see https://code.claude.com/docs/en/hooks.md
326+ */
327+ const vHookJsonOutput = vObj ( {
328+ continue : vBoolean ( ) ,
329+ stopReason : vString ( ) ,
330+ systemMessage : vString ( ) ,
331+ decision : vLiteral ( 'block' ) ,
332+ reason : vString ( ) ,
333+ } ) ;
334+
335+ export type HookJsonOutput = ValidatorType < typeof vHookJsonOutput > ;
336+
337+ /**
338+ * Parses JSON output from a hook's stdout.
339+ * Returns the validated fields, or undefined if parsing/validation fails.
340+ * Fields that are missing from the JSON are simply absent from the result.
341+ */
342+ export function parseHookJsonOutput ( stdout : string ) : Partial < HookJsonOutput > | undefined {
343+ let raw : unknown ;
344+ try {
345+ raw = JSON . parse ( stdout ) ;
346+ } catch {
347+ return undefined ;
348+ }
349+
350+ if ( typeof raw !== 'object' || raw === null || Array . isArray ( raw ) ) {
351+ return undefined ;
352+ }
353+
354+ // Use the validator to extract known fields with type safety.
355+ // vObj skips missing optional fields, so partial results are expected.
356+ const result = vHookJsonOutput . validate ( raw ) ;
357+ if ( result . error ) {
358+ // Validation error means some present field had the wrong type —
359+ // extract what we can by validating each field individually.
360+ const obj = raw as Record < string , unknown > ;
361+ const partial : Partial < HookJsonOutput > = { } ;
362+
363+ const continueResult = vBoolean ( ) . validate ( obj [ 'continue' ] ) ;
364+ if ( ! continueResult . error ) {
365+ partial . continue = continueResult . content ;
366+ }
367+ const stopReasonResult = vString ( ) . validate ( obj [ 'stopReason' ] ) ;
368+ if ( ! stopReasonResult . error ) {
369+ partial . stopReason = stopReasonResult . content ;
370+ }
371+ const systemMessageResult = vString ( ) . validate ( obj [ 'systemMessage' ] ) ;
372+ if ( ! systemMessageResult . error ) {
373+ partial . systemMessage = systemMessageResult . content ;
374+ }
375+ const decisionResult = vLiteral ( 'block' ) . validate ( obj [ 'decision' ] ) ;
376+ if ( ! decisionResult . error ) {
377+ partial . decision = decisionResult . content ;
378+ }
379+ const reasonResult = vString ( ) . validate ( obj [ 'reason' ] ) ;
380+ if ( ! reasonResult . error ) {
381+ partial . reason = reasonResult . content ;
382+ }
383+
384+ return Object . keys ( partial ) . length > 0 ? partial : undefined ;
385+ }
386+
387+ return result . content ;
388+ }
389+
390+ // #endregion
391+
392+ /**
393+ * Formats a localized error message for a failed hook.
394+ * @param errorMessage The error message from the hook
395+ * @returns A localized error message string
396+ * @todo use a common function with: https://github.com/microsoft/vscode-copilot-chat/blob/9a9461734da42f28e4e2d0b975ebeae6162e9b4c/src/extension/intents/node/hookResultProcessor.ts#L142
397+ */
398+ function formatHookErrorMessage ( errorMessage : string ) : string {
399+ if ( errorMessage ) {
400+ return l10n . t ( 'A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details. \nError message: {0}' , errorMessage ) ;
401+ }
402+ return l10n . t ( 'A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details.' ) ;
403+ }
404+
405+
406+ export function handleHookProgress (
407+ message : SDKHookProgressMessage ,
408+ accessor : ServicesAccessor ,
409+ request : MessageHandlerRequestContext ,
410+ ) : void {
411+ const logService = accessor . get ( ILogService ) ;
412+ // TODO: can we map these types better
413+ const hookType = message . hook_event as ChatHookType ;
414+ const progressText = message . stdout || message . stderr ;
415+
416+ logService . trace ( `[ClaudeMessageDispatch] Hook progress "${ message . hook_name } " (${ message . hook_event } ): ${ progressText } ` ) ;
417+
418+ if ( progressText ) {
419+ request . stream . hookProgress ( hookType , undefined , progressText ) ;
420+ }
421+ }
422+
314423export function handleHookResponse (
315424 message : SDKHookResponseMessage ,
316425 accessor : ServicesAccessor ,
317426 request : MessageHandlerRequestContext ,
318427 state : MessageHandlerState ,
319428) : void {
320429 const logService = accessor . get ( ILogService ) ;
430+ // TODO: can we map these types better
431+ const hookType = message . hook_event as ChatHookType ;
432+
433+ // #region OTel span
321434 const span = state . otelHookSpans . get ( message . hook_id ) ;
322435 if ( span ) {
323436 if ( message . outcome === 'error' ) {
@@ -336,13 +449,66 @@ export function handleHookResponse(
336449 span . end ( ) ;
337450 state . otelHookSpans . delete ( message . hook_id ) ;
338451 }
452+ // #endregion
339453
340- if ( message . outcome === 'error' ) {
341- logService . warn ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " (${ message . hook_event } ) failed: ${ message . stderr || message . output } ` ) ;
342- request . stream . markdown ( `*${ l10n . t ( 'Hook "{0}" failed' , message . hook_name ) } *\n` ) ;
343- if ( message . stderr ) {
344- request . stream . markdown ( `\`\`\`\n${ message . stderr } \n\`\`\`\n` ) ;
454+ // Cancelled — log only, no user-facing output
455+ if ( message . outcome === 'cancelled' ) {
456+ logService . trace ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " (${ message . hook_event } ) was cancelled` ) ;
457+ return ;
458+ }
459+
460+ // Exit code 2 — blocking error (stderr is the message, JSON ignored)
461+ if ( message . exit_code === 2 ) {
462+ const errorMessage = message . stderr || message . output ;
463+ logService . warn ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " (${ message . hook_event } ) blocking error: ${ errorMessage } ` ) ;
464+ request . stream . hookProgress ( hookType , formatHookErrorMessage ( errorMessage ) ) ;
465+ return ;
466+ }
467+
468+ // Other non-zero exit codes — non-blocking warning
469+ if ( message . exit_code !== undefined && message . exit_code !== 0 ) {
470+ const warningMessage = message . stderr || message . output || ( l10n . t ( 'Exit Code: {0}' , message . exit_code ) ) ;
471+ logService . warn ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " (${ message . hook_event } ) non-blocking error (exit ${ message . exit_code } ): ${ warningMessage } ` ) ;
472+ if ( warningMessage ) {
473+ request . stream . hookProgress ( hookType , undefined , warningMessage ) ;
345474 }
475+ return ;
476+ }
477+
478+ // Outcome 'error' without a specific exit code — treat as blocking error
479+ if ( message . outcome === 'error' ) {
480+ const errorMessage = message . stderr || message . output ;
481+ logService . warn ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " (${ message . hook_event } ) failed: ${ errorMessage } ` ) ;
482+ request . stream . hookProgress ( hookType , formatHookErrorMessage ( errorMessage ) ) ;
483+ return ;
484+ }
485+
486+ // Exit code 0 (or undefined with success outcome) — parse JSON from stdout
487+ if ( ! message . stdout ) {
488+ return ;
489+ }
490+
491+ const parsed = parseHookJsonOutput ( message . stdout ) ;
492+ if ( ! parsed ) {
493+ logService . warn ( `[ClaudeMessageDispatch] Hook "${ message . hook_name } " returned non-JSON output` ) ;
494+ return ;
495+ }
496+
497+ // Handle `decision: "block"` with `reason`
498+ if ( parsed . decision === 'block' ) {
499+ request . stream . hookProgress ( hookType , formatHookErrorMessage ( parsed . reason ?? '' ) ) ;
500+ return ;
501+ }
502+
503+ // Handle `continue: false` with optional `stopReason`
504+ if ( parsed . continue === false ) {
505+ request . stream . hookProgress ( hookType , formatHookErrorMessage ( parsed . stopReason ?? '' ) ) ;
506+ return ;
507+ }
508+
509+ // Handle `systemMessage` — shown as a warning
510+ if ( parsed . systemMessage ) {
511+ request . stream . hookProgress ( hookType , undefined , parsed . systemMessage ) ;
346512 }
347513}
348514
@@ -400,6 +566,10 @@ export function dispatchMessage(
400566 handleHookStarted ( message , accessor , sessionId , state ) ;
401567 return ;
402568 }
569+ if ( message . subtype === 'hook_progress' ) {
570+ handleHookProgress ( message , accessor , request ) ;
571+ return ;
572+ }
403573 if ( message . subtype === 'hook_response' ) {
404574 handleHookResponse ( message , accessor , request , state ) ;
405575 return ;
0 commit comments