cc-haha/src/query/stopHooks.ts
程序员阿江(Relakkes) f08aee351d Merge remote-tracking branch 'origin/main'
# Conflicts:
#	src/query/stopHooks.ts
2026-07-01 20:25:30 +08:00

592 lines
22 KiB
TypeScript

import { feature } from 'bun:bundle'
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
import { getSessionId } from '../bootstrap/state.js'
import { isExtractModeActive } from '../memdir/paths.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import type { ToolUseContext } from '../Tool.js'
import {
ensureThreadGoalHookFromTranscript,
isGoalPromptHookCommand,
} from '../goals/goalState.js'
import type { HookResult } from '../types/hooks.js'
import type { HookProgress } from '../types/hooks.js'
import type {
AssistantMessage,
Message,
RequestStartEvent,
StopHookInfo,
StreamEvent,
TombstoneMessage,
ToolUseSummaryMessage,
} from '../types/message.js'
import { createAttachmentMessage } from '../utils/attachments.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js'
import {
executeStopHooks,
executeTaskCompletedHooks,
executeTeammateIdleHooks,
getStopHookMessage,
getTaskCompletedHookMessage,
getTeammateIdleHookMessage,
} from '../utils/hooks.js'
import {
createStopHookSummaryMessage,
createCommandInputMessage,
createSystemMessage,
createUserInterruptionMessage,
createUserMessage,
} from '../utils/messages.js'
import type { SystemPrompt } from '../utils/systemPromptType.js'
import { getTaskListId, listTasks } from '../utils/tasks.js'
import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null
const jobClassifierModule = feature('TEMPLATES')
? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import type { QuerySource } from '../constants/querySource.js'
import { executeAutoDream } from '../services/autoDream/autoDream.js'
import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js'
import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js'
import {
createCacheSafeParams,
saveCacheSafeParams,
} from '../utils/forkedAgent.js'
type StopHookResult = {
blockingErrors: Message[]
preventContinuation: boolean
}
export function shouldLetGoalPromptHookContinue(
result: Pick<HookResult, 'blockingError' | 'preventContinuation'>,
): boolean {
return Boolean(
result.preventContinuation &&
isGoalPromptHookCommand(result.blockingError?.command),
)
}
export function formatGoalContinuationStatusOutput(reason: string): string {
const normalizedReason = reason
.replace(/^Prompt hook condition was not met:\s*/i, '')
.replace(/[<>&]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 240)
return normalizedReason
? `Goal continuing: ${normalizedReason}`
: 'Goal continuing: more work is required'
}
export async function* handleStopHooks(
messagesForQuery: Message[],
assistantMessages: AssistantMessage[],
systemPrompt: SystemPrompt,
userContext: { [k: string]: string },
systemContext: { [k: string]: string },
toolUseContext: ToolUseContext,
querySource: QuerySource,
stopHookActive?: boolean,
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
StopHookResult
> {
const hookStartTime = Date.now()
const stopHookContext: REPLHookContext = {
messages: [...messagesForQuery, ...assistantMessages],
systemPrompt,
userContext,
systemContext,
toolUseContext,
querySource,
}
// Only save params for main session queries — subagents must not overwrite.
// Outside the prompt-suggestion gate: the REPL /btw command and the
// side_question SDK control_request both read this snapshot, and neither
// depends on prompt suggestions being enabled.
if (querySource === 'repl_main_thread' || querySource === 'sdk') {
saveCacheSafeParams(createCacheSafeParams(stopHookContext))
}
// Template job classification: when running as a dispatched job, classify
// state after each turn. Gate on repl_main_thread so background forks
// (extract-memories, auto-dream) don't pollute the timeline with their own
// assistant messages. Await the classifier so state.json is written before
// the turn returns — otherwise `claude list` shows stale state for the gap.
// Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the
// require()-gated jobs/ import pattern above; spawn.test.ts asserts the
// string matches.
if (
feature('TEMPLATES') &&
process.env.CLAUDE_JOB_DIR &&
querySource.startsWith('repl_main_thread') &&
!toolUseContext.agentId
) {
// Full turn history — assistantMessages resets each queryLoop iteration,
// so tool calls from earlier iterations (Agent spawn, then summary) need
// messagesForQuery to be visible in the tool-call summary.
const turnAssistantMessages = stopHookContext.messages.filter(
(m): m is AssistantMessage => m.type === 'assistant',
)
const p = jobClassifierModule!
.classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages)
.catch(err => {
logForDebugging(`[job] classifier error: ${errorMessage(err)}`, {
level: 'error',
})
})
await Promise.race([
p,
// eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit
new Promise<void>(r => setTimeout(r, 60_000).unref()),
])
}
// --bare / SIMPLE: skip background bookkeeping (prompt suggestion,
// memory extraction, auto-dream). Scripted -p calls don't want auto-memory
// or forked agents contending for resources during shutdown.
if (!isBareMode()) {
// Inline env check for dead code elimination in external builds
if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) {
void executePromptSuggestion(stopHookContext)
}
if (
feature('EXTRACT_MEMORIES') &&
!toolUseContext.agentId &&
isExtractModeActive()
) {
// Fire-and-forget in both interactive and non-interactive. For -p/SDK,
// print.ts drains the in-flight promise after flushing the response
// but before gracefulShutdownSync (see drainPendingExtraction).
void extractMemoriesModule!.executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage,
)
}
if (!toolUseContext.agentId) {
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
}
}
// chicago MCP: auto-unhide + lock release at turn end.
// Main thread only — the CU lock is a process-wide module-level variable,
// so a subagent's stopHooks releasing it leaves the main thread's cleanup
// seeing isLockHeldLocally()===false → no exit notification, and unhides
// mid-turn. Subagents don't start CU sessions so this is a pure skip.
if (!toolUseContext.agentId) {
try {
const { cleanupComputerUseAfterTurn } = await import(
'../utils/computerUse/cleanup.js'
)
await cleanupComputerUseAfterTurn(toolUseContext)
} catch {
// Failures are silent — this is dogfooding cleanup, not critical path
}
}
try {
const blockingErrors = []
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
if (!toolUseContext.agentId) {
ensureThreadGoalHookFromTranscript(
toolUseContext,
getSessionId(),
[...messagesForQuery, ...assistantMessages],
)
}
const generator = executeStopHooks(
permissionMode,
toolUseContext.abortController.signal,
undefined,
stopHookActive ?? false,
toolUseContext.agentId,
toolUseContext,
[...messagesForQuery, ...assistantMessages],
toolUseContext.agentType,
)
// Consume all progress messages and get blocking errors
let stopHookToolUseID = ''
let hookCount = 0
let preventedContinuation = false
let stopReason = ''
let hasOutput = false
const hookErrors: string[] = []
const hookInfos: StopHookInfo[] = []
let goalCompleted = false
let goalContinuationReason: string | null = null
// Goal hook's preventContinuation and blockingError arrive as separate
// generator yields — preventContinuation comes first, but blockingError.command
// (which identifies it as a goal hook) arrives later. Defer the
// preventContinuation decision until we've seen all results and can
// cross-reference with blockingError.command.
let pendingPreventContinuation: {
stopReason: string
toolUseID: string
} | null = null
// Track whether any blockingError came from a goal hook, so we can
// resolve the pending preventContinuation correctly after the loop.
let goalBlockingErrorSeen = false
for await (const result of generator) {
if (result.message) {
yield result.message
// Track toolUseID from progress messages and count hooks
if (result.message.type === 'progress' && result.message.toolUseID) {
stopHookToolUseID = result.message.toolUseID
hookCount++
// Extract hook command and prompt text from progress data
const progressData = result.message.data as HookProgress
if (progressData.command) {
hookInfos.push({
command: progressData.command,
promptText: progressData.promptText,
})
}
}
// Track errors and output from attachments
if (result.message.type === 'attachment') {
const attachment = result.message.attachment
if (
'hookEvent' in attachment &&
(attachment.hookEvent === 'Stop' ||
attachment.hookEvent === 'SubagentStop')
) {
if (attachment.type === 'hook_non_blocking_error') {
hookErrors.push(
attachment.stderr || `Exit code ${attachment.exitCode}`,
)
// Non-blocking errors always have output
hasOutput = true
} else if (attachment.type === 'hook_error_during_execution') {
hookErrors.push(attachment.content)
hasOutput = true
} else if (attachment.type === 'hook_success') {
if (isGoalPromptHookCommand(attachment.command)) {
goalCompleted = true
}
// Check if successful hook produced any stdout/stderr
if (
(attachment.stdout && attachment.stdout.trim()) ||
(attachment.stderr && attachment.stderr.trim())
) {
hasOutput = true
}
}
// Extract per-hook duration for timing visibility.
// Hooks run in parallel; match by command + first unassigned entry.
if ('durationMs' in attachment && 'command' in attachment) {
const info = hookInfos.find(
i =>
i.command === attachment.command &&
i.durationMs === undefined,
)
if (info) {
info.durationMs = attachment.durationMs
}
}
}
}
}
if (result.blockingError) {
const isGoalHook = isGoalPromptHookCommand(result.blockingError.command)
if (isGoalHook) {
goalContinuationReason ??= result.blockingError.blockingError
// If this blockingError is from a goal hook AND we have a pending
// preventContinuation, the goal hook's intent is block-and-continue
// (not prevent-and-stop). Mark it so we can resolve after the loop.
if (pendingPreventContinuation) {
goalBlockingErrorSeen = true
}
}
const userMessage = createUserMessage({
content: getStopHookMessage(result.blockingError),
isMeta: true, // Hide from UI (shown in summary message instead)
})
blockingErrors.push(userMessage)
yield userMessage
hasOutput = true
// Add to hookErrors so it appears in the summary
hookErrors.push(result.blockingError.blockingError)
}
// Check if hook wants to prevent continuation
if (result.preventContinuation) {
// If blockingError.command is already available in this same result,
// we can decide immediately using shouldLetGoalPromptHookContinue.
if (shouldLetGoalPromptHookContinue(result)) {
// Goal hook wants to block-and-continue — don't prevent.
// The blockingError (in this or a later yield) drives loop continuation.
} else if (result.blockingError?.command) {
// Has a blockingError.command but it's NOT a goal hook → prevent immediately
preventedContinuation = true
stopReason = result.stopReason || 'Stop hook prevented continuation'
// Create attachment to track the stopped continuation (for structured data)
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: stopReason,
hookName: 'Stop',
toolUseID: stopHookToolUseID,
hookEvent: 'Stop',
})
} else {
// No blockingError yet — defer the decision. The goal hook's
// blockingError.command may arrive in a later yield, and we need
// it to distinguish goal-hook preventContinuation (block-and-continue)
// from regular preventContinuation (prevent-and-stop).
pendingPreventContinuation = {
stopReason: result.stopReason || 'Stop hook prevented continuation',
toolUseID: stopHookToolUseID,
}
}
}
// Check if we were aborted during hook execution
if (toolUseContext.abortController.signal.aborted) {
logEvent('tengu_pre_stop_hooks_cancelled', {
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
})
yield createUserInterruptionMessage({
toolUse: false,
})
return { blockingErrors: [], preventContinuation: true }
}
}
// Resolve any pending preventContinuation after collecting all results.
// If a goal hook's blockingError was seen, the preventContinuation was
// the goal hook's signal — it wants to block-and-continue, not stop.
// The blockingError will drive the query loop continuation via the
// blockingErrors return path.
if (pendingPreventContinuation && goalBlockingErrorSeen) {
// Don't set preventedContinuation — the blockingErrors will drive
// loop continuation in query.ts.
pendingPreventContinuation = null
} else if (pendingPreventContinuation) {
// No goal hook blockingError was found → this preventContinuation
// is from a regular hook → actually prevent and stop the session.
preventedContinuation = true
stopReason = pendingPreventContinuation.stopReason
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: stopReason,
hookName: 'Stop',
toolUseID: pendingPreventContinuation.toolUseID,
hookEvent: 'Stop',
})
pendingPreventContinuation = null
}
// Create summary system message if hooks ran
if (hookCount > 0) {
yield createStopHookSummaryMessage(
hookCount,
hookInfos,
hookErrors,
preventedContinuation,
stopReason,
hasOutput,
'suggestion',
stopHookToolUseID,
)
// Send notification about errors (shown in verbose/transcript mode via ctrl+o)
if (hookErrors.length > 0) {
const expandShortcut = getShortcutDisplay(
'app:toggleTranscript',
'Global',
'ctrl+o',
)
toolUseContext.addNotification?.({
key: 'stop-hook-error',
text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`,
priority: 'immediate',
})
}
}
if (goalCompleted) {
yield createCommandInputMessage(
'<local-command-stdout>Goal marked complete.</local-command-stdout>',
)
}
if (goalContinuationReason) {
yield createCommandInputMessage(
`<local-command-stdout>${formatGoalContinuationStatusOutput(goalContinuationReason)}</local-command-stdout>`,
)
}
if (preventedContinuation) {
return { blockingErrors: [], preventContinuation: true }
}
// Collect blocking errors from stop hooks
if (blockingErrors.length > 0) {
return { blockingErrors, preventContinuation: false }
}
// After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate
if (isTeammate()) {
const teammateName = getAgentName() ?? ''
const teamName = getTeamName() ?? ''
const teammateBlockingErrors: Message[] = []
let teammatePreventedContinuation = false
let teammateStopReason: string | undefined
// Each hook executor generates its own toolUseID — capture from progress
// messages (same pattern as stopHookToolUseID at L142), not the Stop ID.
let teammateHookToolUseID = ''
// Run TaskCompleted hooks for any in-progress tasks owned by this teammate
const taskListId = getTaskListId()
const tasks = await listTasks(taskListId)
const inProgressTasks = tasks.filter(
t => t.status === 'in_progress' && t.owner === teammateName,
)
for (const task of inProgressTasks) {
const taskCompletedGenerator = executeTaskCompletedHooks(
task.id,
task.subject,
task.description,
teammateName,
teamName,
permissionMode,
toolUseContext.abortController.signal,
undefined,
toolUseContext,
)
for await (const result of taskCompletedGenerator) {
if (result.message) {
if (
result.message.type === 'progress' &&
result.message.toolUseID
) {
teammateHookToolUseID = result.message.toolUseID
}
yield result.message
}
if (result.blockingError) {
const userMessage = createUserMessage({
content: getTaskCompletedHookMessage(result.blockingError),
isMeta: true,
})
teammateBlockingErrors.push(userMessage)
yield userMessage
}
// Match Stop hook behavior: allow preventContinuation/stopReason
if (result.preventContinuation) {
teammatePreventedContinuation = true
teammateStopReason =
result.stopReason || 'TaskCompleted hook prevented continuation'
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: teammateStopReason,
hookName: 'TaskCompleted',
toolUseID: teammateHookToolUseID,
hookEvent: 'TaskCompleted',
})
}
if (toolUseContext.abortController.signal.aborted) {
return { blockingErrors: [], preventContinuation: true }
}
}
}
// Run TeammateIdle hooks
const teammateIdleGenerator = executeTeammateIdleHooks(
teammateName,
teamName,
permissionMode,
toolUseContext.abortController.signal,
)
for await (const result of teammateIdleGenerator) {
if (result.message) {
if (result.message.type === 'progress' && result.message.toolUseID) {
teammateHookToolUseID = result.message.toolUseID
}
yield result.message
}
if (result.blockingError) {
const userMessage = createUserMessage({
content: getTeammateIdleHookMessage(result.blockingError),
isMeta: true,
})
teammateBlockingErrors.push(userMessage)
yield userMessage
}
// Match Stop hook behavior: allow preventContinuation/stopReason
if (result.preventContinuation) {
teammatePreventedContinuation = true
teammateStopReason =
result.stopReason || 'TeammateIdle hook prevented continuation'
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: teammateStopReason,
hookName: 'TeammateIdle',
toolUseID: teammateHookToolUseID,
hookEvent: 'TeammateIdle',
})
}
if (toolUseContext.abortController.signal.aborted) {
return { blockingErrors: [], preventContinuation: true }
}
}
if (teammatePreventedContinuation) {
return { blockingErrors: [], preventContinuation: true }
}
if (teammateBlockingErrors.length > 0) {
return {
blockingErrors: teammateBlockingErrors,
preventContinuation: false,
}
}
}
return { blockingErrors: [], preventContinuation: false }
} catch (error) {
const durationMs = Date.now() - hookStartTime
logEvent('tengu_stop_hook_error', {
duration: durationMs,
queryChainId: toolUseContext.queryTracking
?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryDepth: toolUseContext.queryTracking?.depth,
})
// Yield a system message that is not visible to the model for the user
// to debug their hook.
yield createSystemMessage(
`Stop hook failed: ${errorMessage(error)}`,
'warning',
)
return { blockingErrors: [], preventContinuation: false }
}
}