474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { getShortcutDisplay } from '../keybindings/shortcutFormat.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 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,
|
|
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 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 (feature('CHICAGO_MCP') && !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
|
|
|
|
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[] = []
|
|
|
|
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') {
|
|
// 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 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) {
|
|
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',
|
|
})
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
}
|
|
|
|
// 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 (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 }
|
|
}
|
|
}
|