Add native Codex hooks integration (#2319)
* Add native Codex hooks integration * Address Codex review feedback * Use durable Codex marketplace root * Address Codex file context review feedback * Harden Codex installer review paths * Report Codex legacy cleanup failures * fix: keep MCP manifests in marketplace sync * fix: bundle zod in MCP server * fix: warn on Codex legacy cleanup failure * Fix hook observation readiness timeouts * Address Codex hook review notes * Tighten Codex MCP file context matching * Resolve final Codex review nits * Add Codex marketplace version guidance * Reset worker failure counter on API fallback * Fix Codex cat flag file extraction
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { parse, type ParsedToken } from 'shell-quote';
|
||||
|
||||
const MAX_FILE_PATHS = 10;
|
||||
const READ_COMMANDS = new Set(['cat', 'head', 'tail', 'less', 'more', 'bat', 'view', 'nl', 'tac']);
|
||||
const FLAGS_WITH_VALUES_BY_COMMAND: Record<string, Set<string>> = {
|
||||
head: new Set(['-n', '-c', '--lines', '--bytes']),
|
||||
tail: new Set(['-n', '-c', '--lines', '--bytes']),
|
||||
};
|
||||
const NO_FLAGS_WITH_VALUES = new Set<string>();
|
||||
|
||||
function isOperatorToken(token: ParsedToken): boolean {
|
||||
return typeof token === 'object' && token !== null && 'op' in token;
|
||||
}
|
||||
|
||||
function splitSegments(tokens: ParsedToken[]): string[][] {
|
||||
const segments: string[][] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isOperatorToken(token)) {
|
||||
if (current.length > 0) segments.push(current);
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
if (typeof token === 'string') {
|
||||
current.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
function normalizeCommand(command: unknown): string | null {
|
||||
if (typeof command === 'string') return command;
|
||||
if (Array.isArray(command)) {
|
||||
const parts = command.filter((part): part is string => typeof part === 'string');
|
||||
return parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFlagLike(value: string): boolean {
|
||||
return value.startsWith('-') || value.startsWith('+');
|
||||
}
|
||||
|
||||
function flagsWithValues(command: string): Set<string> {
|
||||
return FLAGS_WITH_VALUES_BY_COMMAND[command] ?? NO_FLAGS_WITH_VALUES;
|
||||
}
|
||||
|
||||
function dropFlagValue(flag: string, command: string): boolean {
|
||||
const valueFlags = flagsWithValues(command);
|
||||
if (valueFlags.has(flag)) return true;
|
||||
const eqIndex = flag.indexOf('=');
|
||||
return eqIndex > 0 && valueFlags.has(flag.slice(0, eqIndex));
|
||||
}
|
||||
|
||||
function isExistingFile(candidate: string, cwd: string): boolean {
|
||||
const absolutePath = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
||||
try {
|
||||
if (!existsSync(absolutePath)) return false;
|
||||
return statSync(absolutePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeAndCap(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
deduped.push(filePath);
|
||||
if (deduped.length >= MAX_FILE_PATHS) break;
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function extractFromBash(toolInput: unknown, cwd: string): string[] {
|
||||
const command = normalizeCommand((toolInput as { command?: unknown } | undefined)?.command);
|
||||
if (!command) return [];
|
||||
|
||||
const tokens = parse(command);
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const segment of splitSegments(tokens)) {
|
||||
const argv0Index = segment.findIndex(token => token && !isFlagLike(token));
|
||||
if (argv0Index === -1) continue;
|
||||
|
||||
const argv0 = path.basename(segment[argv0Index]);
|
||||
if (!READ_COMMANDS.has(argv0)) continue;
|
||||
|
||||
let skipNext = false;
|
||||
for (const token of segment.slice(argv0Index + 1)) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (isFlagLike(token)) {
|
||||
skipNext = dropFlagValue(token, argv0) && !token.includes('=');
|
||||
continue;
|
||||
}
|
||||
if (isExistingFile(token, cwd)) {
|
||||
paths.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndCap(paths);
|
||||
}
|
||||
|
||||
function extractFromMcp(toolName: string, toolInput: unknown, cwd: string): string[] {
|
||||
if (!/^mcp__.+__(read|view|cat)(?:_file|_files)?$/.test(toolName)) return [];
|
||||
|
||||
const input = (toolInput ?? {}) as { path?: unknown; paths?: unknown };
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (typeof input.path === 'string') candidates.push(input.path);
|
||||
if (Array.isArray(input.paths)) {
|
||||
for (const item of input.paths) {
|
||||
if (typeof item === 'string') candidates.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndCap(candidates.filter(candidate => isExistingFile(candidate, cwd)));
|
||||
}
|
||||
|
||||
export function extractFilePaths(toolName: string, toolInput: unknown, cwd: string): string[] {
|
||||
if (toolName === 'Bash') return extractFromBash(toolInput, cwd);
|
||||
if (toolName.startsWith('mcp__')) return extractFromMcp(toolName, toolInput, cwd);
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { HookResult, NormalizedHookInput, PlatformAdapter } from '../types.js';
|
||||
import { AdapterRejectedInput, isValidCwd } from './errors.js';
|
||||
import { extractFilePaths } from './codex-file-context.js';
|
||||
|
||||
type CodexEventName =
|
||||
| 'PreToolUse'
|
||||
| 'PermissionRequest'
|
||||
| 'PostToolUse'
|
||||
| 'SessionStart'
|
||||
| 'UserPromptSubmit'
|
||||
| 'Stop';
|
||||
|
||||
const EVENT_NAMES = new Set<CodexEventName>([
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
]);
|
||||
|
||||
function eventName(value: unknown): CodexEventName | undefined {
|
||||
return typeof value === 'string' && EVENT_NAMES.has(value as CodexEventName)
|
||||
? value as CodexEventName
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stringOrUndefined(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function booleanOrUndefined(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cloneToolInput(toolInput: unknown): unknown {
|
||||
if (toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
|
||||
return { ...(toolInput as Record<string, unknown>) };
|
||||
}
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
function buildBaseOutput(result: HookResult): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {};
|
||||
if (result.continue !== undefined) output.continue = result.continue;
|
||||
if (result.suppressOutput !== undefined) output.suppressOutput = result.suppressOutput;
|
||||
if (result.systemMessage) output.systemMessage = result.systemMessage;
|
||||
if (result.decision === 'block') output.decision = 'block';
|
||||
if (result.reason) output.reason = result.reason;
|
||||
return output;
|
||||
}
|
||||
|
||||
function inferOutputEvent(result: HookResult): CodexEventName | undefined {
|
||||
return eventName(result.hookSpecificOutput?.hookEventName);
|
||||
}
|
||||
|
||||
export const codexAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw): NormalizedHookInput {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const cwd = typeof r.cwd === 'string' ? r.cwd : process.cwd();
|
||||
if (!isValidCwd(cwd)) {
|
||||
throw new AdapterRejectedInput('invalid_cwd');
|
||||
}
|
||||
|
||||
const hookEventName = eventName(r.hook_event_name);
|
||||
const toolName = stringOrUndefined(r.tool_name);
|
||||
let toolInput = cloneToolInput(r.tool_input);
|
||||
|
||||
if (hookEventName === 'PreToolUse' && toolName) {
|
||||
const filePaths = extractFilePaths(toolName, toolInput, cwd);
|
||||
if (filePaths.length > 0 && toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
|
||||
toolInput = { ...(toolInput as Record<string, unknown>), filePaths };
|
||||
}
|
||||
}
|
||||
|
||||
const source = r.source;
|
||||
const sessionSource =
|
||||
source === 'startup' || source === 'resume' || source === 'clear'
|
||||
? source
|
||||
: undefined;
|
||||
const sessionId = stringOrUndefined(r.session_id);
|
||||
if (!sessionId) {
|
||||
throw new AdapterRejectedInput('missing_session_id');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
prompt: stringOrUndefined(r.prompt),
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse: r.tool_response,
|
||||
transcriptPath: stringOrUndefined(r.transcript_path),
|
||||
lastAssistantMessage: stringOrUndefined(r.last_assistant_message),
|
||||
turnId: stringOrUndefined(r.turn_id),
|
||||
stopHookActive: booleanOrUndefined(r.stop_hook_active),
|
||||
permissionMode: stringOrUndefined(r.permission_mode),
|
||||
model: stringOrUndefined(r.model),
|
||||
sessionSource,
|
||||
};
|
||||
},
|
||||
|
||||
formatOutput(result): unknown {
|
||||
const r = result ?? {};
|
||||
const output = buildBaseOutput(r);
|
||||
const hookSpecific = r.hookSpecificOutput;
|
||||
const outputEvent = inferOutputEvent(r);
|
||||
|
||||
if (!hookSpecific || !outputEvent || outputEvent === 'Stop') {
|
||||
return output;
|
||||
}
|
||||
|
||||
const specific: Record<string, unknown> = {
|
||||
hookEventName: outputEvent,
|
||||
};
|
||||
|
||||
if (hookSpecific.additionalContext) {
|
||||
specific.additionalContext = hookSpecific.additionalContext;
|
||||
}
|
||||
|
||||
if (outputEvent === 'PreToolUse') {
|
||||
if (hookSpecific.permissionDecision === 'deny') {
|
||||
specific.permissionDecision = 'deny';
|
||||
if (hookSpecific.permissionDecisionReason) {
|
||||
specific.permissionDecisionReason = hookSpecific.permissionDecisionReason;
|
||||
}
|
||||
}
|
||||
if (hookSpecific.updatedInput) {
|
||||
specific.updatedInput = hookSpecific.updatedInput;
|
||||
}
|
||||
}
|
||||
|
||||
output.hookSpecificOutput = specific;
|
||||
return output;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlatformAdapter } from '../types.js';
|
||||
import { claudeCodeAdapter } from './claude-code.js';
|
||||
import { codexAdapter } from './codex.js';
|
||||
import { cursorAdapter } from './cursor.js';
|
||||
import { geminiCliAdapter } from './gemini-cli.js';
|
||||
import { rawAdapter } from './raw.js';
|
||||
@@ -8,6 +9,7 @@ import { windsurfAdapter } from './windsurf.js';
|
||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
switch (platform) {
|
||||
case 'claude-code': return claudeCodeAdapter;
|
||||
case 'codex': return codexAdapter;
|
||||
case 'cursor': return cursorAdapter;
|
||||
case 'gemini':
|
||||
case 'gemini-cli': return geminiCliAdapter;
|
||||
@@ -17,4 +19,4 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
export { claudeCodeAdapter, codexAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
|
||||
@@ -13,6 +13,7 @@ const FILE_READ_GATE_MIN_BYTES = 1_500;
|
||||
const FETCH_LOOKAHEAD_LIMIT = 40;
|
||||
|
||||
const DISPLAY_LIMIT = 15;
|
||||
const MAX_FILE_CONTEXT_PATHS = 10;
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
decision: '\u2696\uFE0F',
|
||||
@@ -135,86 +136,112 @@ function formatFileTimeline(
|
||||
export const fileContextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
const toolInput = input.toolInput as Record<string, unknown> | undefined;
|
||||
const filePaths = Array.isArray(toolInput?.filePaths)
|
||||
? (toolInput.filePaths as unknown[]).filter((p): p is string => typeof p === 'string').slice(0, MAX_FILE_CONTEXT_PATHS)
|
||||
: [];
|
||||
const filePath = toolInput?.file_path as string | undefined;
|
||||
const candidatePaths = filePaths.length > 0 ? filePaths : (filePath ? [filePath] : []);
|
||||
|
||||
if (!filePath) {
|
||||
if (candidatePaths.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
let fileMtimeMs = 0;
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
fileMtimeMs = stat.mtimeMs;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
if (input.cwd && !shouldTrackProject(input.cwd)) {
|
||||
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const context = getProjectContext(input.cwd);
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
const queryParams = new URLSearchParams({ path: relativePath });
|
||||
if (context.allProjects.length > 0) {
|
||||
queryParams.set('projects', context.allProjects.join(','));
|
||||
}
|
||||
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||
|
||||
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
|
||||
`/api/observations/by-file?${queryParams.toString()}`,
|
||||
'GET',
|
||||
const timelineResults = await Promise.allSettled(
|
||||
candidatePaths.map(candidatePath => buildFileContextTimeline(input, candidatePath))
|
||||
);
|
||||
if (isWorkerFallback(result)) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
if (!result || !Array.isArray((result as any).observations)) {
|
||||
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
const data = result;
|
||||
const timelines: string[] = [];
|
||||
|
||||
if (!data.observations || data.observations.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return { continue: true, suppressOutput: true };
|
||||
timelineResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
if (result.value) timelines.push(result.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger.debug('HOOK', 'File context timeline lookup failed, skipping path', {
|
||||
filePath: candidatePaths[index],
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
});
|
||||
});
|
||||
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
if (timelines.length === 0) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: timeline,
|
||||
additionalContext: timelines.join('\n\n---\n\n'),
|
||||
permissionDecision: 'allow',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function buildFileContextTimeline(input: NormalizedHookInput, filePath: string): Promise<string | null> {
|
||||
let fileMtimeMs = 0;
|
||||
try {
|
||||
const statPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||
const stat = statSync(statPath);
|
||||
if (!stat.isFile() || stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||
return null;
|
||||
}
|
||||
fileMtimeMs = stat.mtimeMs;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.debug('HOOK', 'File stat failed, proceeding with gate', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
const context = getProjectContext(input.cwd);
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||
const queryParams = new URLSearchParams({ path: relativePath });
|
||||
if (context.allProjects.length > 0) {
|
||||
queryParams.set('projects', context.allProjects.join(','));
|
||||
}
|
||||
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||
|
||||
const result = await executeWithWorkerFallback<{ observations: ObservationRow[]; count: number }>(
|
||||
`/api/observations/by-file?${queryParams.toString()}`,
|
||||
'GET',
|
||||
);
|
||||
if (isWorkerFallback(result)) {
|
||||
return null;
|
||||
}
|
||||
if (!result || !Array.isArray((result as any).observations)) {
|
||||
logger.warn('HOOK', 'File context query returned malformed body, skipping', { filePath });
|
||||
return null;
|
||||
}
|
||||
const data = result;
|
||||
|
||||
if (!data.observations || data.observations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileMtimeMs > 0) {
|
||||
const newestObservationMs = Math.max(...data.observations.map(o => o.created_at_epoch));
|
||||
if (fileMtimeMs >= newestObservationMs) {
|
||||
logger.debug('HOOK', 'File modified since last observation, skipping context injection', {
|
||||
filePath: relativePath,
|
||||
fileMtimeMs,
|
||||
newestObservationMs,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||
if (dedupedObservations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatFileTimeline(dedupedObservations, filePath);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ export const summarizeHandler: EventHandler = {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
if (input.stopHookActive === true) {
|
||||
logger.debug('HOOK', 'Skipping summary: Codex Stop hook re-entry detected', {
|
||||
sessionId: input.sessionId,
|
||||
});
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
if (input.agentId) {
|
||||
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
|
||||
sessionId: input.sessionId,
|
||||
@@ -29,22 +36,28 @@ export const summarizeHandler: EventHandler = {
|
||||
logger.warn('HOOK', 'summarize: No sessionId provided, skipping');
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
if (!transcriptPath) {
|
||||
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
let lastAssistantMessage = '';
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
|
||||
if (input.lastAssistantMessage !== undefined) {
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(input.lastAssistantMessage);
|
||||
} else {
|
||||
if (!transcriptPath) {
|
||||
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
try {
|
||||
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
||||
lastAssistantMessage = stripMemoryTagsFromPrompt(lastAssistantMessage);
|
||||
} catch (err) {
|
||||
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
|
||||
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
|
||||
logger.debug('HOOK', 'No assistant message available - skipping summary', {
|
||||
sessionId,
|
||||
transcriptPath
|
||||
});
|
||||
@@ -71,6 +84,6 @@ export const summarizeHandler: EventHandler = {
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Summary request queued, exiting hook');
|
||||
return { continue: true, suppressOutput: true };
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface NormalizedHookInput {
|
||||
toolInput?: unknown;
|
||||
toolResponse?: unknown;
|
||||
transcriptPath?: string;
|
||||
lastAssistantMessage?: string;
|
||||
turnId?: string;
|
||||
stopHookActive?: boolean;
|
||||
permissionMode?: string;
|
||||
model?: string;
|
||||
sessionSource?: 'startup' | 'resume' | 'clear';
|
||||
filePath?: string;
|
||||
edits?: unknown[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -21,9 +27,12 @@ export interface HookResult {
|
||||
hookEventName: string;
|
||||
additionalContext: string;
|
||||
permissionDecision?: 'allow' | 'deny';
|
||||
permissionDecisionReason?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
};
|
||||
systemMessage?: string;
|
||||
decision?: 'block' | 'approve';
|
||||
reason?: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user