feat(ccw): add session manager tool with auto workspace detection

- Add session_manager tool for workflow session lifecycle management
- Add ccw session CLI command with subcommands:
  - list, init, status, task, stats, delete, read, write, update, archive, mkdir
- Implement auto workspace detection (traverse up to find .workflow)
- Implement auto session location detection (active, archived, lite-plan, lite-fix)
- Add dashboard notifications for tool executions via WebSocket
- Add granular event types (SESSION_CREATED, TASK_UPDATED, etc.)
- Add status_history auto-tracking for task status changes
- Update workflow session commands to document ccw session usage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-10 19:26:53 +08:00
parent df104d6e9b
commit 598bea9b21
11 changed files with 2003 additions and 6 deletions

View File

@@ -60,11 +60,91 @@ function handleNotification(data) {
}
break;
case 'SESSION_CREATED':
case 'SESSION_ARCHIVED':
case 'TASK_UPDATED':
case 'SESSION_UPDATED':
case 'TASK_CREATED':
case 'SUMMARY_WRITTEN':
case 'PLAN_UPDATED':
case 'REVIEW_UPDATED':
case 'CONTENT_WRITTEN':
// Route to state reducer for granular updates
if (typeof handleWorkflowEvent === 'function') {
handleWorkflowEvent({ type, ...payload });
} else {
// Fallback to full refresh if reducer not available
refreshIfNeeded();
}
break;
case 'tool_execution':
// Handle tool execution notifications from CLI
handleToolExecutionNotification(payload);
break;
default:
console.log('[WS] Unknown notification type:', type);
}
}
/**
* Handle tool execution notifications from CLI
* @param {Object} payload - Tool execution payload
*/
function handleToolExecutionNotification(payload) {
const { toolName, status, params, result, error, timestamp } = payload;
// Determine notification type and message
let notifType = 'info';
let message = `Tool: ${toolName}`;
let details = null;
switch (status) {
case 'started':
notifType = 'info';
message = `Executing ${toolName}...`;
if (params) {
// Show truncated params
const paramStr = JSON.stringify(params);
details = paramStr.length > 100 ? paramStr.substring(0, 100) + '...' : paramStr;
}
break;
case 'completed':
notifType = 'success';
message = `${toolName} completed`;
if (result) {
// Show truncated result
if (result._truncated) {
details = result.preview;
} else {
const resultStr = JSON.stringify(result);
details = resultStr.length > 150 ? resultStr.substring(0, 150) + '...' : resultStr;
}
}
break;
case 'failed':
notifType = 'error';
message = `${toolName} failed`;
details = error || 'Unknown error';
break;
default:
notifType = 'info';
message = `${toolName}: ${status}`;
}
// Add to global notifications
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(notifType, message, details, 'CLI');
}
// Log to console
console.log(`[CLI] ${status}: ${toolName}`, payload);
}
// ========== Auto Refresh ==========
function initAutoRefresh() {
// Calculate initial hash

View File

@@ -39,4 +39,153 @@ const taskJsonStore = {};
// ========== Global Notification Queue ==========
// Notification queue visible from any view
let globalNotificationQueue = [];
let isNotificationPanelVisible = false;
let isNotificationPanelVisible = false;
// ========== Event Handler ==========
/**
* Handle granular workflow events from CLI
* @param {Object} event - Event object with type, sessionId, payload
*/
function handleWorkflowEvent(event) {
const { type, payload, sessionId, entityId } = event;
switch(type) {
case 'SESSION_CREATED':
// Add to activeSessions array
if (payload) {
const sessionData = {
session_id: sessionId,
...(payload.metadata || { status: 'planning', created_at: new Date().toISOString() }),
location: 'active'
};
// Add to store
const key = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
sessionDataStore[key] = sessionData;
// Add to workflowData
if (!workflowData.activeSessions) workflowData.activeSessions = [];
workflowData.activeSessions.push(sessionData);
}
break;
case 'SESSION_ARCHIVED':
// Move from active to archived
if (!workflowData.activeSessions) workflowData.activeSessions = [];
if (!workflowData.archivedSessions) workflowData.archivedSessions = [];
const activeIndex = workflowData.activeSessions.findIndex(s => s.session_id === sessionId);
if (activeIndex !== -1) {
const session = workflowData.activeSessions.splice(activeIndex, 1)[0];
session.location = 'archived';
if (payload && payload.metadata) {
Object.assign(session, payload.metadata);
}
workflowData.archivedSessions.push(session);
// Update store
const key = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
sessionDataStore[key] = session;
}
break;
case 'TASK_UPDATED':
// Find task in session and merge payload
const taskSessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const taskSession = sessionDataStore[taskSessionKey];
if (taskSession && taskSession.tasks) {
const task = taskSession.tasks.find(t => t.task_id === entityId);
if (task && payload) {
Object.assign(task, payload);
}
}
break;
case 'SESSION_UPDATED':
// Update session metadata
const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const session = sessionDataStore[sessionKey];
if (session && payload) {
Object.assign(session, payload);
// Update in workflowData arrays
const activeSession = workflowData.activeSessions?.find(s => s.session_id === sessionId);
const archivedSession = workflowData.archivedSessions?.find(s => s.session_id === sessionId);
if (activeSession) Object.assign(activeSession, payload);
if (archivedSession) Object.assign(archivedSession, payload);
}
break;
case 'TASK_CREATED':
// Add new task to session
const tcSessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const tcSession = sessionDataStore[tcSessionKey];
if (tcSession) {
if (!tcSession.tasks) tcSession.tasks = [];
// Check if task already exists (by entityId or task_id in payload)
const taskId = entityId || (payload && payload.task_id);
const existingTask = tcSession.tasks.find(t => t.task_id === taskId);
if (!existingTask && payload) {
tcSession.tasks.push(payload);
}
}
break;
case 'SUMMARY_WRITTEN':
// Update session summary count or mark task as having summary
const swSessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const swSession = sessionDataStore[swSessionKey];
if (swSession) {
if (!swSession.summaries) swSession.summaries = [];
swSession.summaries.push({ task_id: entityId, content: payload });
// Update task status if found
if (swSession.tasks && entityId) {
const task = swSession.tasks.find(t => t.task_id === entityId);
if (task) task.has_summary = true;
}
}
break;
case 'PLAN_UPDATED':
// Update session plan reference
const puSessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const puSession = sessionDataStore[puSessionKey];
if (puSession) {
puSession.has_plan = true;
puSession.plan_updated_at = new Date().toISOString();
}
break;
case 'REVIEW_UPDATED':
// Update session review data
const ruSessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
const ruSession = sessionDataStore[ruSessionKey];
if (ruSession) {
if (!ruSession.review) ruSession.review = { dimensions: [], iterations: [], fixes: [] };
// Track review updates by type based on entityId pattern (prevent duplicates)
if (event.contentType === 'review-dim') {
if (!ruSession.review.dimensions.includes(entityId)) ruSession.review.dimensions.push(entityId);
} else if (event.contentType === 'review-iter') {
if (!ruSession.review.iterations.includes(entityId)) ruSession.review.iterations.push(entityId);
} else if (event.contentType === 'review-fix') {
if (!ruSession.review.fixes.includes(entityId)) ruSession.review.fixes.push(entityId);
}
ruSession.has_review = true;
}
break;
case 'CONTENT_WRITTEN':
// Generic content write - just log for debugging
console.log(`[State] Content written: ${event.contentType} for ${sessionId}`);
break;
}
// Trigger UI updates
if (typeof updateStats === 'function') updateStats();
if (typeof updateBadges === 'function') updateBadges();
if (typeof updateCarousel === 'function') updateCarousel();
// Re-render current view if needed
if (currentView === 'sessions' && typeof renderSessions === 'function') {
renderSessions();
}
}