mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add Discuss and Explore subagents for dynamic critique and code exploration
- Implement Discuss Subagent for multi-perspective critique with dynamic perspectives. - Create Explore Subagent for shared codebase exploration with centralized caching. - Add tests for CcwToolsMcpCard component to ensure enabled tools are preserved on config save. - Introduce SessionPreviewPanel component for previewing and selecting sessions for Memory V2 extraction. - Develop CommandCreateDialog component for creating/importing commands with import and CLI generate modes.
This commit is contained in:
@@ -7,6 +7,7 @@ import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
|
||||
import { join, dirname, resolve } from 'path';
|
||||
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
|
||||
import { getDiscoverer, getNativeSessions } from './native-session-discovery.js';
|
||||
import { StoragePaths, ensureStorageDir, getProjectId, getCCWHome } from '../config/storage-paths.js';
|
||||
import type { CliOutputUnit } from './cli-output-converter.js';
|
||||
|
||||
@@ -1065,11 +1066,94 @@ export class CliHistoryStore {
|
||||
*/
|
||||
async getNativeSessionContent(ccwId: string): Promise<ParsedSession | null> {
|
||||
const mapping = this.getNativeSessionMapping(ccwId);
|
||||
if (!mapping || !mapping.native_session_path) {
|
||||
return null;
|
||||
if (mapping?.native_session_path) {
|
||||
const parsed = await parseSessionFile(mapping.native_session_path, mapping.tool);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
// If mapping exists but file is missing/invalid, fall through to re-discovery.
|
||||
}
|
||||
|
||||
return parseSessionFile(mapping.native_session_path, mapping.tool);
|
||||
// On-demand discovery/backfill: attempt to locate native session file from conversation metadata.
|
||||
try {
|
||||
const conversation = this.getConversation(ccwId);
|
||||
if (!conversation) return null;
|
||||
|
||||
const tool = conversation.tool;
|
||||
const discoverer = getDiscoverer(tool);
|
||||
if (!discoverer) return null;
|
||||
|
||||
const createdMs = Date.parse(conversation.created_at);
|
||||
const updatedMs = Date.parse(conversation.updated_at || conversation.created_at);
|
||||
const durationMs = conversation.total_duration_ms || 0;
|
||||
|
||||
const endMs = Number.isFinite(updatedMs)
|
||||
? updatedMs
|
||||
: (Number.isFinite(createdMs) ? createdMs + durationMs : NaN);
|
||||
if (!Number.isFinite(endMs)) return null;
|
||||
|
||||
const afterTimestamp = Number.isFinite(createdMs) ? new Date(createdMs - 60_000) : undefined;
|
||||
const sessions = getNativeSessions(tool, { workingDir: this.projectPath, afterTimestamp });
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// Prefer sessions whose updatedAt is close to execution end time.
|
||||
const timeWindowMs = Math.max(5 * 60_000, durationMs + 2 * 60_000);
|
||||
const timeCandidates = sessions.filter(s => Math.abs(s.updatedAt.getTime() - endMs) <= timeWindowMs);
|
||||
const candidates = timeCandidates.length > 0
|
||||
? timeCandidates
|
||||
: sessions
|
||||
.map(session => ({ session, timeDiffMs: Math.abs(session.updatedAt.getTime() - endMs) }))
|
||||
.sort((a, b) => a.timeDiffMs - b.timeDiffMs)
|
||||
.slice(0, 50)
|
||||
.map(x => x.session);
|
||||
|
||||
const prompt = conversation.turns[0]?.prompt || '';
|
||||
const promptPrefix = prompt.substring(0, 200).trim();
|
||||
|
||||
const scored = candidates
|
||||
.map(session => {
|
||||
let promptMatch = false;
|
||||
if (promptPrefix) {
|
||||
try {
|
||||
const firstUserMessage = discoverer.extractFirstUserMessage(session.filePath);
|
||||
promptMatch = !!firstUserMessage && firstUserMessage.includes(promptPrefix);
|
||||
} catch {
|
||||
// Ignore extraction errors (still allow time-based match)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
promptMatch,
|
||||
timeDiffMs: Math.abs(session.updatedAt.getTime() - endMs)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.promptMatch !== b.promptMatch) return a.promptMatch ? -1 : 1;
|
||||
return a.timeDiffMs - b.timeDiffMs;
|
||||
});
|
||||
|
||||
const best = scored[0]?.session;
|
||||
if (!best) return null;
|
||||
|
||||
// Persist mapping for future loads (best-effort).
|
||||
try {
|
||||
this.saveNativeSessionMapping({
|
||||
ccw_id: ccwId,
|
||||
tool,
|
||||
native_session_id: best.sessionId,
|
||||
native_session_path: best.filePath,
|
||||
project_hash: best.projectHash,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch {
|
||||
// Ignore persistence errors; still attempt to return content.
|
||||
}
|
||||
|
||||
return await parseSessionFile(best.filePath, tool);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, basename, resolve } from 'path';
|
||||
import { join, basename, dirname, resolve } from 'path';
|
||||
// basename is used for extracting session ID from filename
|
||||
import { createHash } from 'crypto';
|
||||
import { homedir } from 'os';
|
||||
@@ -43,6 +43,48 @@ function getHomePath(): string {
|
||||
return homedir().replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a project root path for comparing against Gemini's `projects.json` keys
|
||||
* and `.project_root` marker file contents.
|
||||
*
|
||||
* On Windows Gemini uses lowercased absolute paths with backslashes.
|
||||
*/
|
||||
function normalizeGeminiProjectRootPath(projectDir: string): string {
|
||||
const absolutePath = resolve(projectDir);
|
||||
if (process.platform !== 'win32') return absolutePath;
|
||||
return absolutePath.replace(/\//g, '\\').toLowerCase();
|
||||
}
|
||||
|
||||
let geminiProjectsCache:
|
||||
| { configPath: string; mtimeMs: number; projects: Record<string, string> }
|
||||
| null = null;
|
||||
|
||||
/**
|
||||
* Load Gemini project mapping from `~/.gemini/projects.json` (best-effort).
|
||||
* Format: { "projects": { "<projectRoot>": "<projectName>" } }
|
||||
*/
|
||||
function getGeminiProjectsMap(): Record<string, string> | null {
|
||||
const configPath = join(getHomePath(), '.gemini', 'projects.json');
|
||||
|
||||
try {
|
||||
const stat = statSync(configPath);
|
||||
if (geminiProjectsCache?.configPath === configPath && geminiProjectsCache.mtimeMs === stat.mtimeMs) {
|
||||
return geminiProjectsCache.projects;
|
||||
}
|
||||
|
||||
const raw = readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projects?: Record<string, string> };
|
||||
if (!parsed.projects || typeof parsed.projects !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
geminiProjectsCache = { configPath, mtimeMs: stat.mtimeMs, projects: parsed.projects };
|
||||
return parsed.projects;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base session discoverer interface
|
||||
*/
|
||||
@@ -177,12 +219,76 @@ abstract class SessionDiscoverer {
|
||||
|
||||
/**
|
||||
* Gemini Session Discoverer
|
||||
* Path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
|
||||
* Legacy path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
|
||||
* Current path (Gemini CLI): ~/.gemini/tmp/<projectName>/chats/session-*.json
|
||||
*/
|
||||
class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'gemini';
|
||||
basePath = join(getHomePath(), '.gemini', 'tmp');
|
||||
|
||||
private getProjectFoldersForWorkingDir(workingDir: string): string[] {
|
||||
const folders = new Set<string>();
|
||||
|
||||
// Legacy: hashed folder
|
||||
const projectHash = calculateProjectHash(workingDir);
|
||||
if (existsSync(join(this.basePath, projectHash))) {
|
||||
folders.add(projectHash);
|
||||
}
|
||||
|
||||
// Current: project-name folder resolved via ~/.gemini/projects.json
|
||||
let hasProjectNameFolder = false;
|
||||
const projectsMap = getGeminiProjectsMap();
|
||||
if (projectsMap) {
|
||||
const normalized = normalizeGeminiProjectRootPath(workingDir);
|
||||
|
||||
// Prefer exact match first, then walk up parents (Gemini can map nested roots)
|
||||
let cursor: string | null = normalized;
|
||||
while (cursor) {
|
||||
const mapped = projectsMap[cursor];
|
||||
if (mapped) {
|
||||
const mappedPath = join(this.basePath, mapped);
|
||||
if (existsSync(mappedPath)) {
|
||||
folders.add(mapped);
|
||||
hasProjectNameFolder = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = dirname(cursor);
|
||||
cursor = parent !== cursor ? parent : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan for `.project_root` marker (best-effort; avoids missing mappings)
|
||||
if (!hasProjectNameFolder) {
|
||||
const normalized = normalizeGeminiProjectRootPath(workingDir);
|
||||
try {
|
||||
if (existsSync(this.basePath)) {
|
||||
for (const dirName of readdirSync(this.basePath)) {
|
||||
const fullPath = join(this.basePath, dirName);
|
||||
try {
|
||||
if (!statSync(fullPath).isDirectory()) continue;
|
||||
|
||||
const markerPath = join(fullPath, '.project_root');
|
||||
if (!existsSync(markerPath)) continue;
|
||||
|
||||
const marker = readFileSync(markerPath, 'utf8').trim();
|
||||
if (normalizeGeminiProjectRootPath(marker) === normalized) {
|
||||
folders.add(dirName);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid entries
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore scan failures
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(folders);
|
||||
}
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { workingDir, limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
@@ -193,9 +299,7 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
// If workingDir provided, only look in that project's folder
|
||||
let projectDirs: string[];
|
||||
if (workingDir) {
|
||||
const projectHash = calculateProjectHash(workingDir);
|
||||
const projectPath = join(this.basePath, projectHash);
|
||||
projectDirs = existsSync(projectPath) ? [projectHash] : [];
|
||||
projectDirs = this.getProjectFoldersForWorkingDir(workingDir);
|
||||
} else {
|
||||
projectDirs = readdirSync(this.basePath).filter(d => {
|
||||
const fullPath = join(this.basePath, d);
|
||||
@@ -203,8 +307,8 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectHash of projectDirs) {
|
||||
const chatsDir = join(this.basePath, projectHash, 'chats');
|
||||
for (const projectFolder of projectDirs) {
|
||||
const chatsDir = join(this.basePath, projectFolder, 'chats');
|
||||
if (!existsSync(chatsDir)) continue;
|
||||
|
||||
const sessionFiles = readdirSync(chatsDir)
|
||||
@@ -217,7 +321,10 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) {
|
||||
// sessionFiles are sorted descending by mtime, we can stop early
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(file.path, 'utf8'));
|
||||
@@ -225,7 +332,7 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
sessionId: content.sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash,
|
||||
projectHash: content.projectHash,
|
||||
createdAt: new Date(content.startTime || file.stat.birthtime),
|
||||
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
|
||||
});
|
||||
@@ -238,7 +345,14 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
|
||||
// Sort by updatedAt descending
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
const seen = new Set<string>();
|
||||
const uniqueSessions = sessions.filter(s => {
|
||||
if (seen.has(s.sessionId)) return false;
|
||||
seen.add(s.sessionId);
|
||||
return true;
|
||||
});
|
||||
|
||||
return limit ? uniqueSessions.slice(0, limit) : uniqueSessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_co
|
||||
},
|
||||
team: {
|
||||
type: 'string',
|
||||
description: 'Team name',
|
||||
description: 'Session ID (e.g., TLS-my-project-2026-02-27). Maps to .workflow/.team/{session-id}/.msg/. Use session ID, NOT team name.',
|
||||
},
|
||||
from: { type: 'string', description: '[log/list] Sender role' },
|
||||
to: { type: 'string', description: '[log/list] Recipient role' },
|
||||
|
||||
Reference in New Issue
Block a user