mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
feat(hooks): add hook management and session timeline features
- Add hook quick templates component with configurable templates - Refactor NativeSessionPanel to use new SessionTimeline component - Add OpenCode session parser for parsing OpenCode CLI sessions - Enhance API with session-related endpoints - Add locale translations for hooks and native session features - Update hook commands and routes for better hook management
This commit is contained in:
@@ -559,30 +559,82 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
}
|
||||
|
||||
// API: Get Native Session Content
|
||||
// Supports: ?id=<executionId> (existing), ?path=<filepath>&tool=<tool> (new direct path query)
|
||||
if (pathname === '/api/cli/native-session') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const executionId = url.searchParams.get('id');
|
||||
const filePath = url.searchParams.get('filePath'); // New: direct file path
|
||||
const toolParam = url.searchParams.get('tool') || 'auto'; // New: tool type for path query
|
||||
const format = url.searchParams.get('format') || 'json';
|
||||
|
||||
if (!executionId) {
|
||||
// Priority: filePath > id (backward compatible)
|
||||
if (!executionId && !filePath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Execution ID is required' }));
|
||||
res.end(JSON.stringify({ error: 'Either execution ID (id) or file path (filePath) is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (format === 'text') {
|
||||
result = await getFormattedNativeConversation(projectPath, executionId, {
|
||||
includeThoughts: url.searchParams.get('thoughts') === 'true',
|
||||
includeToolCalls: url.searchParams.get('tools') === 'true',
|
||||
includeTokens: url.searchParams.get('tokens') === 'true'
|
||||
});
|
||||
} else if (format === 'pairs') {
|
||||
const enriched = await getEnrichedConversation(projectPath, executionId);
|
||||
result = enriched?.merged || null;
|
||||
|
||||
// Direct file path query (new)
|
||||
if (filePath) {
|
||||
const { parseSessionFile } = await import('../../tools/session-content-parser.js');
|
||||
|
||||
// Determine tool type
|
||||
let tool = toolParam;
|
||||
if (tool === 'auto') {
|
||||
// Auto-detect tool from file path
|
||||
if (filePath.includes('.claude') as boolean || filePath.includes('claude-session')) {
|
||||
tool = 'claude';
|
||||
} else if (filePath.includes('.opencode') as boolean || filePath.includes('opencode')) {
|
||||
tool = 'opencode';
|
||||
} else if (filePath.includes('.codex') as boolean || filePath.includes('rollout-')) {
|
||||
tool = 'codex';
|
||||
} else if (filePath.includes('.qwen') as boolean) {
|
||||
tool = 'qwen';
|
||||
} else if (filePath.includes('.gemini') as boolean) {
|
||||
tool = 'gemini';
|
||||
} else {
|
||||
// Default to claude for unknown paths
|
||||
tool = 'claude';
|
||||
}
|
||||
}
|
||||
|
||||
const session = parseSessionFile(filePath, tool);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Native session not found at path: ' + filePath }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (format === 'text') {
|
||||
const { formatConversation } = await import('../../tools/session-content-parser.js');
|
||||
result = formatConversation(session, {
|
||||
includeThoughts: url.searchParams.get('thoughts') === 'true',
|
||||
includeToolCalls: url.searchParams.get('tools') === 'true',
|
||||
includeTokens: url.searchParams.get('tokens') === 'true'
|
||||
});
|
||||
} else if (format === 'pairs') {
|
||||
const { extractConversationPairs } = await import('../../tools/session-content-parser.js');
|
||||
result = extractConversationPairs(session);
|
||||
} else {
|
||||
result = session;
|
||||
}
|
||||
} else {
|
||||
result = await getNativeSessionContent(projectPath, executionId);
|
||||
// Existing: query by execution ID
|
||||
if (format === 'text') {
|
||||
result = await getFormattedNativeConversation(projectPath, executionId!, {
|
||||
includeThoughts: url.searchParams.get('thoughts') === 'true',
|
||||
includeToolCalls: url.searchParams.get('tools') === 'true',
|
||||
includeTokens: url.searchParams.get('tokens') === 'true'
|
||||
});
|
||||
} else if (format === 'pairs') {
|
||||
const enriched = await getEnrichedConversation(projectPath, executionId!);
|
||||
result = enriched?.merged || null;
|
||||
} else {
|
||||
result = await getNativeSessionContent(projectPath, executionId!);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
@@ -600,6 +652,83 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: List Native Sessions (new endpoint)
|
||||
// Supports: ?tool=<gemini|qwen|codex|claude|opencode> & ?project=<projectPath>
|
||||
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
|
||||
const toolFilter = url.searchParams.get('tool');
|
||||
const projectPath = url.searchParams.get('project') || initialPath;
|
||||
|
||||
try {
|
||||
const {
|
||||
getDiscoverer,
|
||||
getNativeSessions
|
||||
} = await import('../../tools/native-session-discovery.js');
|
||||
|
||||
const sessions: Array<{
|
||||
id: string;
|
||||
tool: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
startTime: string;
|
||||
updatedAt: string;
|
||||
projectHash?: string;
|
||||
}> = [];
|
||||
|
||||
// Define supported tools
|
||||
const supportedTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
|
||||
const toolsToQuery = toolFilter && supportedTools.includes(toolFilter as typeof supportedTools[number])
|
||||
? [toolFilter as typeof supportedTools[number]]
|
||||
: [...supportedTools];
|
||||
|
||||
for (const tool of toolsToQuery) {
|
||||
const discoverer = getDiscoverer(tool);
|
||||
if (!discoverer) continue;
|
||||
|
||||
const nativeSessions = getNativeSessions(tool, {
|
||||
workingDir: projectPath,
|
||||
limit: 100
|
||||
});
|
||||
|
||||
for (const session of nativeSessions) {
|
||||
// Try to extract title from session
|
||||
let title: string | undefined;
|
||||
try {
|
||||
const firstUserMessage = (discoverer as any).extractFirstUserMessage?.(session.filePath);
|
||||
if (firstUserMessage) {
|
||||
// Truncate to first 100 chars as title
|
||||
title = firstUserMessage.substring(0, 100).trim();
|
||||
if (firstUserMessage.length > 100) {
|
||||
title += '...';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors extracting title
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id: session.sessionId,
|
||||
tool: session.tool,
|
||||
path: session.filePath,
|
||||
title,
|
||||
startTime: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
projectHash: session.projectHash
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sessions, count: sessions.length }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get Enriched Conversation
|
||||
if (pathname === '/api/cli/enriched') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - POST /api/hook - Main hook endpoint for Claude Code notifications
|
||||
* - Handles: session-start, context, CLI events, A2UI surfaces
|
||||
* - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output
|
||||
* - GET /api/hook/project-state - Get project guidelines and recent dev history summary
|
||||
* - GET /api/hooks - Get hooks configuration from global and project settings
|
||||
* - POST /api/hooks - Save a hook to settings
|
||||
* - DELETE /api/hooks - Delete a hook from settings
|
||||
@@ -520,6 +521,62 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get project state summary for hook injection
|
||||
if (pathname === '/api/hook/project-state' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '5', 10), 20);
|
||||
|
||||
const result: Record<string, unknown> = { tech: { recent: [] }, guidelines: { constraints: [], recent_learnings: [] } };
|
||||
|
||||
// Read project-tech.json
|
||||
const techPath = join(projectPath, '.workflow', 'project-tech.json');
|
||||
if (existsSync(techPath)) {
|
||||
try {
|
||||
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
|
||||
const allEntries: Array<{ title: string; category: string; date: string }> = [];
|
||||
if (tech.development_index) {
|
||||
for (const [cat, entries] of Object.entries(tech.development_index)) {
|
||||
if (Array.isArray(entries)) {
|
||||
for (const e of entries as Array<{ title?: string; date?: string }>) {
|
||||
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allEntries.sort((a, b) => b.date.localeCompare(a.date));
|
||||
(result.tech as Record<string, unknown>).recent = allEntries.slice(0, limit);
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Read project-guidelines.json
|
||||
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
||||
if (existsSync(guidelinesPath)) {
|
||||
try {
|
||||
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
||||
const g = result.guidelines as Record<string, unknown>;
|
||||
// constraints is Record<string, array> - flatten all categories
|
||||
const allConstraints: string[] = [];
|
||||
if (gl.constraints && typeof gl.constraints === 'object') {
|
||||
for (const entries of Object.values(gl.constraints)) {
|
||||
if (Array.isArray(entries)) {
|
||||
for (const c of entries) {
|
||||
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.constraints = allConstraints.slice(0, limit);
|
||||
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
||||
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
||||
g.recent_learnings = learnings.slice(0, limit).map((l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' }));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get hooks configuration
|
||||
if (pathname === '/api/hooks' && req.method === 'GET') {
|
||||
const projectPathParam = url.searchParams.get('path');
|
||||
|
||||
Reference in New Issue
Block a user